@sqldoc/ns-audit 0.0.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/package.json +30 -0
- package/src/__tests__/audit.test.ts +156 -0
- package/src/index.ts +140 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@sqldoc/ns-audit",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "Audit namespace for sqldoc -- generates audit trigger functions and triggers",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@sqldoc/core": "0.0.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vitest": "^4.1.0",
|
|
25
|
+
"@sqldoc/core": "0.0.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { TagContext } from '@sqldoc/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import plugin from '../index'
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides: Partial<TagContext> = {}): TagContext {
|
|
6
|
+
return {
|
|
7
|
+
target: 'table',
|
|
8
|
+
objectName: 'users',
|
|
9
|
+
tag: { name: '$self', args: {} },
|
|
10
|
+
namespaceTags: [],
|
|
11
|
+
siblingTags: [],
|
|
12
|
+
fileTags: [],
|
|
13
|
+
astNode: null,
|
|
14
|
+
fileStatements: [],
|
|
15
|
+
config: {},
|
|
16
|
+
filePath: 'test.sql',
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('ns-audit plugin', () => {
|
|
22
|
+
it('exports apiVersion === 1', () => {
|
|
23
|
+
expect(plugin.apiVersion).toBe(1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('exports name === "audit"', () => {
|
|
27
|
+
expect(plugin.name).toBe('audit')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('has $self and redact tag definitions', () => {
|
|
31
|
+
expect(plugin.tags).toHaveProperty('$self')
|
|
32
|
+
expect(plugin.tags).toHaveProperty('redact')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('onTag', () => {
|
|
36
|
+
it('@audit produces table + function + trigger (all ops by default)', () => {
|
|
37
|
+
const result = plugin.onTag!(
|
|
38
|
+
makeCtx({
|
|
39
|
+
objectName: 'orders',
|
|
40
|
+
tag: { name: null, args: {} },
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
const sql = (result as any).sql
|
|
44
|
+
expect(sql).toHaveLength(3)
|
|
45
|
+
|
|
46
|
+
expect(sql[0].sql).toContain('CREATE TABLE IF NOT EXISTS "orders_audit_log"')
|
|
47
|
+
expect(sql[1].sql).toContain('CREATE OR REPLACE FUNCTION "orders_audit_fn"()')
|
|
48
|
+
expect(sql[1].sql).toContain('INSERT INTO "orders_audit_log"')
|
|
49
|
+
expect(sql[2].sql).toContain('AFTER INSERT OR UPDATE OR DELETE ON "orders"')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('@audit(on: [update, delete]) uses specific operations', () => {
|
|
53
|
+
const result = plugin.onTag!(
|
|
54
|
+
makeCtx({
|
|
55
|
+
objectName: 'products',
|
|
56
|
+
tag: { name: null, args: { on: ['update', 'delete'] } },
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
const sql = (result as any).sql
|
|
60
|
+
|
|
61
|
+
expect(sql[2].sql).toContain('AFTER UPDATE OR DELETE ON "products"')
|
|
62
|
+
expect(sql[2].sql).not.toContain('INSERT')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('@audit(on: [insert]) handles NULL OLD', () => {
|
|
66
|
+
const result = plugin.onTag!(
|
|
67
|
+
makeCtx({
|
|
68
|
+
objectName: 'events',
|
|
69
|
+
tag: { name: null, args: { on: ['insert'] } },
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
const sql = (result as any).sql
|
|
73
|
+
|
|
74
|
+
expect(sql[1].sql).toContain("CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE row_to_json(OLD) END")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('@audit(on: [delete]) handles NULL NEW', () => {
|
|
78
|
+
const result = plugin.onTag!(
|
|
79
|
+
makeCtx({
|
|
80
|
+
objectName: 'events',
|
|
81
|
+
tag: { name: null, args: { on: ['delete'] } },
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
const sql = (result as any).sql
|
|
85
|
+
|
|
86
|
+
expect(sql[1].sql).toContain("CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE row_to_json(NEW) END")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('defaults destination to {objectName}_audit_log', () => {
|
|
90
|
+
const result = plugin.onTag!(
|
|
91
|
+
makeCtx({
|
|
92
|
+
objectName: 'users',
|
|
93
|
+
tag: { name: null, args: {} },
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
const sql = (result as any).sql
|
|
97
|
+
|
|
98
|
+
expect(sql[0].sql).toContain('CREATE TABLE IF NOT EXISTS "users_audit_log"')
|
|
99
|
+
expect(sql[1].sql).toContain('INSERT INTO "users_audit_log"')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('uses explicit destination', () => {
|
|
103
|
+
const result = plugin.onTag!(
|
|
104
|
+
makeCtx({
|
|
105
|
+
objectName: 'users',
|
|
106
|
+
tag: { name: null, args: { destination: 'custom_log' } },
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
const sql = (result as any).sql
|
|
110
|
+
|
|
111
|
+
expect(sql[0].sql).toContain('CREATE TABLE IF NOT EXISTS "custom_log"')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('@audit.redact produces no SQL', () => {
|
|
115
|
+
const result = plugin.onTag!(
|
|
116
|
+
makeCtx({
|
|
117
|
+
target: 'column',
|
|
118
|
+
tag: { name: 'redact', args: { strategy: 'hash' } },
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
expect(result).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('validation', () => {
|
|
126
|
+
it('@audit.redact without @audit errors', () => {
|
|
127
|
+
const result = plugin.tags!.redact.validate!({
|
|
128
|
+
target: 'column',
|
|
129
|
+
lines: [],
|
|
130
|
+
siblingTags: [],
|
|
131
|
+
fileTags: [],
|
|
132
|
+
argValues: { strategy: 'hash' },
|
|
133
|
+
objectName: 'users',
|
|
134
|
+
})
|
|
135
|
+
expect(result).toBe('@audit.redact requires @audit on the same table')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('@audit.redact with @audit passes', () => {
|
|
139
|
+
const result = plugin.tags!.redact.validate!({
|
|
140
|
+
target: 'column',
|
|
141
|
+
lines: [],
|
|
142
|
+
siblingTags: [{ namespace: 'audit', tag: null, rawArgs: null }],
|
|
143
|
+
fileTags: [
|
|
144
|
+
{
|
|
145
|
+
objectName: 'users',
|
|
146
|
+
target: 'table',
|
|
147
|
+
tags: [{ namespace: 'audit', tag: null, rawArgs: null }],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
argValues: { strategy: 'hash' },
|
|
151
|
+
objectName: 'users',
|
|
152
|
+
})
|
|
153
|
+
expect(result).toBeUndefined()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { NamespacePlugin, SqlOutput, TagContext, TagOutput } from '@sqldoc/core'
|
|
2
|
+
|
|
3
|
+
const plugin: NamespacePlugin = {
|
|
4
|
+
apiVersion: 1,
|
|
5
|
+
name: 'audit',
|
|
6
|
+
tags: {
|
|
7
|
+
$self: {
|
|
8
|
+
description: 'Enable audit logging on this table',
|
|
9
|
+
targets: ['table'],
|
|
10
|
+
args: {
|
|
11
|
+
on: { type: 'array', items: { type: 'enum', values: ['insert', 'update', 'delete'] } },
|
|
12
|
+
destination: { type: 'string' },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
redact: {
|
|
16
|
+
description: 'Mark this column for redaction in audit logs',
|
|
17
|
+
targets: ['column'],
|
|
18
|
+
args: {
|
|
19
|
+
strategy: { type: 'enum', values: ['hash', 'mask', 'omit'], required: true },
|
|
20
|
+
},
|
|
21
|
+
validate: (ctx) => {
|
|
22
|
+
// Check the parent table for @audit across the whole file
|
|
23
|
+
const objName = ctx.objectName?.toLowerCase()
|
|
24
|
+
const hasAudit = ctx.fileTags.some(
|
|
25
|
+
(ft) =>
|
|
26
|
+
ft.objectName.toLowerCase() === objName &&
|
|
27
|
+
ft.target === 'table' &&
|
|
28
|
+
ft.tags.some((t) => t.namespace === 'audit' && (t.tag === null || t.tag === '$self')),
|
|
29
|
+
)
|
|
30
|
+
if (!hasAudit) {
|
|
31
|
+
return '@audit.redact requires @audit on the same table'
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
onTag(ctx: TagContext): TagOutput | undefined {
|
|
38
|
+
const { tag, objectName } = ctx
|
|
39
|
+
|
|
40
|
+
if (tag.name === 'redact') return undefined
|
|
41
|
+
if (tag.name !== '$self' && tag.name !== null) return undefined
|
|
42
|
+
|
|
43
|
+
const args = tag.args as Record<string, unknown>
|
|
44
|
+
const operations = (args.on as string[] | undefined) ?? ['insert', 'update', 'delete']
|
|
45
|
+
const destination = (args.destination as string) || (ctx.config.destination as string) || `${objectName}_audit_log`
|
|
46
|
+
|
|
47
|
+
const ops = operations.map((op) => op.toUpperCase())
|
|
48
|
+
const triggerEvents = ops.join(' OR ')
|
|
49
|
+
|
|
50
|
+
const hasInsert = ops.includes('INSERT')
|
|
51
|
+
const hasDelete = ops.includes('DELETE')
|
|
52
|
+
|
|
53
|
+
let oldExpr = 'row_to_json(OLD)'
|
|
54
|
+
let newExpr = 'row_to_json(NEW)'
|
|
55
|
+
let returnExpr = 'RETURN NEW;'
|
|
56
|
+
|
|
57
|
+
if (hasInsert && !hasDelete) {
|
|
58
|
+
oldExpr = "CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE row_to_json(OLD) END"
|
|
59
|
+
} else if (hasDelete && !hasInsert) {
|
|
60
|
+
newExpr = "CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE row_to_json(NEW) END"
|
|
61
|
+
returnExpr = "RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;"
|
|
62
|
+
} else if (hasInsert && hasDelete) {
|
|
63
|
+
oldExpr = "CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE row_to_json(OLD) END"
|
|
64
|
+
newExpr = "CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE row_to_json(NEW) END"
|
|
65
|
+
returnExpr = "RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tableSql = `CREATE TABLE IF NOT EXISTS "${destination}" (
|
|
69
|
+
id BIGSERIAL PRIMARY KEY,
|
|
70
|
+
table_name TEXT NOT NULL,
|
|
71
|
+
operation TEXT NOT NULL,
|
|
72
|
+
old_data JSONB,
|
|
73
|
+
new_data JSONB,
|
|
74
|
+
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
75
|
+
);`
|
|
76
|
+
|
|
77
|
+
const fnSql = `CREATE OR REPLACE FUNCTION "${objectName}_audit_fn"() RETURNS TRIGGER AS $$
|
|
78
|
+
BEGIN
|
|
79
|
+
INSERT INTO "${destination}" (table_name, operation, old_data, new_data, changed_at)
|
|
80
|
+
VALUES (TG_TABLE_NAME, TG_OP, ${oldExpr}, ${newExpr}, now());
|
|
81
|
+
${returnExpr}
|
|
82
|
+
END;
|
|
83
|
+
$$ LANGUAGE plpgsql;`
|
|
84
|
+
|
|
85
|
+
const triggerSql = `CREATE TRIGGER "${objectName}_audit_trigger"
|
|
86
|
+
AFTER ${triggerEvents} ON "${objectName}"
|
|
87
|
+
FOR EACH ROW EXECUTE FUNCTION "${objectName}_audit_fn"();`
|
|
88
|
+
|
|
89
|
+
const sql: SqlOutput[] = [{ sql: tableSql }, { sql: fnSql }, { sql: triggerSql }]
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sql,
|
|
93
|
+
docs: {
|
|
94
|
+
relationships: [
|
|
95
|
+
{
|
|
96
|
+
from: objectName,
|
|
97
|
+
to: destination,
|
|
98
|
+
label: 'audit events',
|
|
99
|
+
style: 'dashed',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
annotations: [
|
|
103
|
+
{
|
|
104
|
+
object: objectName,
|
|
105
|
+
text: `Audited (${operations.join(', ')})`,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
lintRules: [
|
|
113
|
+
{
|
|
114
|
+
name: 'audit.require-audit',
|
|
115
|
+
description: 'Tables should have an @audit tag',
|
|
116
|
+
default: 'warn',
|
|
117
|
+
check(ctx) {
|
|
118
|
+
const diagnostics = []
|
|
119
|
+
for (const output of ctx.outputs) {
|
|
120
|
+
// Collect all table-level objects
|
|
121
|
+
const tableObjects = output.fileTags.filter((obj) => obj.target === 'table' && !obj.objectName.includes('.'))
|
|
122
|
+
|
|
123
|
+
for (const obj of tableObjects) {
|
|
124
|
+
const hasAudit = obj.tags.some((t) => t.namespace === 'audit' && (t.tag === null || t.tag === '$self'))
|
|
125
|
+
if (!hasAudit) {
|
|
126
|
+
diagnostics.push({
|
|
127
|
+
objectName: obj.objectName,
|
|
128
|
+
sourceFile: output.sourceFile,
|
|
129
|
+
message: `Table '${obj.objectName}' has no @audit tag`,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return diagnostics
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default plugin
|