@omnifyjp/ts 5.0.6 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/php/audit-config-generator.d.ts +17 -0
- package/dist/php/audit-config-generator.js +70 -0
- package/dist/php/audit-observer-generator.d.ts +21 -0
- package/dist/php/audit-observer-generator.js +226 -0
- package/dist/php/audit-trait-generator.d.ts +31 -0
- package/dist/php/audit-trait-generator.js +197 -0
- package/dist/php/index.js +14 -0
- package/dist/php/model-generator.js +72 -6
- package/dist/php/schema-reader.d.ts +27 -7
- package/dist/php/schema-reader.js +96 -0
- package/dist/types.d.ts +40 -6
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates `config/omnify.php` — Laravel runtime config for the audit-log
|
|
3
|
+
* feature.
|
|
4
|
+
*
|
|
5
|
+
* The trait reads `config('omnify.audit.logExclude')`, `config('omnify.audit.logQueue')`,
|
|
6
|
+
* and `config('omnify.audit.logRetention')` at request time. We emit those
|
|
7
|
+
* values from the resolved schemas.json so the trait + Audit model stay in
|
|
8
|
+
* sync with whatever the user declared in `omnify.yaml`.
|
|
9
|
+
*
|
|
10
|
+
* Each value is wrapped in `env(...)` so operators can override per
|
|
11
|
+
* environment without re-running codegen — production might use a longer
|
|
12
|
+
* retention than dev, or a dedicated audits queue, without touching YAML.
|
|
13
|
+
*/
|
|
14
|
+
import { SchemaReader } from './schema-reader.js';
|
|
15
|
+
import type { GeneratedFile, PhpConfig } from './types.js';
|
|
16
|
+
/** Generate config/omnify.php with audit-log settings. */
|
|
17
|
+
export declare function generateAuditConfig(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates `config/omnify.php` — Laravel runtime config for the audit-log
|
|
3
|
+
* feature.
|
|
4
|
+
*
|
|
5
|
+
* The trait reads `config('omnify.audit.logExclude')`, `config('omnify.audit.logQueue')`,
|
|
6
|
+
* and `config('omnify.audit.logRetention')` at request time. We emit those
|
|
7
|
+
* values from the resolved schemas.json so the trait + Audit model stay in
|
|
8
|
+
* sync with whatever the user declared in `omnify.yaml`.
|
|
9
|
+
*
|
|
10
|
+
* Each value is wrapped in `env(...)` so operators can override per
|
|
11
|
+
* environment without re-running codegen — production might use a longer
|
|
12
|
+
* retention than dev, or a dedicated audits queue, without touching YAML.
|
|
13
|
+
*/
|
|
14
|
+
import { baseFile } from './types.js';
|
|
15
|
+
/** Render a JS string as a single-quoted PHP string literal. PHP accepts
|
|
16
|
+
* both quoting styles, but single quotes are the idiomatic choice for
|
|
17
|
+
* plain strings (no variable interpolation, no escape processing) and
|
|
18
|
+
* match the rest of the generator's output. Embedded single quotes and
|
|
19
|
+
* backslashes are escaped per PHP single-quoted-string rules. */
|
|
20
|
+
function phpStr(s) {
|
|
21
|
+
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
22
|
+
}
|
|
23
|
+
/** Generate config/omnify.php with audit-log settings. */
|
|
24
|
+
export function generateAuditConfig(reader, config) {
|
|
25
|
+
if (!reader.hasAuditLog())
|
|
26
|
+
return [];
|
|
27
|
+
const auditCfg = reader.getAuditConfig();
|
|
28
|
+
const excludes = auditCfg?.logExclude ?? [];
|
|
29
|
+
const retention = auditCfg?.logRetention ?? '';
|
|
30
|
+
const queue = auditCfg?.logQueue ?? '';
|
|
31
|
+
const excludeLines = excludes.length > 0
|
|
32
|
+
? excludes.map((c) => ` ${phpStr(c)},`).join('\n')
|
|
33
|
+
: '';
|
|
34
|
+
const configPath = config.rootPath
|
|
35
|
+
? `${config.rootPath}/config/omnify.php`
|
|
36
|
+
: 'config/omnify.php';
|
|
37
|
+
const content = `<?php
|
|
38
|
+
|
|
39
|
+
// Auto-generated by Omnify. DO NOT EDIT.
|
|
40
|
+
//
|
|
41
|
+
// This file mirrors the \`audit:\` block from omnify.yaml so the
|
|
42
|
+
// HasOmnifyAuditLog trait, WriteAuditLog job, and Audit model can read
|
|
43
|
+
// runtime settings via Laravel's config(). Each value can be overridden
|
|
44
|
+
// per environment via the env() variables below.
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
'audit' => [
|
|
48
|
+
// Sensitive columns scrubbed from old_values / new_values BEFORE
|
|
49
|
+
// any audit row is written. Per-schema \`options.audit.logExclude\`
|
|
50
|
+
// adds to this list at runtime — they are NOT a replacement.
|
|
51
|
+
'logExclude' => [
|
|
52
|
+
${excludeLines}
|
|
53
|
+
],
|
|
54
|
+
|
|
55
|
+
// Prunable retention period (e.g. "90d", "12w", "6m", "1y").
|
|
56
|
+
// Empty string keeps audits forever (no scheduled prune).
|
|
57
|
+
// Override per environment with OMNIFY_AUDIT_RETENTION.
|
|
58
|
+
'logRetention' => env('OMNIFY_AUDIT_RETENTION', ${phpStr(retention)}),
|
|
59
|
+
|
|
60
|
+
// Laravel queue name for WriteAuditLog dispatch. Empty string
|
|
61
|
+
// forces synchronous dispatch — only acceptable in dev or for
|
|
62
|
+
// very low-traffic apps. Production should set this to a queue
|
|
63
|
+
// name backed by a real driver (redis, sqs, etc.).
|
|
64
|
+
// Override per environment with OMNIFY_AUDIT_QUEUE.
|
|
65
|
+
'logQueue' => env('OMNIFY_AUDIT_QUEUE', ${phpStr(queue)}),
|
|
66
|
+
],
|
|
67
|
+
];
|
|
68
|
+
`;
|
|
69
|
+
return [baseFile(configPath, content)];
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the audit-log persistence layer:
|
|
3
|
+
* - app/Jobs/WriteAuditLog.php — queued (or sync) job that inserts a
|
|
4
|
+
* prepared audit payload into the audits table.
|
|
5
|
+
* - app/Models/Audit.php — Eloquent model with polymorphic auditable +
|
|
6
|
+
* user relations and Prunable retention support.
|
|
7
|
+
*
|
|
8
|
+
* The trait (HasOmnifyAuditLog) does ALL Auth + Request + scrub work on
|
|
9
|
+
* the request thread, so the job's only responsibility is the insert.
|
|
10
|
+
* That's deliberate: workers run with no Auth context, no Request facade,
|
|
11
|
+
* and no session — anything captured at job HANDLE time would be wrong.
|
|
12
|
+
*
|
|
13
|
+
* The Audit model uses Laravel's Prunable trait so `php artisan model:prune`
|
|
14
|
+
* (scheduled hourly / daily by the consumer) deletes rows older than the
|
|
15
|
+
* configured retention. When `omnify.audit.retention` is empty the
|
|
16
|
+
* `prunable()` query matches no rows (audits are kept forever).
|
|
17
|
+
*/
|
|
18
|
+
import { SchemaReader } from './schema-reader.js';
|
|
19
|
+
import type { GeneratedFile, PhpConfig } from './types.js';
|
|
20
|
+
/** Generate the WriteAuditLog job + Audit model. */
|
|
21
|
+
export declare function generateAuditObserver(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the audit-log persistence layer:
|
|
3
|
+
* - app/Jobs/WriteAuditLog.php — queued (or sync) job that inserts a
|
|
4
|
+
* prepared audit payload into the audits table.
|
|
5
|
+
* - app/Models/Audit.php — Eloquent model with polymorphic auditable +
|
|
6
|
+
* user relations and Prunable retention support.
|
|
7
|
+
*
|
|
8
|
+
* The trait (HasOmnifyAuditLog) does ALL Auth + Request + scrub work on
|
|
9
|
+
* the request thread, so the job's only responsibility is the insert.
|
|
10
|
+
* That's deliberate: workers run with no Auth context, no Request facade,
|
|
11
|
+
* and no session — anything captured at job HANDLE time would be wrong.
|
|
12
|
+
*
|
|
13
|
+
* The Audit model uses Laravel's Prunable trait so `php artisan model:prune`
|
|
14
|
+
* (scheduled hourly / daily by the consumer) deletes rows older than the
|
|
15
|
+
* configured retention. When `omnify.audit.retention` is empty the
|
|
16
|
+
* `prunable()` query matches no rows (audits are kept forever).
|
|
17
|
+
*/
|
|
18
|
+
import { baseFile } from './types.js';
|
|
19
|
+
/** Generate the WriteAuditLog job + Audit model. */
|
|
20
|
+
export function generateAuditObserver(reader, config) {
|
|
21
|
+
// Generation is gated by index.ts; defensively no-op if called when the
|
|
22
|
+
// feature is off so callers can't accidentally emit dead infra.
|
|
23
|
+
if (!reader.hasAuditLog())
|
|
24
|
+
return [];
|
|
25
|
+
const files = [];
|
|
26
|
+
files.push(generateWriteAuditLogJob(config));
|
|
27
|
+
files.push(generateAuditModel(reader, config));
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
function generateWriteAuditLogJob(config) {
|
|
31
|
+
const modelNamespace = config.models.namespace;
|
|
32
|
+
// Jobs always live at the conventional `app/Jobs/...` location. The
|
|
33
|
+
// path is rooted under `config.rootPath` so monorepo setups (Laravel
|
|
34
|
+
// in a subdir) still write to the right place.
|
|
35
|
+
const jobsPath = config.rootPath
|
|
36
|
+
? `${config.rootPath}/app/Jobs`
|
|
37
|
+
: 'app/Jobs';
|
|
38
|
+
const jobsNamespace = 'App\\Jobs';
|
|
39
|
+
const content = `<?php
|
|
40
|
+
|
|
41
|
+
namespace ${jobsNamespace};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persists a single audit-log row to the audits table.
|
|
45
|
+
*
|
|
46
|
+
* The trait builds the payload on the request thread (so Auth + Request
|
|
47
|
+
* data is captured correctly) and dispatches this job. The handler does
|
|
48
|
+
* nothing but the insert — it is intentionally trivial so it can run on
|
|
49
|
+
* cheap workers and survive retries cleanly.
|
|
50
|
+
*
|
|
51
|
+
* DO NOT EDIT — auto-generated by Omnify.
|
|
52
|
+
*
|
|
53
|
+
* @generated by omnify
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
use ${modelNamespace}\\Audit;
|
|
57
|
+
use Illuminate\\Bus\\Queueable;
|
|
58
|
+
use Illuminate\\Contracts\\Queue\\ShouldQueue;
|
|
59
|
+
use Illuminate\\Foundation\\Bus\\Dispatchable;
|
|
60
|
+
use Illuminate\\Queue\\InteractsWithQueue;
|
|
61
|
+
use Illuminate\\Queue\\SerializesModels;
|
|
62
|
+
|
|
63
|
+
class WriteAuditLog implements ShouldQueue
|
|
64
|
+
{
|
|
65
|
+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param array<string, mixed> $payload Pre-scrubbed insert row
|
|
69
|
+
* (auditable_type, auditable_id,
|
|
70
|
+
* event, old_values, new_values,
|
|
71
|
+
* user_type, user_id, url,
|
|
72
|
+
* ip_address, user_agent, tags,
|
|
73
|
+
* created_at).
|
|
74
|
+
*/
|
|
75
|
+
public function __construct(public array $payload)
|
|
76
|
+
{
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default retry behaviour: try 3 times with 5-second backoff between
|
|
81
|
+
* attempts. Audit rows are append-only and idempotent at the row
|
|
82
|
+
* level (no unique constraint trips on retry), so retries are safe.
|
|
83
|
+
*
|
|
84
|
+
* @return array<int, int>
|
|
85
|
+
*/
|
|
86
|
+
public function backoff(): array
|
|
87
|
+
{
|
|
88
|
+
return [5, 30, 120];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public int $tries = 3;
|
|
92
|
+
|
|
93
|
+
public function handle(): void
|
|
94
|
+
{
|
|
95
|
+
Audit::create($this->payload);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
`;
|
|
99
|
+
return baseFile(`${jobsPath}/WriteAuditLog.php`, content);
|
|
100
|
+
}
|
|
101
|
+
function generateAuditModel(reader, config) {
|
|
102
|
+
const modelNamespace = config.models.namespace;
|
|
103
|
+
const retention = reader.getAuditRetention();
|
|
104
|
+
// The retention parser is embedded in the model so the consumer can
|
|
105
|
+
// change `omnify.audit.retention` (or its environment variable) without
|
|
106
|
+
// re-generating. Empty retention = keep forever (prunable matches nothing).
|
|
107
|
+
const content = `<?php
|
|
108
|
+
|
|
109
|
+
namespace ${modelNamespace};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Audit history row.
|
|
113
|
+
*
|
|
114
|
+
* Polymorphic on both \`auditable_*\` (the model whose state changed) and
|
|
115
|
+
* \`user_*\` (the actor who triggered the change). No FK constraints on
|
|
116
|
+
* either side so records survive when the source row is hard-deleted —
|
|
117
|
+
* which is the whole point of an audit trail. Append-only: \`updated_at\`
|
|
118
|
+
* is NOT present on this table.
|
|
119
|
+
*
|
|
120
|
+
* Uses Laravel's Prunable trait. Wire it up in your scheduler:
|
|
121
|
+
*
|
|
122
|
+
* // app/Console/Kernel.php
|
|
123
|
+
* \\$schedule->command('model:prune', ['--model' => [\\App\\Models\\Audit::class]])->daily();
|
|
124
|
+
*
|
|
125
|
+
* Set retention via the \`omnify.audit.logRetention\` config key
|
|
126
|
+
* (e.g. \`90d\`, \`12w\`, \`6m\`, \`1y\`). An empty string disables prune
|
|
127
|
+
* (audits are kept forever).
|
|
128
|
+
*
|
|
129
|
+
* DO NOT EDIT — auto-generated by Omnify.
|
|
130
|
+
*
|
|
131
|
+
* @generated by omnify
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
use Illuminate\\Database\\Eloquent\\Builder;
|
|
135
|
+
use Illuminate\\Database\\Eloquent\\Model;
|
|
136
|
+
use Illuminate\\Database\\Eloquent\\Prunable;
|
|
137
|
+
use Illuminate\\Database\\Eloquent\\Relations\\MorphTo;
|
|
138
|
+
|
|
139
|
+
class Audit extends Model
|
|
140
|
+
{
|
|
141
|
+
use Prunable;
|
|
142
|
+
|
|
143
|
+
/** Audits are append-only — no \`updated_at\` column on this table. */
|
|
144
|
+
public $timestamps = false;
|
|
145
|
+
|
|
146
|
+
/** @var list<string> */
|
|
147
|
+
protected $fillable = [
|
|
148
|
+
'auditable_type',
|
|
149
|
+
'auditable_id',
|
|
150
|
+
'event',
|
|
151
|
+
'old_values',
|
|
152
|
+
'new_values',
|
|
153
|
+
'user_type',
|
|
154
|
+
'user_id',
|
|
155
|
+
'url',
|
|
156
|
+
'ip_address',
|
|
157
|
+
'user_agent',
|
|
158
|
+
'tags',
|
|
159
|
+
'created_at',
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/** @var array<string, string> */
|
|
163
|
+
protected $casts = [
|
|
164
|
+
'old_values' => 'array',
|
|
165
|
+
'new_values' => 'array',
|
|
166
|
+
'created_at' => 'datetime',
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The audited record. \`auditable_id\` is unconstrained so the audit
|
|
171
|
+
* row survives a hard-delete of the source row.
|
|
172
|
+
*/
|
|
173
|
+
public function auditable(): MorphTo
|
|
174
|
+
{
|
|
175
|
+
return $this->morphTo();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The actor who triggered the change. Null \`user_*\` columns
|
|
180
|
+
* indicate a system-driven write (CLI, scheduled job, internal API).
|
|
181
|
+
*/
|
|
182
|
+
public function user(): MorphTo
|
|
183
|
+
{
|
|
184
|
+
return $this->morphTo();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Prunable scope. Reads \`omnify.audit.logRetention\` at runtime so the
|
|
189
|
+
* consumer can change retention without regenerating code.
|
|
190
|
+
*
|
|
191
|
+
* - \`90d\` → 90 days
|
|
192
|
+
* - \`12w\` → 12 weeks
|
|
193
|
+
* - \`6m\` → 6 months
|
|
194
|
+
* - \`1y\` → 1 year
|
|
195
|
+
*
|
|
196
|
+
* Empty / unparseable retention → matches no rows (kept forever).
|
|
197
|
+
*/
|
|
198
|
+
public function prunable(): Builder
|
|
199
|
+
{
|
|
200
|
+
$retention = (string) config('omnify.audit.logRetention', ${JSON.stringify(retention)});
|
|
201
|
+
if ($retention === '') {
|
|
202
|
+
return static::query()->whereRaw('1 = 0');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!preg_match('/^(\\\\d+)([dwmy])$/', $retention, $m)) {
|
|
206
|
+
// Misconfigured retention — fail safe by keeping all rows
|
|
207
|
+
// rather than silently deleting based on a guessed period.
|
|
208
|
+
return static::query()->whereRaw('1 = 0');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
$n = (int) $m[1];
|
|
212
|
+
$cutoff = match ($m[2]) {
|
|
213
|
+
'd' => now()->subDays($n),
|
|
214
|
+
'w' => now()->subWeeks($n),
|
|
215
|
+
'm' => now()->subMonths($n),
|
|
216
|
+
'y' => now()->subYears($n),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return static::query()->where('created_at', '<', $cutoff);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
`;
|
|
223
|
+
// Audit model lives next to the rest of the generated models so
|
|
224
|
+
// morphTo's class resolution finds it via the standard namespace.
|
|
225
|
+
return baseFile(`${config.models.path}/Audit.php`, content);
|
|
226
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the HasOmnifyAuditLog trait for models with audit-log opt-in.
|
|
3
|
+
*
|
|
4
|
+
* The trait is the single hook point that connects every audited Eloquent
|
|
5
|
+
* model to the central `audits` history table. Adding `use HasOmnifyAuditLog;`
|
|
6
|
+
* to a model makes it record `created` / `updated` / `deleted` / `restored`
|
|
7
|
+
* events, with dirty-only diffing on update and sensitive-column scrubbing
|
|
8
|
+
* before serialization.
|
|
9
|
+
*
|
|
10
|
+
* Performance design (audit-log is a hot path on every write):
|
|
11
|
+
* - WriteAuditLog is dispatched as a queued job when `omnify.audit.queue`
|
|
12
|
+
* is non-empty. The web request returns immediately; the audit row is
|
|
13
|
+
* inserted by a worker. Sync fallback is only used in dev / low-traffic
|
|
14
|
+
* mode (queue == "").
|
|
15
|
+
* - Auth, URL, IP, and User-Agent are captured at DISPATCH time, not at
|
|
16
|
+
* job HANDLE time — workers have no Auth context and no Request facade.
|
|
17
|
+
* - Updated events skip dispatch entirely when `getDirty()` is empty
|
|
18
|
+
* (re-saves with no real changes don't emit phantom audit rows).
|
|
19
|
+
* - Sensitive columns listed in `$auditExclude` are stripped from
|
|
20
|
+
* `old_values` / `new_values` BEFORE the JSON payload is built, so a
|
|
21
|
+
* stolen audits dump can't leak passwords or two-factor secrets.
|
|
22
|
+
* - String columns are truncated to fit the table schema (`url` 2048,
|
|
23
|
+
* `user_agent` 1023, `tags` 255) so a hostile UA header can't blow up
|
|
24
|
+
* the insert with a row-too-large error.
|
|
25
|
+
* - `Model::withoutAudit(fn() => ...)` lets seeders / migrations / batch
|
|
26
|
+
* imports temporarily disable auditing for bulk writes that would
|
|
27
|
+
* otherwise dispatch hundreds of thousands of jobs.
|
|
28
|
+
*/
|
|
29
|
+
import type { GeneratedFile, PhpConfig } from './types.js';
|
|
30
|
+
/** Generate the HasOmnifyAuditLog trait. */
|
|
31
|
+
export declare function generateAuditTrait(config: PhpConfig): GeneratedFile[];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the HasOmnifyAuditLog trait for models with audit-log opt-in.
|
|
3
|
+
*
|
|
4
|
+
* The trait is the single hook point that connects every audited Eloquent
|
|
5
|
+
* model to the central `audits` history table. Adding `use HasOmnifyAuditLog;`
|
|
6
|
+
* to a model makes it record `created` / `updated` / `deleted` / `restored`
|
|
7
|
+
* events, with dirty-only diffing on update and sensitive-column scrubbing
|
|
8
|
+
* before serialization.
|
|
9
|
+
*
|
|
10
|
+
* Performance design (audit-log is a hot path on every write):
|
|
11
|
+
* - WriteAuditLog is dispatched as a queued job when `omnify.audit.queue`
|
|
12
|
+
* is non-empty. The web request returns immediately; the audit row is
|
|
13
|
+
* inserted by a worker. Sync fallback is only used in dev / low-traffic
|
|
14
|
+
* mode (queue == "").
|
|
15
|
+
* - Auth, URL, IP, and User-Agent are captured at DISPATCH time, not at
|
|
16
|
+
* job HANDLE time — workers have no Auth context and no Request facade.
|
|
17
|
+
* - Updated events skip dispatch entirely when `getDirty()` is empty
|
|
18
|
+
* (re-saves with no real changes don't emit phantom audit rows).
|
|
19
|
+
* - Sensitive columns listed in `$auditExclude` are stripped from
|
|
20
|
+
* `old_values` / `new_values` BEFORE the JSON payload is built, so a
|
|
21
|
+
* stolen audits dump can't leak passwords or two-factor secrets.
|
|
22
|
+
* - String columns are truncated to fit the table schema (`url` 2048,
|
|
23
|
+
* `user_agent` 1023, `tags` 255) so a hostile UA header can't blow up
|
|
24
|
+
* the insert with a row-too-large error.
|
|
25
|
+
* - `Model::withoutAudit(fn() => ...)` lets seeders / migrations / batch
|
|
26
|
+
* imports temporarily disable auditing for bulk writes that would
|
|
27
|
+
* otherwise dispatch hundreds of thousands of jobs.
|
|
28
|
+
*/
|
|
29
|
+
import { baseFile, resolveGlobalTraitPath, resolveGlobalTraitNamespace, } from './types.js';
|
|
30
|
+
/** Generate the HasOmnifyAuditLog trait. */
|
|
31
|
+
export function generateAuditTrait(config) {
|
|
32
|
+
const traitsNamespace = resolveGlobalTraitNamespace(config, config.models.baseNamespace + '\\Traits');
|
|
33
|
+
const modelNamespace = config.models.namespace;
|
|
34
|
+
const content = `<?php
|
|
35
|
+
|
|
36
|
+
namespace ${traitsNamespace};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Trait that records every Eloquent lifecycle event into the central
|
|
40
|
+
* audits history table. Apply via \`use HasOmnifyAuditLog;\` on any model
|
|
41
|
+
* whose schema has \`options.audit.log: true\` (or when the global
|
|
42
|
+
* \`audit.log\` flag is enabled).
|
|
43
|
+
*
|
|
44
|
+
* DO NOT EDIT — auto-generated by Omnify. Any manual changes are lost on
|
|
45
|
+
* the next \`omnify generate\` run.
|
|
46
|
+
*
|
|
47
|
+
* @generated by omnify
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
use ${modelNamespace}\\Audit;
|
|
51
|
+
use Illuminate\\Support\\Facades\\Auth;
|
|
52
|
+
use Illuminate\\Support\\Str;
|
|
53
|
+
|
|
54
|
+
trait HasOmnifyAuditLog
|
|
55
|
+
{
|
|
56
|
+
/**
|
|
57
|
+
* Per-class kill switch flipped by withoutAudit(). Keyed by class so
|
|
58
|
+
* disabling on one model doesn't accidentally silence another running
|
|
59
|
+
* inside the same callback.
|
|
60
|
+
*
|
|
61
|
+
* @var array<class-string, bool>
|
|
62
|
+
*/
|
|
63
|
+
protected static array $omnifyAuditingDisabled = [];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Eloquent boot hook. Wires the four model events into the audit
|
|
67
|
+
* recorder. Restored is gated on a softDelete-capable model; calling
|
|
68
|
+
* \`static::restored\` on a non-softDelete model is a no-op so this is
|
|
69
|
+
* safe across the board.
|
|
70
|
+
*/
|
|
71
|
+
public static function bootHasOmnifyAuditLog(): void
|
|
72
|
+
{
|
|
73
|
+
static::created(function ($model) {
|
|
74
|
+
$model->writeOmnifyAudit('created', null, $model->getAttributes());
|
|
75
|
+
});
|
|
76
|
+
static::updated(function ($model) {
|
|
77
|
+
$dirty = $model->getDirty();
|
|
78
|
+
if (empty($dirty)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
$old = array_intersect_key($model->getOriginal(), $dirty);
|
|
82
|
+
$model->writeOmnifyAudit('updated', $old, $dirty);
|
|
83
|
+
});
|
|
84
|
+
static::deleted(function ($model) {
|
|
85
|
+
$event = method_exists($model, 'isForceDeleting') && $model->isForceDeleting()
|
|
86
|
+
? 'deleted'
|
|
87
|
+
: (method_exists($model, 'trashed') ? 'soft_deleted' : 'deleted');
|
|
88
|
+
$model->writeOmnifyAudit($event, $model->getOriginal(), null);
|
|
89
|
+
});
|
|
90
|
+
if (method_exists(static::class, 'restored')) {
|
|
91
|
+
static::restored(function ($model) {
|
|
92
|
+
$model->writeOmnifyAudit('restored', null, $model->getAttributes());
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the audit payload and dispatch it. All Auth / Request capture
|
|
99
|
+
* happens here on the request thread, so the worker only needs to
|
|
100
|
+
* persist the row.
|
|
101
|
+
*
|
|
102
|
+
* @param array<string, mixed>|null $oldValues
|
|
103
|
+
* @param array<string, mixed>|null $newValues
|
|
104
|
+
*/
|
|
105
|
+
protected function writeOmnifyAudit(string $event, ?array $oldValues, ?array $newValues): void
|
|
106
|
+
{
|
|
107
|
+
if (static::$omnifyAuditingDisabled[static::class] ?? false) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
$exclude = array_unique(array_merge(
|
|
112
|
+
(array) config('omnify.audit.logExclude', []),
|
|
113
|
+
(array) (static::$auditExclude ?? [])
|
|
114
|
+
));
|
|
115
|
+
if (!empty($exclude)) {
|
|
116
|
+
$flip = array_flip($exclude);
|
|
117
|
+
if ($oldValues !== null) {
|
|
118
|
+
$oldValues = array_diff_key($oldValues, $flip);
|
|
119
|
+
}
|
|
120
|
+
if ($newValues !== null) {
|
|
121
|
+
$newValues = array_diff_key($newValues, $flip);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
$request = function_exists('request') ? request() : null;
|
|
126
|
+
$user = Auth::user();
|
|
127
|
+
$tags = static::$auditTags ?? [];
|
|
128
|
+
|
|
129
|
+
$payload = [
|
|
130
|
+
'auditable_type' => $this->getMorphClass(),
|
|
131
|
+
'auditable_id' => $this->getKey(),
|
|
132
|
+
'event' => $event,
|
|
133
|
+
'old_values' => $oldValues,
|
|
134
|
+
'new_values' => $newValues,
|
|
135
|
+
'user_type' => $user ? $user->getMorphClass() : null,
|
|
136
|
+
'user_id' => $user ? $user->getKey() : null,
|
|
137
|
+
'url' => $request ? Str::limit($request->fullUrl(), 2048, '') : null,
|
|
138
|
+
'ip_address' => $request ? $request->ip() : null,
|
|
139
|
+
'user_agent' => $request ? Str::limit((string) $request->userAgent(), 1023, '') : null,
|
|
140
|
+
'tags' => !empty($tags) ? Str::limit(implode(',', (array) $tags), 255, '') : null,
|
|
141
|
+
'created_at' => now(),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
$queue = (string) config('omnify.audit.logQueue', '');
|
|
145
|
+
if ($queue !== '') {
|
|
146
|
+
\\App\\Jobs\\WriteAuditLog::dispatch($payload)->onQueue($queue);
|
|
147
|
+
} else {
|
|
148
|
+
\\App\\Jobs\\WriteAuditLog::dispatchSync($payload);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run \`$callback\` with audit-log dispatch suppressed for THIS model
|
|
154
|
+
* class. Use for seeders, bulk imports, and migration data fixups
|
|
155
|
+
* where you don't want hundreds of thousands of audit rows. Always
|
|
156
|
+
* restores the previous state, even on exception.
|
|
157
|
+
*
|
|
158
|
+
* @template T
|
|
159
|
+
* @param callable(): T $callback
|
|
160
|
+
* @return T
|
|
161
|
+
*/
|
|
162
|
+
public static function withoutAudit(callable $callback): mixed
|
|
163
|
+
{
|
|
164
|
+
$prev = static::$omnifyAuditingDisabled[static::class] ?? false;
|
|
165
|
+
static::$omnifyAuditingDisabled[static::class] = true;
|
|
166
|
+
try {
|
|
167
|
+
return $callback();
|
|
168
|
+
} finally {
|
|
169
|
+
static::$omnifyAuditingDisabled[static::class] = $prev;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Direct helper so external code can persist an audit row without
|
|
175
|
+
* going through Eloquent events (e.g. recording a sensitive admin
|
|
176
|
+
* action that's not a model write). Routed through the same job
|
|
177
|
+
* pipeline so retention + queue config still apply.
|
|
178
|
+
*
|
|
179
|
+
* @param array<string, mixed>|null $oldValues
|
|
180
|
+
* @param array<string, mixed>|null $newValues
|
|
181
|
+
*/
|
|
182
|
+
public function recordAudit(string $event, ?array $oldValues = null, ?array $newValues = null): void
|
|
183
|
+
{
|
|
184
|
+
$this->writeOmnifyAudit($event, $oldValues, $newValues);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Eloquent relation back to every audit row that targets this model. */
|
|
188
|
+
public function audits(): \\Illuminate\\Database\\Eloquent\\Relations\\MorphMany
|
|
189
|
+
{
|
|
190
|
+
return $this->morphMany(Audit::class, 'auditable');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
return [
|
|
195
|
+
baseFile(resolveGlobalTraitPath(config, 'HasOmnifyAuditLog.php', config.models.basePath + '/Traits'), content),
|
|
196
|
+
];
|
|
197
|
+
}
|
package/dist/php/index.js
CHANGED
|
@@ -25,6 +25,9 @@ import { generatePolicies } from './policy-generator.js';
|
|
|
25
25
|
import { generateFileModels } from './file-model-generator.js';
|
|
26
26
|
import { generateFileTrait } from './file-trait-generator.js';
|
|
27
27
|
import { generateFileCleanup } from './file-cleanup-generator.js';
|
|
28
|
+
import { generateAuditTrait } from './audit-trait-generator.js';
|
|
29
|
+
import { generateAuditObserver } from './audit-observer-generator.js';
|
|
30
|
+
import { generateAuditConfig } from './audit-config-generator.js';
|
|
28
31
|
import { generateSchemaConfig } from './schema-config-generator.js';
|
|
29
32
|
import { generateTranslatableConfig } from './translatable-config-generator.js';
|
|
30
33
|
import { baseFile } from './types.js';
|
|
@@ -57,6 +60,17 @@ export function generatePhp(data, overrides) {
|
|
|
57
60
|
files.push(...generateFileFactory(reader, config));
|
|
58
61
|
files.push(...generateFileCleanup(reader, config));
|
|
59
62
|
}
|
|
63
|
+
// Audit-log infrastructure (only when at least one schema opts in,
|
|
64
|
+
// either via global `audit.log: true` or per-schema override).
|
|
65
|
+
// Emits the trait, the queued WriteAuditLog job, the Audit model,
|
|
66
|
+
// and the runtime config that powers them. The audits TABLE itself
|
|
67
|
+
// is emitted by omnify-go (synthesized once globally) — this layer
|
|
68
|
+
// is purely the application-level codegen.
|
|
69
|
+
if (reader.hasAuditLog()) {
|
|
70
|
+
files.push(...generateAuditTrait(config));
|
|
71
|
+
files.push(...generateAuditObserver(reader, config));
|
|
72
|
+
files.push(...generateAuditConfig(reader, config));
|
|
73
|
+
}
|
|
60
74
|
// CRUD API files (only for schemas with options.api)
|
|
61
75
|
if (reader.hasApiSchemas()) {
|
|
62
76
|
files.push(...generateSchemaConfig(reader, config));
|
|
@@ -64,11 +64,17 @@ function generateBaseModel(name, schema, reader, config) {
|
|
|
64
64
|
const prop = properties[p];
|
|
65
65
|
return prop && prop['type'] === 'File';
|
|
66
66
|
});
|
|
67
|
-
|
|
67
|
+
// Audit-log opt-in (folded global default + per-schema override).
|
|
68
|
+
// Drives the `use HasOmnifyAuditLog;` import + trait declaration AND
|
|
69
|
+
// the per-schema `$auditExclude` / `$auditTags` static properties on
|
|
70
|
+
// the generated base model.
|
|
71
|
+
const hasAuditLog = reader.isAuditLogEnabled(name);
|
|
72
|
+
const imports = buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait, needsUlidTrait, hasNestedSet, config.nestedset.namespace, hasFiles, modelNamespace, localesNamespace, traitsNamespace, sharedModelsNamespace, config, hasAuditLog);
|
|
68
73
|
const docProperties = buildDocProperties(properties, expandedProperties, propertyOrder);
|
|
69
74
|
const baseClass = isAuthenticatable ? 'Authenticatable' : 'BaseModel';
|
|
70
75
|
const implementsClause = hasTranslatable ? ' implements TranslatableContract' : '';
|
|
71
|
-
const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet, hasFiles);
|
|
76
|
+
const traits = buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait, needsUlidTrait, hasNestedSet, hasFiles, hasAuditLog);
|
|
77
|
+
const auditLogProperties = buildAuditLogProperties(name, reader);
|
|
72
78
|
const fillable = buildFillable(properties, expandedProperties, propertyOrder);
|
|
73
79
|
const hidden = buildHidden(properties, expandedProperties, propertyOrder);
|
|
74
80
|
const appends = buildAppends(expandedProperties);
|
|
@@ -174,7 +180,7 @@ ${appends} ];
|
|
|
174
180
|
return [
|
|
175
181
|
${casts} ];
|
|
176
182
|
}
|
|
177
|
-
${relations}${accessors}${fileAccessors}${auditSection}${nestedSetMethod}
|
|
183
|
+
${auditLogProperties}${relations}${accessors}${fileAccessors}${auditSection}${nestedSetMethod}
|
|
178
184
|
}
|
|
179
185
|
`;
|
|
180
186
|
return baseFile(resolveModularBasePath(config, name, 'Models', `${modelName}BaseModel.php`, config.models.basePath), content);
|
|
@@ -229,7 +235,7 @@ ${traits.join('\n')}
|
|
|
229
235
|
`;
|
|
230
236
|
return userFile(`${config.models.path}/${modelName}.php`, content);
|
|
231
237
|
}
|
|
232
|
-
function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config) {
|
|
238
|
+
function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable, hasTranslatable, properties, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, nestedSetNamespace = 'Aimeos\\Nestedset', hasFiles = false, modelNamespace = '', localesNamespace = '', traitsNamespace = '', sharedModelsNamespace = '', config, hasAuditLog = false) {
|
|
233
239
|
const lines = [];
|
|
234
240
|
// Import BaseModel from shared namespace when in modular mode
|
|
235
241
|
if (sharedModelsNamespace && sharedModelsNamespace !== baseNamespace && !isAuthenticatable) {
|
|
@@ -268,6 +274,16 @@ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable
|
|
|
268
274
|
lines.push(`use ${modelNamespace}\\File;`);
|
|
269
275
|
}
|
|
270
276
|
}
|
|
277
|
+
if (hasAuditLog) {
|
|
278
|
+
// HasOmnifyAuditLog is a global Omnify trait emitted by
|
|
279
|
+
// audit-trait-generator.ts. Same global-traits placement as HasFiles
|
|
280
|
+
// so consumers can update the trait in place without per-schema
|
|
281
|
+
// duplication.
|
|
282
|
+
const auditTraitsNs = config
|
|
283
|
+
? resolveGlobalTraitNamespace(config, baseNamespace + '\\Traits')
|
|
284
|
+
: (traitsNamespace || baseNamespace + '\\Traits');
|
|
285
|
+
lines.push(`use ${auditTraitsNs}\\HasOmnifyAuditLog;`);
|
|
286
|
+
}
|
|
271
287
|
if (hasSoftDelete) {
|
|
272
288
|
lines.push('use Illuminate\\Database\\Eloquent\\SoftDeletes;');
|
|
273
289
|
}
|
|
@@ -321,11 +337,13 @@ function buildDocProperties(properties, expandedProperties, propertyOrder) {
|
|
|
321
337
|
}
|
|
322
338
|
return lines.length === 0 ? '' : lines.join('\n') + '\n';
|
|
323
339
|
}
|
|
324
|
-
function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false) {
|
|
340
|
+
function buildTraits(hasSoftDelete, isAuthenticatable, hasTranslatable, needsUuidTrait = false, needsUlidTrait = false, hasNestedSet = false, hasFiles = false, hasAuditLog = false) {
|
|
325
341
|
const lines = [];
|
|
326
342
|
lines.push(' use HasLocalizedDisplayName;');
|
|
327
343
|
if (hasFiles)
|
|
328
344
|
lines.push(' use HasFiles;');
|
|
345
|
+
if (hasAuditLog)
|
|
346
|
+
lines.push(' use HasOmnifyAuditLog;');
|
|
329
347
|
if (needsUuidTrait)
|
|
330
348
|
lines.push(' use HasUuids;');
|
|
331
349
|
if (needsUlidTrait)
|
|
@@ -730,6 +748,54 @@ function buildNestedSetParentIdMethod(parentColumn) {
|
|
|
730
748
|
}
|
|
731
749
|
`;
|
|
732
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Emits per-schema audit-log static properties on the base model:
|
|
753
|
+
* - `$auditExclude` is the merged scrub list (global defaults +
|
|
754
|
+
* per-schema additions) — read by HasOmnifyAuditLog at write time
|
|
755
|
+
* so sensitive columns are stripped from old_values / new_values
|
|
756
|
+
* BEFORE serialization.
|
|
757
|
+
* - `$auditTags` is the static tag list applied to every audit row
|
|
758
|
+
* from this model; useful for grouping (e.g. ["billing"]).
|
|
759
|
+
*
|
|
760
|
+
* Empty arrays are omitted entirely so the trait falls through to its
|
|
761
|
+
* own static defaults — keeps the generated PHP minimal.
|
|
762
|
+
*/
|
|
763
|
+
function buildAuditLogProperties(schemaName, reader) {
|
|
764
|
+
if (!reader.isAuditLogEnabled(schemaName))
|
|
765
|
+
return '';
|
|
766
|
+
const excludes = reader.getAuditExcludesFor(schemaName);
|
|
767
|
+
const tags = reader.getAuditTagsFor(schemaName);
|
|
768
|
+
const parts = [];
|
|
769
|
+
if (excludes.length > 0) {
|
|
770
|
+
const lines = excludes.map((c) => ` ${JSON.stringify(c).replace(/"/g, "'")},`).join('\n');
|
|
771
|
+
parts.push(`
|
|
772
|
+
/**
|
|
773
|
+
* Columns scrubbed from old_values / new_values BEFORE the audit row
|
|
774
|
+
* is built. Merged at runtime with the global \`config('omnify.audit.logExclude')\`
|
|
775
|
+
* defaults — they are NOT a replacement.
|
|
776
|
+
*
|
|
777
|
+
* @var array<int, string>
|
|
778
|
+
*/
|
|
779
|
+
protected static array $auditExclude = [
|
|
780
|
+
${lines}
|
|
781
|
+
];`);
|
|
782
|
+
}
|
|
783
|
+
if (tags.length > 0) {
|
|
784
|
+
const lines = tags.map((t) => ` ${JSON.stringify(t).replace(/"/g, "'")},`).join('\n');
|
|
785
|
+
parts.push(`
|
|
786
|
+
/**
|
|
787
|
+
* Static tags applied to every audit row from this model. Surfaces
|
|
788
|
+
* in the audits table's \`tags\` column for grouping in the activity
|
|
789
|
+
* feed.
|
|
790
|
+
*
|
|
791
|
+
* @var array<int, string>
|
|
792
|
+
*/
|
|
793
|
+
protected static array $auditTags = [
|
|
794
|
+
${lines}
|
|
795
|
+
];`);
|
|
796
|
+
}
|
|
797
|
+
return parts.length === 0 ? '' : parts.join('\n') + '\n';
|
|
798
|
+
}
|
|
733
799
|
function buildAuditSection(options, reader, modelNamespace) {
|
|
734
800
|
const opts = options;
|
|
735
801
|
const audit = opts?.['audit'];
|
|
@@ -741,7 +807,7 @@ function buildAuditSection(options, reader, modelNamespace) {
|
|
|
741
807
|
if (!hasCreatedBy && !hasUpdatedBy && !hasDeletedBy && !hasDefaultOrder)
|
|
742
808
|
return '';
|
|
743
809
|
// Get audit model name from schemas.json auditConfig
|
|
744
|
-
const auditModel = reader.
|
|
810
|
+
const auditModel = reader.getAuditConfig()?.model ?? 'User';
|
|
745
811
|
const fqcn = `\\${modelNamespace}\\${auditModel}`;
|
|
746
812
|
const parts = [];
|
|
747
813
|
// Model events (booted)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Port of SchemaReader.php — reads schemas.json data.
|
|
3
3
|
*/
|
|
4
|
-
import type { SchemasJson, SchemaDefinition, ExpandedProperty, PackageExportInfo } from '../types.js';
|
|
4
|
+
import type { SchemasJson, SchemaDefinition, ExpandedProperty, PackageExportInfo, AuditConfigExport } from '../types.js';
|
|
5
5
|
export declare class SchemaReader {
|
|
6
6
|
private data;
|
|
7
7
|
constructor(data: SchemasJson);
|
|
@@ -64,12 +64,32 @@ export declare class SchemaReader {
|
|
|
64
64
|
/** Get all schemas that have File-type properties. */
|
|
65
65
|
getSchemasWithFileProperties(): Record<string, SchemaDefinition>;
|
|
66
66
|
/** Get global audit config from schemas.json. */
|
|
67
|
-
getAuditConfig():
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
getAuditConfig(): AuditConfigExport | null;
|
|
68
|
+
/** True when ANY schema (project or package) is audited — the audits
|
|
69
|
+
* table + supporting infra (trait, job, model) are emitted as a unit. */
|
|
70
|
+
hasAuditLog(): boolean;
|
|
71
|
+
/** Resolve per-schema audit-log enablement: per-schema override wins
|
|
72
|
+
* over the global default. Returns false for non-object schemas. */
|
|
73
|
+
isAuditLogEnabled(schemaName: string): boolean;
|
|
74
|
+
/** Object schemas with audit-log enabled (folded global + per-schema). */
|
|
75
|
+
getSchemasWithAuditLog(): Record<string, SchemaDefinition>;
|
|
76
|
+
/** Merged scrub list for a schema (global defaults + per-schema overrides).
|
|
77
|
+
* Duplicates are de-duplicated; order is global-first then per-schema. */
|
|
78
|
+
getAuditExcludesFor(schemaName: string): string[];
|
|
79
|
+
/** Static tag list configured for a schema (or empty). Used by the
|
|
80
|
+
* trait to populate the `tags` column on every audit row. */
|
|
81
|
+
getAuditTagsFor(schemaName: string): string[];
|
|
82
|
+
/** Audit user-model schema name (typically `User`). Required when the
|
|
83
|
+
* feature is enabled — the omnify-go validator already rejects
|
|
84
|
+
* `audit.log: true` without a model, so this returns "" only when the
|
|
85
|
+
* feature is itself off. */
|
|
86
|
+
getAuditUserModel(): string;
|
|
87
|
+
/** Laravel queue name for WriteAuditLog dispatch. Empty string means
|
|
88
|
+
* sync fallback (low-traffic / dev mode). */
|
|
89
|
+
getAuditQueue(): string;
|
|
90
|
+
/** Prunable retention period (e.g. `90d`). Empty string means audits
|
|
91
|
+
* are kept forever — no Prunable scope is registered. */
|
|
92
|
+
getAuditRetention(): string;
|
|
73
93
|
/** Get schemas that have service options configured (options.service). */
|
|
74
94
|
getSchemasWithService(): Record<string, import('../types.js').SchemaDefinition>;
|
|
75
95
|
/** Check if any schema has service options. */
|
|
@@ -214,6 +214,102 @@ export class SchemaReader {
|
|
|
214
214
|
getAuditConfig() {
|
|
215
215
|
return this.data.auditConfig ?? null;
|
|
216
216
|
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Audit-log feature helpers (audits-table history)
|
|
219
|
+
//
|
|
220
|
+
// The audit-log feature is OFF unless either (a) the global flag
|
|
221
|
+
// `auditConfig.log === true` is set, or (b) at least one schema has
|
|
222
|
+
// `options.audit.log === true`. The trait, observer job, and Audit model
|
|
223
|
+
// are emitted as a unit — gated by `hasAuditLog()`. Per-schema state is
|
|
224
|
+
// resolved by `isAuditLogEnabled(name)` which folds the global flag and
|
|
225
|
+
// the per-schema override into a single boolean.
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
/** True when ANY schema (project or package) is audited — the audits
|
|
228
|
+
* table + supporting infra (trait, job, model) are emitted as a unit. */
|
|
229
|
+
hasAuditLog() {
|
|
230
|
+
const cfg = this.getAuditConfig();
|
|
231
|
+
if (cfg?.log === true) {
|
|
232
|
+
// Global on, but the feature still requires at least one auditable
|
|
233
|
+
// OBJECT schema (no point emitting infra for a project that has only
|
|
234
|
+
// enums / partials).
|
|
235
|
+
for (const schema of Object.values(this.getObjectSchemas())) {
|
|
236
|
+
if (schema.options?.audit?.log !== false)
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
// Global on but every schema explicitly opts out — feature is dead.
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
for (const schema of Object.values(this.getObjectSchemas())) {
|
|
243
|
+
if (schema.options?.audit?.log === true)
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
/** Resolve per-schema audit-log enablement: per-schema override wins
|
|
249
|
+
* over the global default. Returns false for non-object schemas. */
|
|
250
|
+
isAuditLogEnabled(schemaName) {
|
|
251
|
+
const schema = this.getSchema(schemaName);
|
|
252
|
+
if (!schema)
|
|
253
|
+
return false;
|
|
254
|
+
const kind = schema.kind ?? 'object';
|
|
255
|
+
if (kind !== 'object')
|
|
256
|
+
return false;
|
|
257
|
+
const perSchema = schema.options?.audit?.log;
|
|
258
|
+
if (perSchema === true)
|
|
259
|
+
return true;
|
|
260
|
+
if (perSchema === false)
|
|
261
|
+
return false;
|
|
262
|
+
return this.getAuditConfig()?.log === true;
|
|
263
|
+
}
|
|
264
|
+
/** Object schemas with audit-log enabled (folded global + per-schema). */
|
|
265
|
+
getSchemasWithAuditLog() {
|
|
266
|
+
const result = {};
|
|
267
|
+
for (const [name, schema] of Object.entries(this.getObjectSchemas())) {
|
|
268
|
+
if (this.isAuditLogEnabled(name)) {
|
|
269
|
+
result[name] = schema;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
/** Merged scrub list for a schema (global defaults + per-schema overrides).
|
|
275
|
+
* Duplicates are de-duplicated; order is global-first then per-schema. */
|
|
276
|
+
getAuditExcludesFor(schemaName) {
|
|
277
|
+
const schema = this.getSchema(schemaName);
|
|
278
|
+
const global = this.getAuditConfig()?.logExclude ?? [];
|
|
279
|
+
const local = schema?.options?.audit?.logExclude ?? [];
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const c of [...global, ...local]) {
|
|
283
|
+
if (!seen.has(c)) {
|
|
284
|
+
seen.add(c);
|
|
285
|
+
out.push(c);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/** Static tag list configured for a schema (or empty). Used by the
|
|
291
|
+
* trait to populate the `tags` column on every audit row. */
|
|
292
|
+
getAuditTagsFor(schemaName) {
|
|
293
|
+
const schema = this.getSchema(schemaName);
|
|
294
|
+
return [...(schema?.options?.audit?.logTags ?? [])];
|
|
295
|
+
}
|
|
296
|
+
/** Audit user-model schema name (typically `User`). Required when the
|
|
297
|
+
* feature is enabled — the omnify-go validator already rejects
|
|
298
|
+
* `audit.log: true` without a model, so this returns "" only when the
|
|
299
|
+
* feature is itself off. */
|
|
300
|
+
getAuditUserModel() {
|
|
301
|
+
return this.getAuditConfig()?.model ?? '';
|
|
302
|
+
}
|
|
303
|
+
/** Laravel queue name for WriteAuditLog dispatch. Empty string means
|
|
304
|
+
* sync fallback (low-traffic / dev mode). */
|
|
305
|
+
getAuditQueue() {
|
|
306
|
+
return this.getAuditConfig()?.logQueue ?? '';
|
|
307
|
+
}
|
|
308
|
+
/** Prunable retention period (e.g. `90d`). Empty string means audits
|
|
309
|
+
* are kept forever — no Prunable scope is registered. */
|
|
310
|
+
getAuditRetention() {
|
|
311
|
+
return this.getAuditConfig()?.logRetention ?? '';
|
|
312
|
+
}
|
|
217
313
|
/** Get schemas that have service options configured (options.service). */
|
|
218
314
|
getSchemasWithService() {
|
|
219
315
|
const result = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -14,6 +14,36 @@ export interface FileConfigExport {
|
|
|
14
14
|
readonly purgeSchedule?: string;
|
|
15
15
|
readonly defaultDisk?: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Global audit configuration surfaced from the Go core.
|
|
19
|
+
*
|
|
20
|
+
* `model` is the FQ schema name of the user / actor (typically `User`)
|
|
21
|
+
* — the audits table records `(user_type, user_id)` polymorphically so
|
|
22
|
+
* any model can be the actor, but the trait needs a default to capture
|
|
23
|
+
* `Auth::user()` correctly.
|
|
24
|
+
*
|
|
25
|
+
* `log` toggles the `audits` history table feature globally; per-schema
|
|
26
|
+
* `options.audit.log` overrides per-schema. Sensitive columns listed in
|
|
27
|
+
* `logExclude` are scrubbed from `old_values` / `new_values` BEFORE the
|
|
28
|
+
* row is written, so a stolen audits dump cannot leak passwords.
|
|
29
|
+
*
|
|
30
|
+
* `logRetention` is the Prunable retention period (`90d`, `12w`, `6m`,
|
|
31
|
+
* `1y`); empty means audits are kept forever (no scheduled prune).
|
|
32
|
+
*
|
|
33
|
+
* `logQueue` is the Laravel queue connection name. Empty string means
|
|
34
|
+
* sync dispatch (low-traffic / dev fallback) — audited writes still
|
|
35
|
+
* complete immediately, just on the request thread.
|
|
36
|
+
*/
|
|
37
|
+
export interface AuditConfigExport {
|
|
38
|
+
readonly model?: string;
|
|
39
|
+
readonly createdBy?: boolean;
|
|
40
|
+
readonly updatedBy?: boolean;
|
|
41
|
+
readonly deletedBy?: boolean;
|
|
42
|
+
readonly log?: boolean;
|
|
43
|
+
readonly logExclude?: readonly string[];
|
|
44
|
+
readonly logRetention?: string;
|
|
45
|
+
readonly logQueue?: string;
|
|
46
|
+
}
|
|
17
47
|
/** Top-level schemas.json structure. */
|
|
18
48
|
export interface SchemasJson {
|
|
19
49
|
/**
|
|
@@ -39,12 +69,7 @@ export interface SchemasJson {
|
|
|
39
69
|
readonly enums: Record<string, string[]>;
|
|
40
70
|
};
|
|
41
71
|
readonly fileConfig?: FileConfigExport;
|
|
42
|
-
readonly auditConfig?:
|
|
43
|
-
readonly model?: string;
|
|
44
|
-
readonly createdBy?: boolean;
|
|
45
|
-
readonly updatedBy?: boolean;
|
|
46
|
-
readonly deletedBy?: boolean;
|
|
47
|
-
};
|
|
72
|
+
readonly auditConfig?: AuditConfigExport;
|
|
48
73
|
readonly packages?: Record<string, PackageExportInfo>;
|
|
49
74
|
readonly schemas: Record<string, SchemaDefinition>;
|
|
50
75
|
}
|
|
@@ -158,9 +183,18 @@ export interface SchemaOptions {
|
|
|
158
183
|
readonly unique?: readonly unknown[];
|
|
159
184
|
readonly api?: ApiOptions;
|
|
160
185
|
readonly audit?: {
|
|
186
|
+
readonly model?: string;
|
|
161
187
|
readonly createdBy?: boolean;
|
|
162
188
|
readonly updatedBy?: boolean;
|
|
163
189
|
readonly deletedBy?: boolean;
|
|
190
|
+
/** Per-schema opt-in to the audits history feature. Pointer-tristate
|
|
191
|
+
* semantics: undefined = inherit global; true/false = explicit. */
|
|
192
|
+
readonly log?: boolean;
|
|
193
|
+
/** Per-schema scrub list. Merged with the global `auditConfig.logExclude`
|
|
194
|
+
* before any audit row is written. */
|
|
195
|
+
readonly logExclude?: readonly string[];
|
|
196
|
+
/** Static tag list applied to every audit row from this schema. */
|
|
197
|
+
readonly logTags?: readonly string[];
|
|
164
198
|
};
|
|
165
199
|
/**
|
|
166
200
|
* Service layer codegen options (issue #57).
|