@sqldoc/ns-rls 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__/rls.test.ts +119 -0
- package/src/index.ts +100 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@sqldoc/ns-rls",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "Row-Level Security namespace for sqldoc -- generates CREATE POLICY and ALTER TABLE statements",
|
|
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,119 @@
|
|
|
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-rls plugin', () => {
|
|
22
|
+
it('exports apiVersion === 1', () => {
|
|
23
|
+
expect(plugin.apiVersion).toBe(1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('exports name === "rls"', () => {
|
|
27
|
+
expect(plugin.name).toBe('rls')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('has policy and $self tag entries', () => {
|
|
31
|
+
expect(plugin.tags).toHaveProperty('policy')
|
|
32
|
+
expect(plugin.tags).toHaveProperty('$self')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('onTag', () => {
|
|
36
|
+
it('@rls.$self produces ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
|
|
37
|
+
const ctx = makeCtx({
|
|
38
|
+
objectName: 'users',
|
|
39
|
+
tag: { name: '$self', args: {} },
|
|
40
|
+
})
|
|
41
|
+
const result = plugin.onTag!(ctx) as any
|
|
42
|
+
expect(result.sql).toEqual([{ sql: 'ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;' }])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('@rls with null tag name produces ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
|
|
46
|
+
const ctx = makeCtx({
|
|
47
|
+
objectName: 'users',
|
|
48
|
+
tag: { name: null, args: {} },
|
|
49
|
+
})
|
|
50
|
+
const result = plugin.onTag!(ctx) as any
|
|
51
|
+
expect(result.sql).toEqual([{ sql: 'ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;' }])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('@rls.policy with for/to/using produces CREATE POLICY', () => {
|
|
55
|
+
const ctx = makeCtx({
|
|
56
|
+
objectName: 'users',
|
|
57
|
+
tag: {
|
|
58
|
+
name: 'policy',
|
|
59
|
+
args: { for: 'select', to: 'viewer_role', using: 'true' },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
const result = plugin.onTag!(ctx) as any
|
|
63
|
+
expect(result.sql).toHaveLength(1)
|
|
64
|
+
expect(result.sql[0].sql).toContain('CREATE POLICY')
|
|
65
|
+
expect(result.sql[0].sql).toContain('"users_viewer_role_select"')
|
|
66
|
+
expect(result.sql[0].sql).toContain('ON "users"')
|
|
67
|
+
expect(result.sql[0].sql).toContain('FOR SELECT')
|
|
68
|
+
expect(result.sql[0].sql).toContain('TO viewer_role')
|
|
69
|
+
expect(result.sql[0].sql).toContain('USING (true)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('@rls.policy with using and check produces both clauses', () => {
|
|
73
|
+
const ctx = makeCtx({
|
|
74
|
+
objectName: 'users',
|
|
75
|
+
tag: {
|
|
76
|
+
name: 'policy',
|
|
77
|
+
args: {
|
|
78
|
+
for: 'all',
|
|
79
|
+
to: 'authenticated',
|
|
80
|
+
using: 'user_id = current_user_id()',
|
|
81
|
+
check: 'user_id = current_user_id()',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const result = plugin.onTag!(ctx) as any
|
|
86
|
+
expect(result.sql).toHaveLength(1)
|
|
87
|
+
expect(result.sql[0].sql).toContain('"users_authenticated_all"')
|
|
88
|
+
expect(result.sql[0].sql).toContain('FOR ALL')
|
|
89
|
+
expect(result.sql[0].sql).toContain('TO authenticated')
|
|
90
|
+
expect(result.sql[0].sql).toContain('USING (user_id = current_user_id())')
|
|
91
|
+
expect(result.sql[0].sql).toContain('WITH CHECK (user_id = current_user_id())')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('policy name is auto-generated from table + role + command', () => {
|
|
95
|
+
const ctx = makeCtx({
|
|
96
|
+
objectName: 'orders',
|
|
97
|
+
tag: {
|
|
98
|
+
name: 'policy',
|
|
99
|
+
args: { for: 'insert', to: 'admin_role', using: 'true' },
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
const result = plugin.onTag!(ctx) as any
|
|
103
|
+
expect(result.sql[0].sql).toContain('"orders_admin_role_insert"')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('@rls.policy with missing to arg defaults to PUBLIC', () => {
|
|
107
|
+
const ctx = makeCtx({
|
|
108
|
+
objectName: 'users',
|
|
109
|
+
tag: {
|
|
110
|
+
name: 'policy',
|
|
111
|
+
args: { for: 'select', using: 'true' },
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
const result = plugin.onTag!(ctx) as any
|
|
115
|
+
expect(result.sql[0].sql).toContain('TO PUBLIC')
|
|
116
|
+
expect(result.sql[0].sql).toContain('"users_public_select"')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { NamespacePlugin, TagContext, TagOutput } from '@sqldoc/core'
|
|
2
|
+
|
|
3
|
+
const plugin: NamespacePlugin = {
|
|
4
|
+
apiVersion: 1,
|
|
5
|
+
name: 'rls',
|
|
6
|
+
tags: {
|
|
7
|
+
policy: {
|
|
8
|
+
description: 'Create a row-level security policy (requires @rls on the table)',
|
|
9
|
+
targets: ['table'],
|
|
10
|
+
args: {
|
|
11
|
+
for: { type: 'string', required: false },
|
|
12
|
+
to: { type: 'string', required: false },
|
|
13
|
+
using: { type: 'string', required: false },
|
|
14
|
+
check: { type: 'string', required: false },
|
|
15
|
+
},
|
|
16
|
+
validate: (ctx) => {
|
|
17
|
+
const hasRlsSelf = ctx.siblingTags.some((t) => t.namespace === 'rls' && (t.tag === null || t.tag === '$self'))
|
|
18
|
+
if (!hasRlsSelf) {
|
|
19
|
+
return '@rls.policy requires @rls on the same table'
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
$self: {
|
|
24
|
+
description: 'Enable row-level security on this table',
|
|
25
|
+
targets: ['table'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
onTag(ctx: TagContext): TagOutput | undefined {
|
|
30
|
+
const { tag, objectName } = ctx
|
|
31
|
+
|
|
32
|
+
switch (tag.name) {
|
|
33
|
+
case '$self':
|
|
34
|
+
case null: {
|
|
35
|
+
return {
|
|
36
|
+
sql: [{ sql: `ALTER TABLE "${objectName}" ENABLE ROW LEVEL SECURITY;` }],
|
|
37
|
+
docs: {
|
|
38
|
+
annotations: [
|
|
39
|
+
{
|
|
40
|
+
object: objectName,
|
|
41
|
+
text: 'RLS enabled',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
case 'policy': {
|
|
48
|
+
const args = tag.args as Record<string, unknown>
|
|
49
|
+
const forCmd = (args.for as string) || 'ALL'
|
|
50
|
+
const toRole = (args.to as string) || 'PUBLIC'
|
|
51
|
+
const using = args.using as string | undefined
|
|
52
|
+
const check = args.check as string | undefined
|
|
53
|
+
|
|
54
|
+
const policyName = `${objectName}_${toRole.toLowerCase().replace(/\s+/g, '_')}_${forCmd.toLowerCase()}`
|
|
55
|
+
|
|
56
|
+
let policySql = `CREATE POLICY "${policyName}" ON "${objectName}"\n FOR ${forCmd.toUpperCase()}\n TO ${toRole}`
|
|
57
|
+
if (using) {
|
|
58
|
+
policySql += `\n USING (${using})`
|
|
59
|
+
}
|
|
60
|
+
if (check) {
|
|
61
|
+
policySql += `\n WITH CHECK (${check})`
|
|
62
|
+
}
|
|
63
|
+
policySql += ';'
|
|
64
|
+
|
|
65
|
+
return { sql: [{ sql: policySql }] }
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
lintRules: [
|
|
73
|
+
{
|
|
74
|
+
name: 'rls.require-policy',
|
|
75
|
+
description: 'Public tables should have RLS enabled',
|
|
76
|
+
default: 'warn',
|
|
77
|
+
check(ctx) {
|
|
78
|
+
const diagnostics = []
|
|
79
|
+
for (const output of ctx.outputs) {
|
|
80
|
+
// Collect all table-level objects
|
|
81
|
+
const tableObjects = output.fileTags.filter((obj) => obj.target === 'table' && !obj.objectName.includes('.'))
|
|
82
|
+
|
|
83
|
+
for (const obj of tableObjects) {
|
|
84
|
+
const hasRls = obj.tags.some((t) => t.namespace === 'rls' && (t.tag === null || t.tag === '$self'))
|
|
85
|
+
if (!hasRls) {
|
|
86
|
+
diagnostics.push({
|
|
87
|
+
objectName: obj.objectName,
|
|
88
|
+
sourceFile: output.sourceFile,
|
|
89
|
+
message: `Table '${obj.objectName}' has no RLS policy`,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return diagnostics
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default plugin
|