strapi-cms-audit-log 1.1.0
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/CHANGELOG.md +212 -0
- package/LICENSE +21 -0
- package/README.md +1026 -0
- package/dist/admin/App-BLt4lqEM.js +1284 -0
- package/dist/admin/App-ou5hy99f.mjs +1266 -0
- package/dist/admin/en-B0rPiE2W.mjs +87 -0
- package/dist/admin/en-BMlJxq3g.js +87 -0
- package/dist/admin/index-BYi8OPTw.js +80 -0
- package/dist/admin/index-DdmY-p3Q.mjs +81 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/AuditLogFilters.d.ts +18 -0
- package/dist/admin/src/components/AuditLogTable.d.ts +19 -0
- package/dist/admin/src/components/ChangeViewer.d.ts +14 -0
- package/dist/admin/src/components/JsonViewer.d.ts +20 -0
- package/dist/admin/src/components/PluginIcon.d.ts +3 -0
- package/dist/admin/src/components/WidgetDiff.d.ts +19 -0
- package/dist/admin/src/hooks/useAuditLogs.d.ts +36 -0
- package/dist/admin/src/index.d.ts +3 -0
- package/dist/admin/src/pages/App.d.ts +11 -0
- package/dist/admin/src/pages/AuditLogDetails.d.ts +10 -0
- package/dist/admin/src/pages/AuditLogs.d.ts +3 -0
- package/dist/admin/src/permissions.d.ts +26 -0
- package/dist/admin/src/pluginId.d.ts +2 -0
- package/dist/admin/src/types.d.ts +91 -0
- package/dist/admin/src/utils/format.d.ts +41 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/admin/src/utils/widgets.d.ts +85 -0
- package/dist/server/index.js +1856 -0
- package/dist/server/index.mjs +1856 -0
- package/dist/server/src/bootstrap.d.ts +14 -0
- package/dist/server/src/config/index.d.ts +52 -0
- package/dist/server/src/constants.d.ts +140 -0
- package/dist/server/src/content-types/audit-log/index.d.ts +86 -0
- package/dist/server/src/content-types/audit-log/schema.d.ts +141 -0
- package/dist/server/src/content-types/index.d.ts +88 -0
- package/dist/server/src/controllers/audit-log.d.ts +31 -0
- package/dist/server/src/controllers/index.d.ts +42 -0
- package/dist/server/src/destroy.d.ts +19 -0
- package/dist/server/src/index.d.ts +328 -0
- package/dist/server/src/register.d.ts +31 -0
- package/dist/server/src/routes/admin.d.ts +26 -0
- package/dist/server/src/routes/index.d.ts +19 -0
- package/dist/server/src/services/access.d.ts +51 -0
- package/dist/server/src/services/audit.d.ts +25 -0
- package/dist/server/src/services/config.d.ts +30 -0
- package/dist/server/src/services/context.d.ts +33 -0
- package/dist/server/src/services/diff.d.ts +48 -0
- package/dist/server/src/services/immutability.d.ts +30 -0
- package/dist/server/src/services/index.d.ts +137 -0
- package/dist/server/src/services/retention.d.ts +22 -0
- package/dist/server/src/services/security.d.ts +54 -0
- package/dist/server/src/services/snapshot.d.ts +46 -0
- package/dist/server/src/services/tracker.d.ts +39 -0
- package/dist/server/src/types/index.d.ts +214 -0
- package/dist/server/src/utils/json.d.ts +17 -0
- package/dist/server/src/utils/paths.d.ts +34 -0
- package/dist/server/src/utils/sanitize.d.ts +16 -0
- package/package.json +112 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
declare const _default: {
|
|
2
|
+
access: ({ strapi }: {
|
|
3
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
4
|
+
}) => {
|
|
5
|
+
createMiddleware: () => (ctx: {
|
|
6
|
+
[x: string]: any;
|
|
7
|
+
}, next: () => Promise<unknown>) => Promise<void>;
|
|
8
|
+
register: () => void;
|
|
9
|
+
recordDenial: (ctx: {
|
|
10
|
+
[x: string]: any;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
sourceOf: (ctx: {
|
|
13
|
+
[x: string]: any;
|
|
14
|
+
}) => "admin" | "api" | "unknown";
|
|
15
|
+
isIgnoredPath: (path: string) => boolean;
|
|
16
|
+
};
|
|
17
|
+
audit: ({ strapi }: {
|
|
18
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
19
|
+
}) => {
|
|
20
|
+
record: (entry: import('..').AuditEntryInput) => Promise<void>;
|
|
21
|
+
write: (entry: import('..').AuditEntryInput) => Promise<void>;
|
|
22
|
+
flush: () => Promise<void>;
|
|
23
|
+
find: (query?: import('..').AuditLogQuery) => Promise<import('..').AuditLogListResult>;
|
|
24
|
+
findOne: (id: number) => Promise<import('..').AuditLog | null>;
|
|
25
|
+
deleteOne: (id: number) => Promise<import('..').AuditLog | null>;
|
|
26
|
+
deleteOlderThan: (date: Date) => Promise<number>;
|
|
27
|
+
getFilterOptions: () => Promise<import('..').AuditFilterOptions>;
|
|
28
|
+
buildWhere: (query: import('..').AuditLogQuery) => Record<string, unknown>;
|
|
29
|
+
parseSort: (sort: string | undefined) => Record<string, "asc" | "desc">;
|
|
30
|
+
};
|
|
31
|
+
config: ({ strapi }: {
|
|
32
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
33
|
+
}) => {
|
|
34
|
+
resolve: () => import('./config').ResolvedConfig;
|
|
35
|
+
isAuditedContentType: (uid: string) => boolean;
|
|
36
|
+
isAuditedAction: (action: import('..').AuditAction) => boolean;
|
|
37
|
+
isAuditedSecurityAction: (action: import('..').AuditSecurityAction) => boolean;
|
|
38
|
+
hasSecurityEvents: () => boolean;
|
|
39
|
+
enabledSecurityActions: () => import('..').AuditSecurityAction[];
|
|
40
|
+
getPublicConfig: () => import('..').AuditConfig;
|
|
41
|
+
clearCache: () => void;
|
|
42
|
+
};
|
|
43
|
+
context: ({ strapi }: {
|
|
44
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
45
|
+
}) => {
|
|
46
|
+
resolve: () => import('..').AuditContext;
|
|
47
|
+
runAs: <T>(override: import('./context').SourceOverride, callback: () => T) => T;
|
|
48
|
+
};
|
|
49
|
+
diff: ({ strapi }: {
|
|
50
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
51
|
+
}) => {
|
|
52
|
+
buildDiff: (before: unknown, after: unknown, options?: import('./diff').DiffOptions) => import('..').AuditChangeSet;
|
|
53
|
+
};
|
|
54
|
+
immutability: ({ strapi }: {
|
|
55
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
56
|
+
}) => {
|
|
57
|
+
createMiddleware: () => (ctx: {
|
|
58
|
+
uid: string;
|
|
59
|
+
action: string;
|
|
60
|
+
}, next: () => Promise<unknown>) => Promise<unknown>;
|
|
61
|
+
register: () => void;
|
|
62
|
+
};
|
|
63
|
+
retention: ({ strapi }: {
|
|
64
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
65
|
+
}) => {
|
|
66
|
+
cleanup: () => Promise<number>;
|
|
67
|
+
cutoffDate: (retentionDays: number, now?: Date) => Date;
|
|
68
|
+
register: () => void;
|
|
69
|
+
unregister: () => void;
|
|
70
|
+
};
|
|
71
|
+
security: ({ strapi }: {
|
|
72
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
73
|
+
}) => {
|
|
74
|
+
register: () => void;
|
|
75
|
+
unregister: () => void;
|
|
76
|
+
record: (action: import('..').AuditSecurityAction, subject: string, fields?: {
|
|
77
|
+
contentDocumentId?: string | null;
|
|
78
|
+
contentId?: string | null;
|
|
79
|
+
user?: {
|
|
80
|
+
[x: string]: any;
|
|
81
|
+
} | null;
|
|
82
|
+
before?: Record<string, unknown> | null;
|
|
83
|
+
after?: Record<string, unknown> | null;
|
|
84
|
+
metadata?: import('..').AuditMetadata | null;
|
|
85
|
+
outcome?: import('..').AuditOutcome;
|
|
86
|
+
}) => Promise<void>;
|
|
87
|
+
identify: (entity: {
|
|
88
|
+
[x: string]: any;
|
|
89
|
+
} | null | undefined) => Record<string, unknown> | null;
|
|
90
|
+
subjectOf: (payload: {
|
|
91
|
+
[x: string]: any;
|
|
92
|
+
}) => {
|
|
93
|
+
[x: string]: any;
|
|
94
|
+
} | null;
|
|
95
|
+
sourceOf: () => "admin" | "system";
|
|
96
|
+
entityHandler: (action: import('..').AuditSecurityAction, subject: string) => (payload: {
|
|
97
|
+
[x: string]: any;
|
|
98
|
+
}) => Promise<void>;
|
|
99
|
+
handlers: Record<string, (payload: {
|
|
100
|
+
[x: string]: any;
|
|
101
|
+
}) => Promise<void>>;
|
|
102
|
+
};
|
|
103
|
+
snapshot: ({ strapi }: {
|
|
104
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
105
|
+
}) => {
|
|
106
|
+
buildSnapshotQuery: (uid: string, keys: string[] | null, options: {
|
|
107
|
+
depth: number;
|
|
108
|
+
isIgnored?: (path: string) => boolean;
|
|
109
|
+
}) => import('./snapshot').SnapshotQuery;
|
|
110
|
+
buildComponentPopulate: (componentUid: string, depth: number) => Record<string, unknown> | true;
|
|
111
|
+
buildWhere: (uid: string, lookup: import('./snapshot').SnapshotLookup) => Record<string, unknown>;
|
|
112
|
+
fetchRows: (uid: string, lookup: import('./snapshot').SnapshotLookup, query: import('./snapshot').SnapshotQuery, depth: number) => Promise<{
|
|
113
|
+
[x: string]: any;
|
|
114
|
+
}[]>;
|
|
115
|
+
refineDynamicZones: (uid: string, rows: {
|
|
116
|
+
[x: string]: any;
|
|
117
|
+
}[], query: import('./snapshot').SnapshotQuery, depth: number) => Promise<{
|
|
118
|
+
[x: string]: any;
|
|
119
|
+
}[]>;
|
|
120
|
+
toSnapshot: (row: {
|
|
121
|
+
[x: string]: any;
|
|
122
|
+
} | null | undefined) => Record<string, unknown> | null;
|
|
123
|
+
hasDraftAndPublish: (uid: string) => boolean;
|
|
124
|
+
isLocalized: (uid: string) => boolean;
|
|
125
|
+
};
|
|
126
|
+
tracker: ({ strapi }: {
|
|
127
|
+
strapi: import('@strapi/types/dist/core').Strapi;
|
|
128
|
+
}) => {
|
|
129
|
+
createMiddleware: () => (ctx: import('./tracker').DocumentMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>;
|
|
130
|
+
register: () => void;
|
|
131
|
+
changedKeys: (uid: string, params: Record<string, any>) => string[] | null;
|
|
132
|
+
resultRows: (result: unknown) => {
|
|
133
|
+
[x: string]: any;
|
|
134
|
+
}[];
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
export default _default;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Core } from '@strapi/strapi';
|
|
2
|
+
/**
|
|
3
|
+
* Scheduled deletion of expired audit records.
|
|
4
|
+
*
|
|
5
|
+
* Runs on `strapi.cron`, off the request path entirely. Cleaning up inside a
|
|
6
|
+
* content operation — the obvious shortcut — would put an unbounded `DELETE`
|
|
7
|
+
* over the largest table in the project directly in front of an editor pressing
|
|
8
|
+
* Save, and would do it once per write. Here it runs once a day, by default at
|
|
9
|
+
* 03:00 server time, and a slow pass delays nothing but itself.
|
|
10
|
+
*
|
|
11
|
+
* `retentionDays: 0` disables the job entirely: no cron entry is registered, so
|
|
12
|
+
* projects that keep audit history forever pay nothing for the feature.
|
|
13
|
+
*/
|
|
14
|
+
declare const retentionService: ({ strapi }: {
|
|
15
|
+
strapi: Core.Strapi;
|
|
16
|
+
}) => {
|
|
17
|
+
cleanup: () => Promise<number>;
|
|
18
|
+
cutoffDate: (retentionDays: number, now?: Date) => Date;
|
|
19
|
+
register: () => void;
|
|
20
|
+
unregister: () => void;
|
|
21
|
+
};
|
|
22
|
+
export default retentionService;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Core } from '@strapi/strapi';
|
|
2
|
+
import { AuditMetadata, AuditOutcome, AuditSecurityAction } from '../types';
|
|
3
|
+
type Payload = Record<string, any>;
|
|
4
|
+
/**
|
|
5
|
+
* The half of the audit trail Strapi does not route through the Document
|
|
6
|
+
* Service: who signed in, who failed to, who signed out, and every change to the
|
|
7
|
+
* admin users, roles, permissions and media that govern the rest of it.
|
|
8
|
+
*
|
|
9
|
+
* ## Why `eventHub` and not more Document Service middleware
|
|
10
|
+
*
|
|
11
|
+
* None of these operations is a document operation. `@strapi/admin` writes admin
|
|
12
|
+
* users with `strapi.db.query('admin::user')` and `@strapi/upload` writes files
|
|
13
|
+
* with `strapi.db.query(FILE_MODEL_UID)` — both sit *below* the Document
|
|
14
|
+
* Service, so the tracker in `tracker.ts` never sees them and no amount of
|
|
15
|
+
* middleware would make it. Authentication is not a write at all.
|
|
16
|
+
*
|
|
17
|
+
* What Strapi does do is emit a named event for each one, on the same
|
|
18
|
+
* `strapi.eventHub` its own webhook system runs on. Those emissions are all in
|
|
19
|
+
* **Community Edition** — `server/src/controllers/authentication.ts` and
|
|
20
|
+
* `server/src/services/{user,role,permission}.ts` in `@strapi/admin`,
|
|
21
|
+
* `server/src/services/{upload,folder}.ts` in `@strapi/upload` — so nothing here
|
|
22
|
+
* needs an Enterprise licence, and nothing here patches Strapi's own code.
|
|
23
|
+
*
|
|
24
|
+
* ## Why a listener may never throw
|
|
25
|
+
*
|
|
26
|
+
* `eventHub.emit` awaits its subscribers in sequence *inside the operation that
|
|
27
|
+
* emitted*. A listener that threw on `admin.auth.success` would turn a correct
|
|
28
|
+
* password into a failed login. Every handler is therefore wrapped: an audit
|
|
29
|
+
* failure is logged and swallowed, and `failOnAuditError` is deliberately not
|
|
30
|
+
* honoured here. That option exists so a project can refuse to serve content it
|
|
31
|
+
* cannot audit, which is a very different proposition from locking every
|
|
32
|
+
* administrator out of the panel because the audit table is unreachable.
|
|
33
|
+
*/
|
|
34
|
+
declare const securityService: ({ strapi }: {
|
|
35
|
+
strapi: Core.Strapi;
|
|
36
|
+
}) => {
|
|
37
|
+
register: () => void;
|
|
38
|
+
unregister: () => void;
|
|
39
|
+
record: (action: AuditSecurityAction, subject: string, fields?: {
|
|
40
|
+
contentDocumentId?: string | null;
|
|
41
|
+
contentId?: string | null;
|
|
42
|
+
user?: Payload | null;
|
|
43
|
+
before?: Record<string, unknown> | null;
|
|
44
|
+
after?: Record<string, unknown> | null;
|
|
45
|
+
metadata?: AuditMetadata | null;
|
|
46
|
+
outcome?: AuditOutcome;
|
|
47
|
+
}) => Promise<void>;
|
|
48
|
+
identify: (entity: Payload | null | undefined) => Record<string, unknown> | null;
|
|
49
|
+
subjectOf: (payload: Payload) => Payload | null;
|
|
50
|
+
sourceOf: () => "admin" | "system";
|
|
51
|
+
entityHandler: (action: AuditSecurityAction, subject: string) => (payload: Payload) => Promise<void>;
|
|
52
|
+
handlers: Record<string, (payload: Payload) => Promise<void>>;
|
|
53
|
+
};
|
|
54
|
+
export default securityService;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Core } from '@strapi/strapi';
|
|
2
|
+
type Row = Record<string, any>;
|
|
3
|
+
export interface SnapshotQuery {
|
|
4
|
+
select: string[];
|
|
5
|
+
populate?: Record<string, unknown>;
|
|
6
|
+
/** Dynamic-zone attributes present in the query, for the optional refinement pass. */
|
|
7
|
+
dynamicZones: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface SnapshotLookup {
|
|
10
|
+
documentId: string;
|
|
11
|
+
locales?: string[] | null;
|
|
12
|
+
status?: 'draft' | 'published' | 'any';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds the narrowest query that can answer "what did these fields look like?".
|
|
16
|
+
*
|
|
17
|
+
* This is the heart of the plugin's performance story. Strapi's own
|
|
18
|
+
* `getDeepPopulate` walks the *schema* and asks for every relation, component
|
|
19
|
+
* and media field a type could ever hold; on a page content type with 173
|
|
20
|
+
* registered dynamic-zone widgets that is hundreds of joins to audit a
|
|
21
|
+
* two-field edit. Here:
|
|
22
|
+
*
|
|
23
|
+
* - the key set is the attributes the write actually touched, not the schema;
|
|
24
|
+
* - relations and media are reduced to identifying fields, so a relation to a
|
|
25
|
+
* 50-field document costs a handful of columns rather than all of them;
|
|
26
|
+
* - dynamic zones are populated with `true`, which makes Strapi read the join
|
|
27
|
+
* table first and then issue one query per component type *actually present*
|
|
28
|
+
* in the rows. Five widgets used out of 173 registered means five queries,
|
|
29
|
+
* and the 168 unused schemas are never touched.
|
|
30
|
+
*/
|
|
31
|
+
declare const snapshotService: ({ strapi }: {
|
|
32
|
+
strapi: Core.Strapi;
|
|
33
|
+
}) => {
|
|
34
|
+
buildSnapshotQuery: (uid: string, keys: string[] | null, options: {
|
|
35
|
+
depth: number;
|
|
36
|
+
isIgnored?: (path: string) => boolean;
|
|
37
|
+
}) => SnapshotQuery;
|
|
38
|
+
buildComponentPopulate: (componentUid: string, depth: number) => Record<string, unknown> | true;
|
|
39
|
+
buildWhere: (uid: string, lookup: SnapshotLookup) => Record<string, unknown>;
|
|
40
|
+
fetchRows: (uid: string, lookup: SnapshotLookup, query: SnapshotQuery, depth: number) => Promise<Row[]>;
|
|
41
|
+
refineDynamicZones: (uid: string, rows: Row[], query: SnapshotQuery, depth: number) => Promise<Row[]>;
|
|
42
|
+
toSnapshot: (row: Row | null | undefined) => Record<string, unknown> | null;
|
|
43
|
+
hasDraftAndPublish: (uid: string) => boolean;
|
|
44
|
+
isLocalized: (uid: string) => boolean;
|
|
45
|
+
};
|
|
46
|
+
export default snapshotService;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Core } from '@strapi/strapi';
|
|
2
|
+
type Row = Record<string, any>;
|
|
3
|
+
export interface DocumentMiddlewareContext {
|
|
4
|
+
uid: string;
|
|
5
|
+
action: string;
|
|
6
|
+
contentType?: Record<string, any>;
|
|
7
|
+
params: Record<string, any>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Global interception of content writes.
|
|
11
|
+
*
|
|
12
|
+
* Registered once with `strapi.documents.use()`, which is the Strapi v5
|
|
13
|
+
* replacement for per-content-type lifecycles and the reason a consuming
|
|
14
|
+
* application never writes a line of audit code. Three properties make this the
|
|
15
|
+
* right layer, and `db.lifecycles` the wrong one:
|
|
16
|
+
*
|
|
17
|
+
* 1. It sees the *semantic* action. `publish` and `unpublish` are ordinary row
|
|
18
|
+
* inserts and deletes to the database layer; only the Document Service knows
|
|
19
|
+
* they were a publish.
|
|
20
|
+
* 2. It runs *outside* the write's transaction (`middlewares.wrapObject` wraps
|
|
21
|
+
* the already-transaction-wrapped repository methods), so an audit failure
|
|
22
|
+
* can never roll back an editor's save, and an audit insert never holds a
|
|
23
|
+
* row lock open.
|
|
24
|
+
* 3. It sees `documentId` and `locale` as first-class parameters rather than
|
|
25
|
+
* having to reconstruct them from a row.
|
|
26
|
+
*
|
|
27
|
+
* Every operation the Content Manager, the REST/GraphQL content API and custom
|
|
28
|
+
* server code perform goes through this facade, so all three are covered by the
|
|
29
|
+
* one registration.
|
|
30
|
+
*/
|
|
31
|
+
declare const trackerService: ({ strapi }: {
|
|
32
|
+
strapi: Core.Strapi;
|
|
33
|
+
}) => {
|
|
34
|
+
createMiddleware: () => (ctx: DocumentMiddlewareContext, next: () => Promise<unknown>) => Promise<unknown>;
|
|
35
|
+
register: () => void;
|
|
36
|
+
changedKeys: (uid: string, params: Record<string, any>) => string[] | null;
|
|
37
|
+
resultRows: (result: unknown) => Row[];
|
|
38
|
+
};
|
|
39
|
+
export default trackerService;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public type surface of the plugin.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported from `strapi-plugin-audit-log/strapi-server`, so a consuming
|
|
5
|
+
* application can type its own code against the same shapes the plugin uses:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import type { AuditLog, AuditConfig } from 'strapi-plugin-audit-log/strapi-server';
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The admin panel keeps its own copies of the few shared unions in
|
|
12
|
+
* `admin/src/types.ts`. That is not an oversight: the admin and server bundles
|
|
13
|
+
* are compiled by separate Vite passes whose `.d.ts` emit is rooted at
|
|
14
|
+
* `admin/src` and `server/src` respectively, so a file imported across that
|
|
15
|
+
* boundary would be emitted outside its own root. See README → Architecture.
|
|
16
|
+
*/
|
|
17
|
+
/** Operations the plugin can record. Extend via `AuditAction` unions downstream. */
|
|
18
|
+
export type AuditAction = 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
|
19
|
+
/**
|
|
20
|
+
* Operations the plugin records that are not content writes.
|
|
21
|
+
*
|
|
22
|
+
* Produced by `strapi.eventHub` listeners and by the access middleware rather
|
|
23
|
+
* than by the Document Service. Namespaced with a dot so a reader can tell at a
|
|
24
|
+
* glance which family a row belongs to, and so a filter on `login.` prefixes
|
|
25
|
+
* groups the authentication trail without a second column.
|
|
26
|
+
*/
|
|
27
|
+
export type AuditSecurityAction = 'login.success' | 'login.failed' | 'logout' | 'access.denied' | 'admin.user.create' | 'admin.user.update' | 'admin.user.delete' | 'admin.role.create' | 'admin.role.update' | 'admin.role.delete' | 'admin.permission.create' | 'admin.permission.update' | 'admin.permission.delete' | 'media.create' | 'media.update' | 'media.delete' | 'media-folder.create' | 'media-folder.update' | 'media-folder.delete';
|
|
28
|
+
/** Every action the plugin can write, across both families. */
|
|
29
|
+
export type AuditAnyAction = AuditAction | AuditSecurityAction;
|
|
30
|
+
/**
|
|
31
|
+
* Whether the recorded attempt succeeded.
|
|
32
|
+
*
|
|
33
|
+
* Every content write is a `success` — the middleware runs after the operation
|
|
34
|
+
* resolved, so a failed save produces no record at all. The value earns its
|
|
35
|
+
* column on the security side, where `login.failed` and `access.denied` are the
|
|
36
|
+
* rows a reviewer opens the log to find, and where "show me the failures" must
|
|
37
|
+
* be an indexed query rather than a scan of an action list.
|
|
38
|
+
*/
|
|
39
|
+
export type AuditOutcome = 'success' | 'failure';
|
|
40
|
+
/**
|
|
41
|
+
* Free-form detail that only some actions have.
|
|
42
|
+
*
|
|
43
|
+
* A deliberately loose bag rather than a column each: the useful fields differ
|
|
44
|
+
* per action (`reason` and `provider` for a login, `method`/`path`/`statusCode`
|
|
45
|
+
* for a denial, `fileName` for an upload), and none of them is ever filtered or
|
|
46
|
+
* sorted on. Anything worth querying gets a real column instead.
|
|
47
|
+
*/
|
|
48
|
+
export interface AuditMetadata {
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
/** How security events are selected. `'*'` means every one of them. */
|
|
52
|
+
export type SecurityEventSelector = '*' | AuditSecurityAction[];
|
|
53
|
+
/**
|
|
54
|
+
* Where the operation came from.
|
|
55
|
+
*
|
|
56
|
+
* `admin` — an authenticated Strapi admin panel request
|
|
57
|
+
* `api` — the REST/GraphQL content API (API token or Users & Permissions user)
|
|
58
|
+
* `cron` — a scheduled task
|
|
59
|
+
* `migration` — a data migration or import script
|
|
60
|
+
* `system` — server-side code with no request context (bootstrap, listeners)
|
|
61
|
+
* `unknown` — a request context existed but could not be classified
|
|
62
|
+
*/
|
|
63
|
+
export type AuditSource = 'admin' | 'api' | 'system' | 'cron' | 'migration' | 'unknown';
|
|
64
|
+
/** A single field-level change, keyed in {@link AuditChangeSet} by dotted path. */
|
|
65
|
+
export interface AuditChange {
|
|
66
|
+
from: unknown;
|
|
67
|
+
to: unknown;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Field-level diff of an operation.
|
|
71
|
+
*
|
|
72
|
+
* Keys are dotted paths into the document, with array members addressed by
|
|
73
|
+
* index: `title`, `seo.metaTitle`, `blocks[2].heading`.
|
|
74
|
+
*/
|
|
75
|
+
export type AuditChangeSet = Record<string, AuditChange>;
|
|
76
|
+
/** Identity of the actor, snapshotted at write time so it survives user deletion. */
|
|
77
|
+
export interface AuditActor {
|
|
78
|
+
userId: string | null;
|
|
79
|
+
userEmail: string | null;
|
|
80
|
+
userName: string | null;
|
|
81
|
+
}
|
|
82
|
+
/** Request metadata, captured opportunistically — every field may be absent. */
|
|
83
|
+
export interface AuditRequestContext {
|
|
84
|
+
ipAddress: string | null;
|
|
85
|
+
userAgent: string | null;
|
|
86
|
+
requestId: string | null;
|
|
87
|
+
}
|
|
88
|
+
/** Everything the audit service needs to know about who/where, resolved once per operation. */
|
|
89
|
+
export interface AuditContext extends AuditActor, AuditRequestContext {
|
|
90
|
+
source: AuditSource;
|
|
91
|
+
}
|
|
92
|
+
/** A persisted audit record, as returned by the plugin's admin API. */
|
|
93
|
+
export interface AuditLog extends AuditContext {
|
|
94
|
+
id: number;
|
|
95
|
+
/** The audit record's own Strapi v5 document id — not the audited document's. */
|
|
96
|
+
documentId: string;
|
|
97
|
+
action: AuditAnyAction | string;
|
|
98
|
+
/** UID of the audited content type, e.g. `api::page.page`. */
|
|
99
|
+
contentType: string;
|
|
100
|
+
/** Human-readable name of the audited content type at the time of the write. */
|
|
101
|
+
contentTypeDisplayName: string | null;
|
|
102
|
+
/**
|
|
103
|
+
* The audited document's Strapi v5 document id.
|
|
104
|
+
*
|
|
105
|
+
* Not called `documentId`: Strapi v5 reserves that attribute name on every
|
|
106
|
+
* content type and throws at boot if a schema declares it.
|
|
107
|
+
*/
|
|
108
|
+
contentDocumentId: string | null;
|
|
109
|
+
/** The audited entry's numeric database id, as a string. */
|
|
110
|
+
contentId: string | null;
|
|
111
|
+
locale: string | null;
|
|
112
|
+
changes: AuditChangeSet | null;
|
|
113
|
+
before: Record<string, unknown> | null;
|
|
114
|
+
after: Record<string, unknown> | null;
|
|
115
|
+
/** `failure` for a rejected login or a denied request; `success` for everything else. */
|
|
116
|
+
outcome: AuditOutcome | null;
|
|
117
|
+
/** Action-specific detail — see {@link AuditMetadata}. */
|
|
118
|
+
metadata: AuditMetadata | null;
|
|
119
|
+
createdAt: string;
|
|
120
|
+
updatedAt: string;
|
|
121
|
+
}
|
|
122
|
+
/** Selects which content types are audited. `'*'` means every content type. */
|
|
123
|
+
export type ContentTypeSelector = '*' | string[];
|
|
124
|
+
/** How the audit row is persisted relative to the content operation. */
|
|
125
|
+
export type AuditWriteMode = 'sync' | 'async';
|
|
126
|
+
/**
|
|
127
|
+
* Plugin configuration, after defaults have been resolved.
|
|
128
|
+
*
|
|
129
|
+
* See {@link AuditUserConfig} for the shape accepted in `config/plugins.ts`.
|
|
130
|
+
*/
|
|
131
|
+
export interface AuditConfig {
|
|
132
|
+
actions: AuditAction[];
|
|
133
|
+
contentTypes: ContentTypeSelector;
|
|
134
|
+
ignoredContentTypes: string[];
|
|
135
|
+
ignoredFields: string[];
|
|
136
|
+
additionalIgnoredFields: string[];
|
|
137
|
+
ignoredChangeFields: string[];
|
|
138
|
+
storeBefore: boolean;
|
|
139
|
+
storeAfter: boolean;
|
|
140
|
+
storeChanges: boolean;
|
|
141
|
+
retentionDays: number;
|
|
142
|
+
retentionCron: string;
|
|
143
|
+
failOnAuditError: boolean;
|
|
144
|
+
writeMode: AuditWriteMode;
|
|
145
|
+
maxPopulateDepth: number;
|
|
146
|
+
maxSnapshotBytes: number;
|
|
147
|
+
auditSystemOperations: boolean;
|
|
148
|
+
/** Which security events to record. `'*'` is all of them; `[]` disables the listeners entirely. */
|
|
149
|
+
securityEvents: SecurityEventSelector;
|
|
150
|
+
/** Mirror every record to `strapi.log` as one line of structured JSON, for a SIEM to pick up off stdout. */
|
|
151
|
+
forwardToLogger: boolean;
|
|
152
|
+
/** Level the mirrored line is written at. */
|
|
153
|
+
forwardLogLevel: AuditLogLevel;
|
|
154
|
+
}
|
|
155
|
+
/** Levels `strapi.log` exposes that make sense for an audit mirror. */
|
|
156
|
+
export type AuditLogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
157
|
+
/** Configuration as written by a consumer — every key optional. */
|
|
158
|
+
export type AuditUserConfig = Partial<AuditConfig>;
|
|
159
|
+
/** Payload handed to the audit service for persistence. */
|
|
160
|
+
export interface AuditEntryInput extends AuditContext {
|
|
161
|
+
action: AuditAnyAction;
|
|
162
|
+
contentType: string;
|
|
163
|
+
contentTypeDisplayName: string | null;
|
|
164
|
+
contentDocumentId: string | null;
|
|
165
|
+
contentId: string | null;
|
|
166
|
+
locale: string | null;
|
|
167
|
+
changes: AuditChangeSet | null;
|
|
168
|
+
before: Record<string, unknown> | null;
|
|
169
|
+
after: Record<string, unknown> | null;
|
|
170
|
+
/** Defaults to `success` when omitted — see {@link AuditOutcome}. */
|
|
171
|
+
outcome?: AuditOutcome | null;
|
|
172
|
+
metadata?: AuditMetadata | null;
|
|
173
|
+
}
|
|
174
|
+
/** Filters accepted by the admin list endpoint. */
|
|
175
|
+
export interface AuditLogQuery {
|
|
176
|
+
page?: number;
|
|
177
|
+
pageSize?: number;
|
|
178
|
+
sort?: string;
|
|
179
|
+
action?: string | string[];
|
|
180
|
+
contentType?: string | string[];
|
|
181
|
+
userId?: string | string[];
|
|
182
|
+
locale?: string | string[];
|
|
183
|
+
source?: string | string[];
|
|
184
|
+
outcome?: string | string[];
|
|
185
|
+
contentDocumentId?: string;
|
|
186
|
+
dateFrom?: string;
|
|
187
|
+
dateTo?: string;
|
|
188
|
+
_q?: string;
|
|
189
|
+
}
|
|
190
|
+
/** Shape returned by `GET /audit-log/logs`. */
|
|
191
|
+
export interface AuditLogListResult {
|
|
192
|
+
results: AuditLog[];
|
|
193
|
+
pagination: {
|
|
194
|
+
page: number;
|
|
195
|
+
pageSize: number;
|
|
196
|
+
pageCount: number;
|
|
197
|
+
total: number;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Distinct values behind the admin list's filter dropdowns. */
|
|
201
|
+
export interface AuditFilterOptions {
|
|
202
|
+
contentTypes: Array<{
|
|
203
|
+
uid: string;
|
|
204
|
+
displayName: string;
|
|
205
|
+
}>;
|
|
206
|
+
users: Array<{
|
|
207
|
+
userId: string;
|
|
208
|
+
label: string;
|
|
209
|
+
}>;
|
|
210
|
+
locales: string[];
|
|
211
|
+
actions: string[];
|
|
212
|
+
sources: string[];
|
|
213
|
+
outcomes: string[];
|
|
214
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Small JSON helpers shared by the diff engine and the audit service. */
|
|
2
|
+
export declare const isPlainObject: (value: unknown) => value is Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Structural equality for values that came out of the database or off a request
|
|
5
|
+
* body — i.e. JSON-shaped, plus `Date`, which Strapi hands back for datetime
|
|
6
|
+
* columns while the incoming payload for the same field is an ISO string.
|
|
7
|
+
* Comparing those two with `===` would report a change on every single write.
|
|
8
|
+
*/
|
|
9
|
+
export declare const isEqual: (a: unknown, b: unknown) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Approximate byte size of a value once serialised.
|
|
12
|
+
*
|
|
13
|
+
* Approximate on purpose: this only ever feeds a "is this snapshot absurdly
|
|
14
|
+
* large" check, and stringifying a 50MB document twice to get an exact number
|
|
15
|
+
* would cost more than the check saves.
|
|
16
|
+
*/
|
|
17
|
+
export declare const approximateJsonBytes: (value: unknown) => number;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotted-path matching for the ignore lists.
|
|
3
|
+
*
|
|
4
|
+
* A path is written the way the diff engine writes it — `seo.metaTitle`,
|
|
5
|
+
* `blocks[2].heading` — and a pattern may be:
|
|
6
|
+
*
|
|
7
|
+
* `password` leaf name; matches at any depth
|
|
8
|
+
* `seo.metaTitle` exact path, anchored at the document root
|
|
9
|
+
* `*token*` substring match within one segment
|
|
10
|
+
* `seo.*.token` `*` on its own matches exactly one whole segment
|
|
11
|
+
* `blocks.**.secret` `**` matches zero or more segments
|
|
12
|
+
*
|
|
13
|
+
* Array indices are stripped before matching, so `blocks[2].secret` is matched
|
|
14
|
+
* by `blocks.secret` — someone writing an ignore list should never have to think
|
|
15
|
+
* about which element of a repeatable component they are excluding.
|
|
16
|
+
*
|
|
17
|
+
* Matching is case-insensitive. Attribute names in Strapi are camelCase while
|
|
18
|
+
* the words worth redacting (`token`, `secret`, `password`) are written
|
|
19
|
+
* lowercase, so a case-sensitive `*token*` would sail straight past
|
|
20
|
+
* `internalToken`. For a redaction list, matching too widely costs a field in an
|
|
21
|
+
* audit record; matching too narrowly costs a secret stored forever.
|
|
22
|
+
*/
|
|
23
|
+
/** `blocks[2].seo.title` -> `['blocks', 'seo', 'title']`, lowercased. */
|
|
24
|
+
export declare const toSegments: (path: string) => string[];
|
|
25
|
+
/**
|
|
26
|
+
* Compiles an ignore list into a predicate.
|
|
27
|
+
*
|
|
28
|
+
* Built once per operation rather than per field: a document with a large
|
|
29
|
+
* dynamic zone produces thousands of path tests, and re-parsing the patterns for
|
|
30
|
+
* each one is the difference between negligible and measurable.
|
|
31
|
+
*/
|
|
32
|
+
export declare const createPathMatcher: (patterns: string[]) => ((path: string) => boolean);
|
|
33
|
+
/** Appends a key to a dotted path, using `[i]` notation for array members. */
|
|
34
|
+
export declare const joinPath: (base: string, key: string | number) => string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createPathMatcher } from './paths';
|
|
2
|
+
export type PathMatcher = (path: string) => boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Recursively strips every ignored path from a snapshot.
|
|
5
|
+
*
|
|
6
|
+
* Runs on `before` and `after` *before* they are handed to the diff engine, so a
|
|
7
|
+
* redacted field cannot leak into `changes` either. Values are dropped
|
|
8
|
+
* entirely rather than masked with a placeholder: a placeholder still tells a
|
|
9
|
+
* reader of the audit log that the field exists and changed, and for a password
|
|
10
|
+
* hash or an API key even that is more than an audit trail needs to say.
|
|
11
|
+
*
|
|
12
|
+
* Returns a new object; the input — which is a live row handed to us by the
|
|
13
|
+
* Document Service — is never mutated.
|
|
14
|
+
*/
|
|
15
|
+
export declare const sanitizeValue: <T>(value: T, isIgnored: PathMatcher, basePath?: string) => T;
|
|
16
|
+
export { createPathMatcher };
|