@objectstack/plugin-security 3.2.1 → 3.2.3
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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +15 -0
- package/package.json +4 -4
- package/src/security-plugin.test.ts +285 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @objectstack/plugin-security@3.2.
|
|
2
|
+
> @objectstack/plugin-security@3.2.3 build /home/runner/work/spec/spec/packages/plugins/plugin-security
|
|
3
3
|
> tsup --config ../../../tsup.config.ts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
12
|
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m9.76 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m21.06 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 75ms
|
|
16
13
|
[32mCJS[39m [1mdist/index.js [22m[32m10.89 KB[39m
|
|
17
14
|
[32mCJS[39m [1mdist/index.js.map [22m[32m21.59 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in
|
|
15
|
+
[32mCJS[39m ⚡️ Build success in 80ms
|
|
16
|
+
[32mESM[39m [1mdist/index.mjs [22m[32m9.76 KB[39m
|
|
17
|
+
[32mESM[39m [1mdist/index.mjs.map [22m[32m21.06 KB[39m
|
|
18
|
+
[32mESM[39m ⚡️ Build success in 82ms
|
|
19
19
|
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in
|
|
20
|
+
[32mDTS[39m ⚡️ Build success in 15443ms
|
|
21
21
|
[32mDTS[39m [1mdist/index.d.mts [22m[32m4.30 KB[39m
|
|
22
22
|
[32mDTS[39m [1mdist/index.d.ts [22m[32m4.30 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @objectstack/plugin-security
|
|
2
2
|
|
|
3
|
+
## 3.2.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @objectstack/spec@3.2.3
|
|
8
|
+
- @objectstack/core@3.2.3
|
|
9
|
+
|
|
10
|
+
## 3.2.2
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [46defbb]
|
|
15
|
+
- @objectstack/spec@3.2.2
|
|
16
|
+
- @objectstack/core@3.2.2
|
|
17
|
+
|
|
3
18
|
## 3.2.1
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-security",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "3.2.
|
|
17
|
-
"@objectstack/spec": "3.2.
|
|
16
|
+
"@objectstack/core": "3.2.3",
|
|
17
|
+
"@objectstack/spec": "3.2.3"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/node": "^25.3.
|
|
20
|
+
"@types/node": "^25.3.5",
|
|
21
21
|
"typescript": "^5.0.0",
|
|
22
22
|
"vitest": "^4.0.18"
|
|
23
23
|
},
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { SecurityPlugin } from './security-plugin.js';
|
|
5
|
+
import { PermissionEvaluator } from './permission-evaluator.js';
|
|
6
|
+
import { FieldMasker } from './field-masker.js';
|
|
7
|
+
import { RLSCompiler } from './rls-compiler.js';
|
|
8
|
+
import type { PermissionSet } from '@objectstack/spec/security';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// SecurityPlugin – basic metadata
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
describe('SecurityPlugin', () => {
|
|
14
|
+
it('should have correct metadata', () => {
|
|
15
|
+
const plugin = new SecurityPlugin();
|
|
16
|
+
expect(plugin.name).toBe('com.objectstack.security');
|
|
17
|
+
expect(plugin.type).toBe('standard');
|
|
18
|
+
expect(plugin.version).toBe('1.0.0');
|
|
19
|
+
expect(plugin.dependencies).toContain('com.objectstack.engine.objectql');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should register services during init', async () => {
|
|
23
|
+
const plugin = new SecurityPlugin();
|
|
24
|
+
const ctx: any = {
|
|
25
|
+
logger: { info: vi.fn(), warn: vi.fn() },
|
|
26
|
+
registerService: vi.fn(),
|
|
27
|
+
getService: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
await plugin.init(ctx);
|
|
30
|
+
expect(ctx.registerService).toHaveBeenCalledWith('security.permissions', expect.any(PermissionEvaluator));
|
|
31
|
+
expect(ctx.registerService).toHaveBeenCalledWith('security.rls', expect.any(RLSCompiler));
|
|
32
|
+
expect(ctx.registerService).toHaveBeenCalledWith('security.fieldMasker', expect.any(FieldMasker));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should warn and return when objectql service is missing', async () => {
|
|
36
|
+
const plugin = new SecurityPlugin();
|
|
37
|
+
const ctx: any = {
|
|
38
|
+
logger: { info: vi.fn(), warn: vi.fn() },
|
|
39
|
+
registerService: vi.fn(),
|
|
40
|
+
getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }),
|
|
41
|
+
};
|
|
42
|
+
await plugin.init(ctx);
|
|
43
|
+
await plugin.start(ctx);
|
|
44
|
+
expect(ctx.logger.warn).toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should warn when objectql does not support middleware', async () => {
|
|
48
|
+
const plugin = new SecurityPlugin();
|
|
49
|
+
const ctx: any = {
|
|
50
|
+
logger: { info: vi.fn(), warn: vi.fn() },
|
|
51
|
+
registerService: vi.fn(),
|
|
52
|
+
getService: vi.fn().mockReturnValue({}), // no registerMiddleware
|
|
53
|
+
};
|
|
54
|
+
await plugin.init(ctx);
|
|
55
|
+
await plugin.start(ctx);
|
|
56
|
+
expect(ctx.logger.warn).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should register middleware when objectql supports it', async () => {
|
|
60
|
+
const plugin = new SecurityPlugin();
|
|
61
|
+
const registerMiddleware = vi.fn();
|
|
62
|
+
const ctx: any = {
|
|
63
|
+
logger: { info: vi.fn(), warn: vi.fn() },
|
|
64
|
+
registerService: vi.fn(),
|
|
65
|
+
getService: vi.fn().mockReturnValue({ registerMiddleware }),
|
|
66
|
+
};
|
|
67
|
+
await plugin.init(ctx);
|
|
68
|
+
await plugin.start(ctx);
|
|
69
|
+
expect(registerMiddleware).toHaveBeenCalledWith(expect.any(Function));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should destroy without error', async () => {
|
|
73
|
+
const plugin = new SecurityPlugin();
|
|
74
|
+
await expect(plugin.destroy()).resolves.toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// PermissionEvaluator
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
describe('PermissionEvaluator', () => {
|
|
82
|
+
const makePermSet = (
|
|
83
|
+
name: string,
|
|
84
|
+
objects: PermissionSet['objects'] = {},
|
|
85
|
+
fields: PermissionSet['fields'] = {}
|
|
86
|
+
): PermissionSet => ({ name, objects, fields });
|
|
87
|
+
|
|
88
|
+
it('should allow read when allowRead is true', () => {
|
|
89
|
+
const evaluator = new PermissionEvaluator();
|
|
90
|
+
const ps = makePermSet('admin', { contact: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } });
|
|
91
|
+
expect(evaluator.checkObjectPermission('find', 'contact', [ps])).toBe(true);
|
|
92
|
+
expect(evaluator.checkObjectPermission('findOne', 'contact', [ps])).toBe(true);
|
|
93
|
+
expect(evaluator.checkObjectPermission('count', 'contact', [ps])).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should deny when no permission set matches', () => {
|
|
97
|
+
const evaluator = new PermissionEvaluator();
|
|
98
|
+
const ps = makePermSet('readonly', { contact: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false } });
|
|
99
|
+
expect(evaluator.checkObjectPermission('insert', 'contact', [ps])).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should allow unknown operations by default', () => {
|
|
103
|
+
const evaluator = new PermissionEvaluator();
|
|
104
|
+
expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should allow via viewAllRecords', () => {
|
|
108
|
+
const evaluator = new PermissionEvaluator();
|
|
109
|
+
const ps = makePermSet('viewer', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true } });
|
|
110
|
+
expect(evaluator.checkObjectPermission('find', 'task', [ps])).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should allow edit/delete via modifyAllRecords', () => {
|
|
114
|
+
const evaluator = new PermissionEvaluator();
|
|
115
|
+
const ps = makePermSet('manager', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } });
|
|
116
|
+
expect(evaluator.checkObjectPermission('update', 'task', [ps])).toBe(true);
|
|
117
|
+
expect(evaluator.checkObjectPermission('delete', 'task', [ps])).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should merge field permissions (most permissive)', () => {
|
|
121
|
+
const evaluator = new PermissionEvaluator();
|
|
122
|
+
const ps1 = makePermSet('ps1', {}, { 'contact.email': { readable: true, editable: false } });
|
|
123
|
+
const ps2 = makePermSet('ps2', {}, { 'contact.email': { readable: false, editable: true } });
|
|
124
|
+
const result = evaluator.getFieldPermissions('contact', [ps1, ps2]);
|
|
125
|
+
expect(result['email']).toEqual({ readable: true, editable: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should filter field permissions to the correct object', () => {
|
|
129
|
+
const evaluator = new PermissionEvaluator();
|
|
130
|
+
const ps = makePermSet('ps', {}, {
|
|
131
|
+
'contact.email': { readable: true, editable: false },
|
|
132
|
+
'task.title': { readable: true, editable: true },
|
|
133
|
+
});
|
|
134
|
+
const result = evaluator.getFieldPermissions('contact', [ps]);
|
|
135
|
+
expect(result['email']).toBeDefined();
|
|
136
|
+
expect(result['title']).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should resolve permission sets from metadata service by role name', () => {
|
|
140
|
+
const evaluator = new PermissionEvaluator();
|
|
141
|
+
const ps1 = { name: 'admin' };
|
|
142
|
+
const ps2 = { name: 'viewer' };
|
|
143
|
+
const metadata = { list: vi.fn().mockReturnValue([ps1, ps2]) };
|
|
144
|
+
const result = evaluator.resolvePermissionSets(['admin'], metadata);
|
|
145
|
+
expect(result).toEqual([ps1]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return empty array when metadata has no permission sets', () => {
|
|
149
|
+
const evaluator = new PermissionEvaluator();
|
|
150
|
+
const metadata = { list: vi.fn().mockReturnValue([]) };
|
|
151
|
+
expect(evaluator.resolvePermissionSets(['admin'], metadata)).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// FieldMasker
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
describe('FieldMasker', () => {
|
|
159
|
+
it('should return results unchanged when no field permissions', () => {
|
|
160
|
+
const masker = new FieldMasker();
|
|
161
|
+
const records = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
|
|
162
|
+
expect(masker.maskResults(records, {}, 'contact')).toEqual(records);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should remove non-readable fields from records', () => {
|
|
166
|
+
const masker = new FieldMasker();
|
|
167
|
+
const records = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
|
|
168
|
+
const perms = { email: { readable: false, editable: false } };
|
|
169
|
+
const result = masker.maskResults(records, perms, 'contact') as any[];
|
|
170
|
+
expect(result[0].email).toBeUndefined();
|
|
171
|
+
expect(result[0].name).toBe('Alice');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle single record (non-array)', () => {
|
|
175
|
+
const masker = new FieldMasker();
|
|
176
|
+
const record = { id: '1', ssn: '123-45-6789', name: 'Bob' };
|
|
177
|
+
const perms = { ssn: { readable: false, editable: false } };
|
|
178
|
+
const result = masker.maskResults(record, perms, 'person') as any;
|
|
179
|
+
expect(result.ssn).toBeUndefined();
|
|
180
|
+
expect(result.name).toBe('Bob');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should preserve readable fields', () => {
|
|
184
|
+
const masker = new FieldMasker();
|
|
185
|
+
const record = { id: '1', name: 'Carol', secret: 'x' };
|
|
186
|
+
const perms = {
|
|
187
|
+
name: { readable: true, editable: true },
|
|
188
|
+
secret: { readable: false, editable: false },
|
|
189
|
+
};
|
|
190
|
+
const result = masker.maskResults(record, perms, 'user') as any;
|
|
191
|
+
expect(result.name).toBe('Carol');
|
|
192
|
+
expect(result.secret).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should return non-editable fields', () => {
|
|
196
|
+
const masker = new FieldMasker();
|
|
197
|
+
const perms = {
|
|
198
|
+
email: { readable: true, editable: false },
|
|
199
|
+
name: { readable: true, editable: true },
|
|
200
|
+
};
|
|
201
|
+
const nonEditable = masker.getNonEditableFields(perms);
|
|
202
|
+
expect(nonEditable).toContain('email');
|
|
203
|
+
expect(nonEditable).not.toContain('name');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should strip non-editable fields from write data', () => {
|
|
207
|
+
const masker = new FieldMasker();
|
|
208
|
+
const data = { name: 'Dave', email: 'dave@example.com', createdAt: '2024' };
|
|
209
|
+
const perms = {
|
|
210
|
+
email: { readable: true, editable: false },
|
|
211
|
+
createdAt: { readable: true, editable: false },
|
|
212
|
+
name: { readable: true, editable: true },
|
|
213
|
+
};
|
|
214
|
+
const result = masker.stripNonEditableFields(data, perms);
|
|
215
|
+
expect(result.name).toBe('Dave');
|
|
216
|
+
expect(result.email).toBeUndefined();
|
|
217
|
+
expect(result.createdAt).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// RLSCompiler
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
describe('RLSCompiler', () => {
|
|
225
|
+
it('should return null for empty policies', () => {
|
|
226
|
+
const compiler = new RLSCompiler();
|
|
227
|
+
expect(compiler.compileFilter([])).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should compile equality expression with current_user property', () => {
|
|
231
|
+
const compiler = new RLSCompiler();
|
|
232
|
+
const policy: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' };
|
|
233
|
+
const ctx: any = { userId: 'user-42', tenantId: 'tenant-1', roles: [] };
|
|
234
|
+
const filter = compiler.compileFilter([policy], ctx);
|
|
235
|
+
expect(filter).toEqual({ owner_id: 'user-42' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should compile literal equality expression', () => {
|
|
239
|
+
const compiler = new RLSCompiler();
|
|
240
|
+
const policy: any = { object: 'doc', operation: 'select', using: "status = 'published'" };
|
|
241
|
+
const filter = compiler.compileFilter([policy]);
|
|
242
|
+
expect(filter).toEqual({ status: 'published' });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should compile IN expression with array property', () => {
|
|
246
|
+
const compiler = new RLSCompiler();
|
|
247
|
+
const policy: any = { object: 'project', operation: 'select', using: 'id IN (current_user.roles)' };
|
|
248
|
+
const ctx: any = { userId: 'u1', tenantId: 't1', roles: ['role-a', 'role-b'] };
|
|
249
|
+
const filter = compiler.compileFilter([policy], ctx);
|
|
250
|
+
expect(filter).toEqual({ id: { $in: ['role-a', 'role-b'] } });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should OR-combine multiple policies', () => {
|
|
254
|
+
const compiler = new RLSCompiler();
|
|
255
|
+
const p1: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' };
|
|
256
|
+
const p2: any = { object: 'task', operation: 'select', using: "status = 'public'" };
|
|
257
|
+
const ctx: any = { userId: 'u99', tenantId: 't1', roles: [] };
|
|
258
|
+
const filter = compiler.compileFilter([p1, p2], ctx);
|
|
259
|
+
expect(filter).toEqual({ $or: [{ owner_id: 'u99' }, { status: 'public' }] });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should return null for unsupported expression', () => {
|
|
263
|
+
const compiler = new RLSCompiler();
|
|
264
|
+
const policy: any = { object: 'x', operation: 'select', using: 'complex expression WITH unsupported syntax' };
|
|
265
|
+
const filter = compiler.compileFilter([policy]);
|
|
266
|
+
expect(filter).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should get applicable policies for object and operation', () => {
|
|
270
|
+
const compiler = new RLSCompiler();
|
|
271
|
+
const policies: any[] = [
|
|
272
|
+
{ object: 'task', operation: 'select', using: 'owner_id = current_user.id' },
|
|
273
|
+
{ object: 'task', operation: 'insert', using: "status = 'open'" },
|
|
274
|
+
{ object: 'contact', operation: 'all', using: 'tenant_id = current_user.tenant_id' },
|
|
275
|
+
{ object: '*', operation: 'all', using: "active = 'true'" },
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
const taskSelect = compiler.getApplicablePolicies('task', 'find', policies);
|
|
279
|
+
expect(taskSelect).toHaveLength(2); // task select + * all
|
|
280
|
+
const taskInsert = compiler.getApplicablePolicies('task', 'insert', policies);
|
|
281
|
+
expect(taskInsert).toHaveLength(2); // task insert + * all
|
|
282
|
+
const contactFind = compiler.getApplicablePolicies('contact', 'find', policies);
|
|
283
|
+
expect(contactFind).toHaveLength(2); // contact all + * all
|
|
284
|
+
});
|
|
285
|
+
});
|