@objectstack/plugin-security 3.2.2 → 3.2.4

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-security@3.2.2 build /home/runner/work/spec/spec/packages/plugins/plugin-security
2
+ > @objectstack/plugin-security@3.2.4 build /home/runner/work/spec/spec/packages/plugins/plugin-security
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- CJS dist/index.js 10.89 KB
14
- CJS dist/index.js.map 21.59 KB
15
- CJS ⚡️ Build success in 63ms
16
13
  ESM dist/index.mjs 9.76 KB
17
14
  ESM dist/index.mjs.map 21.06 KB
18
- ESM ⚡️ Build success in 67ms
15
+ ESM ⚡️ Build success in 83ms
16
+ CJS dist/index.js 10.89 KB
17
+ CJS dist/index.js.map 21.59 KB
18
+ CJS ⚡️ Build success in 87ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 8622ms
20
+ DTS ⚡️ Build success in 15342ms
21
21
  DTS dist/index.d.mts 4.30 KB
22
22
  DTS dist/index.d.ts 4.30 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @objectstack/plugin-security
2
2
 
3
+ ## 3.2.4
4
+
5
+ ### Patch Changes
6
+
7
+ - @objectstack/spec@3.2.4
8
+ - @objectstack/core@3.2.4
9
+
10
+ ## 3.2.3
11
+
12
+ ### Patch Changes
13
+
14
+ - @objectstack/spec@3.2.3
15
+ - @objectstack/core@3.2.3
16
+
3
17
  ## 3.2.2
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-security",
3
- "version": "3.2.2",
3
+ "version": "3.2.4",
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,8 +13,8 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "3.2.2",
17
- "@objectstack/spec": "3.2.2"
16
+ "@objectstack/core": "3.2.4",
17
+ "@objectstack/spec": "3.2.4"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^25.3.5",
@@ -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
+ });