@objectstack/plugin-security 4.0.4 → 4.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/README.md +97 -27
- package/dist/index.d.mts +5407 -566
- package/dist/index.d.ts +5407 -566
- package/dist/index.js +923 -183
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +921 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +33 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -264
- package/src/field-masker.ts +0 -75
- package/src/index.ts +0 -16
- package/src/objects/index.ts +0 -10
- package/src/objects/sys-permission-set.object.ts +0 -94
- package/src/objects/sys-role.object.ts +0 -93
- package/src/permission-evaluator.ts +0 -112
- package/src/rls-compiler.ts +0 -143
- package/src/security-plugin.test.ts +0 -302
- package/src/security-plugin.ts +0 -181
- package/tsconfig.json +0 -18
package/src/rls-compiler.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { RowLevelSecurityPolicy } from '@objectstack/spec/security';
|
|
4
|
-
import type { ExecutionContext } from '@objectstack/spec/kernel';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* RLS User Context
|
|
8
|
-
* Variables available for RLS expression evaluation.
|
|
9
|
-
*/
|
|
10
|
-
interface RLSUserContext {
|
|
11
|
-
id?: string;
|
|
12
|
-
tenant_id?: string;
|
|
13
|
-
roles?: string[];
|
|
14
|
-
[key: string]: unknown;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* RLSCompiler
|
|
19
|
-
*
|
|
20
|
-
* Compiles Row-Level Security policy expressions into query filters.
|
|
21
|
-
* Converts `using` / `check` expressions into ObjectQL-compatible filter conditions.
|
|
22
|
-
*/
|
|
23
|
-
export class RLSCompiler {
|
|
24
|
-
/**
|
|
25
|
-
* Compile RLS policies into a query filter for the given user context.
|
|
26
|
-
* Multiple policies for the same object/operation are OR-combined (any match allows access).
|
|
27
|
-
*/
|
|
28
|
-
compileFilter(
|
|
29
|
-
policies: RowLevelSecurityPolicy[],
|
|
30
|
-
executionContext?: ExecutionContext
|
|
31
|
-
): Record<string, unknown> | null {
|
|
32
|
-
if (policies.length === 0) return null;
|
|
33
|
-
|
|
34
|
-
const userCtx: RLSUserContext = {
|
|
35
|
-
id: executionContext?.userId,
|
|
36
|
-
tenant_id: executionContext?.tenantId,
|
|
37
|
-
roles: executionContext?.roles,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const filters: Record<string, unknown>[] = [];
|
|
41
|
-
|
|
42
|
-
for (const policy of policies) {
|
|
43
|
-
if (!policy.using) continue;
|
|
44
|
-
const filter = this.compileExpression(policy.using, userCtx);
|
|
45
|
-
if (filter) {
|
|
46
|
-
filters.push(filter);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (filters.length === 0) return null;
|
|
51
|
-
if (filters.length === 1) return filters[0];
|
|
52
|
-
|
|
53
|
-
// Multiple policies: OR-combine (any policy allows access)
|
|
54
|
-
return { $or: filters };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Compile a single RLS expression into a query filter.
|
|
59
|
-
*
|
|
60
|
-
* Supports simple expressions like:
|
|
61
|
-
* - "field_name = current_user.property"
|
|
62
|
-
* - "field_name IN (current_user.array_property)"
|
|
63
|
-
* - "field_name = 'literal_value'"
|
|
64
|
-
*/
|
|
65
|
-
compileExpression(
|
|
66
|
-
expression: string,
|
|
67
|
-
userCtx: RLSUserContext
|
|
68
|
-
): Record<string, unknown> | null {
|
|
69
|
-
if (!expression) return null;
|
|
70
|
-
|
|
71
|
-
// Handle simple equality: "field = current_user.property"
|
|
72
|
-
const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
|
|
73
|
-
if (eqMatch) {
|
|
74
|
-
const [, field, prop] = eqMatch;
|
|
75
|
-
const value = userCtx[prop];
|
|
76
|
-
if (value === undefined) return null;
|
|
77
|
-
return { [field]: value };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Handle literal equality: "field = 'value'"
|
|
81
|
-
const litMatch = expression.match(/^\s*(\w+)\s*=\s*'([^']*)'\s*$/);
|
|
82
|
-
if (litMatch) {
|
|
83
|
-
const [, field, value] = litMatch;
|
|
84
|
-
return { [field]: value };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Handle IN: "field IN (current_user.array_property)"
|
|
88
|
-
const inMatch = expression.match(/^\s*(\w+)\s+IN\s+\(\s*current_user\.(\w+)\s*\)\s*$/i);
|
|
89
|
-
if (inMatch) {
|
|
90
|
-
const [, field, prop] = inMatch;
|
|
91
|
-
const value = userCtx[prop];
|
|
92
|
-
if (!Array.isArray(value)) return null;
|
|
93
|
-
return { [field]: { $in: value } };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Unsupported expression: return null (no additional RLS filter applied).
|
|
97
|
-
// Note: callers should treat absence of RLS policies as "allow all" only when
|
|
98
|
-
// no policies are defined. If policies exist but cannot be compiled, the caller
|
|
99
|
-
// may want to deny access as a safety measure.
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Get applicable RLS policies for a given object and operation.
|
|
105
|
-
*/
|
|
106
|
-
getApplicablePolicies(
|
|
107
|
-
objectName: string,
|
|
108
|
-
operation: string,
|
|
109
|
-
allPolicies: RowLevelSecurityPolicy[]
|
|
110
|
-
): RowLevelSecurityPolicy[] {
|
|
111
|
-
// Map engine operation to RLS operation type
|
|
112
|
-
const rlsOp = this.mapOperationToRLS(operation);
|
|
113
|
-
|
|
114
|
-
return allPolicies.filter(policy => {
|
|
115
|
-
// Check object match
|
|
116
|
-
if (policy.object !== objectName && policy.object !== '*') return false;
|
|
117
|
-
|
|
118
|
-
// Check operation match
|
|
119
|
-
if (policy.operation === 'all') return true;
|
|
120
|
-
if (policy.operation === rlsOp) return true;
|
|
121
|
-
|
|
122
|
-
return false;
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private mapOperationToRLS(operation: string): string {
|
|
127
|
-
switch (operation) {
|
|
128
|
-
case 'find':
|
|
129
|
-
case 'findOne':
|
|
130
|
-
case 'count':
|
|
131
|
-
case 'aggregate':
|
|
132
|
-
return 'select';
|
|
133
|
-
case 'insert':
|
|
134
|
-
return 'insert';
|
|
135
|
-
case 'update':
|
|
136
|
-
return 'update';
|
|
137
|
-
case 'delete':
|
|
138
|
-
return 'delete';
|
|
139
|
-
default:
|
|
140
|
-
return 'select';
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
@@ -1,302 +0,0 @@
|
|
|
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 manifestService = { register: vi.fn() };
|
|
25
|
-
const ctx: any = {
|
|
26
|
-
logger: { info: vi.fn(), warn: vi.fn() },
|
|
27
|
-
registerService: vi.fn(),
|
|
28
|
-
getService: vi.fn().mockImplementation((name: string) => {
|
|
29
|
-
if (name === 'manifest') return manifestService;
|
|
30
|
-
return undefined;
|
|
31
|
-
}),
|
|
32
|
-
};
|
|
33
|
-
await plugin.init(ctx);
|
|
34
|
-
expect(ctx.registerService).toHaveBeenCalledWith('security.permissions', expect.any(PermissionEvaluator));
|
|
35
|
-
expect(ctx.registerService).toHaveBeenCalledWith('security.rls', expect.any(RLSCompiler));
|
|
36
|
-
expect(ctx.registerService).toHaveBeenCalledWith('security.fieldMasker', expect.any(FieldMasker));
|
|
37
|
-
expect(manifestService.register).toHaveBeenCalled();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('should warn and return when objectql service is missing', async () => {
|
|
41
|
-
const plugin = new SecurityPlugin();
|
|
42
|
-
const manifestService = { register: vi.fn() };
|
|
43
|
-
const ctx: any = {
|
|
44
|
-
logger: { info: vi.fn(), warn: vi.fn() },
|
|
45
|
-
registerService: vi.fn(),
|
|
46
|
-
getService: vi.fn().mockImplementation((name: string) => {
|
|
47
|
-
if (name === 'manifest') return manifestService;
|
|
48
|
-
throw new Error('not found');
|
|
49
|
-
}),
|
|
50
|
-
};
|
|
51
|
-
await plugin.init(ctx);
|
|
52
|
-
await plugin.start(ctx);
|
|
53
|
-
expect(ctx.logger.warn).toHaveBeenCalled();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('should warn when objectql does not support middleware', async () => {
|
|
57
|
-
const plugin = new SecurityPlugin();
|
|
58
|
-
const manifestService = { register: vi.fn() };
|
|
59
|
-
const ctx: any = {
|
|
60
|
-
logger: { info: vi.fn(), warn: vi.fn() },
|
|
61
|
-
registerService: vi.fn(),
|
|
62
|
-
getService: vi.fn().mockImplementation((name: string) => {
|
|
63
|
-
if (name === 'manifest') return manifestService;
|
|
64
|
-
return {}; // objectql without registerMiddleware
|
|
65
|
-
}),
|
|
66
|
-
};
|
|
67
|
-
await plugin.init(ctx);
|
|
68
|
-
await plugin.start(ctx);
|
|
69
|
-
expect(ctx.logger.warn).toHaveBeenCalled();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should register middleware when objectql supports it', async () => {
|
|
73
|
-
const plugin = new SecurityPlugin();
|
|
74
|
-
const registerMiddleware = vi.fn();
|
|
75
|
-
const manifestService = { register: vi.fn() };
|
|
76
|
-
const ctx: any = {
|
|
77
|
-
logger: { info: vi.fn(), warn: vi.fn() },
|
|
78
|
-
registerService: vi.fn(),
|
|
79
|
-
getService: vi.fn().mockImplementation((name: string) => {
|
|
80
|
-
if (name === 'manifest') return manifestService;
|
|
81
|
-
return { registerMiddleware };
|
|
82
|
-
}),
|
|
83
|
-
};
|
|
84
|
-
await plugin.init(ctx);
|
|
85
|
-
await plugin.start(ctx);
|
|
86
|
-
expect(registerMiddleware).toHaveBeenCalledWith(expect.any(Function));
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should destroy without error', async () => {
|
|
90
|
-
const plugin = new SecurityPlugin();
|
|
91
|
-
await expect(plugin.destroy()).resolves.toBeUndefined();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// PermissionEvaluator
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
describe('PermissionEvaluator', () => {
|
|
99
|
-
const makePermSet = (
|
|
100
|
-
name: string,
|
|
101
|
-
objects: PermissionSet['objects'] = {},
|
|
102
|
-
fields: PermissionSet['fields'] = {}
|
|
103
|
-
): PermissionSet => ({ name, objects, fields });
|
|
104
|
-
|
|
105
|
-
it('should allow read when allowRead is true', () => {
|
|
106
|
-
const evaluator = new PermissionEvaluator();
|
|
107
|
-
const ps = makePermSet('admin', { contact: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } });
|
|
108
|
-
expect(evaluator.checkObjectPermission('find', 'contact', [ps])).toBe(true);
|
|
109
|
-
expect(evaluator.checkObjectPermission('findOne', 'contact', [ps])).toBe(true);
|
|
110
|
-
expect(evaluator.checkObjectPermission('count', 'contact', [ps])).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('should deny when no permission set matches', () => {
|
|
114
|
-
const evaluator = new PermissionEvaluator();
|
|
115
|
-
const ps = makePermSet('readonly', { contact: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false } });
|
|
116
|
-
expect(evaluator.checkObjectPermission('insert', 'contact', [ps])).toBe(false);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('should allow unknown operations by default', () => {
|
|
120
|
-
const evaluator = new PermissionEvaluator();
|
|
121
|
-
expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('should allow via viewAllRecords', () => {
|
|
125
|
-
const evaluator = new PermissionEvaluator();
|
|
126
|
-
const ps = makePermSet('viewer', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true } });
|
|
127
|
-
expect(evaluator.checkObjectPermission('find', 'task', [ps])).toBe(true);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('should allow edit/delete via modifyAllRecords', () => {
|
|
131
|
-
const evaluator = new PermissionEvaluator();
|
|
132
|
-
const ps = makePermSet('manager', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, modifyAllRecords: true } });
|
|
133
|
-
expect(evaluator.checkObjectPermission('update', 'task', [ps])).toBe(true);
|
|
134
|
-
expect(evaluator.checkObjectPermission('delete', 'task', [ps])).toBe(true);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('should merge field permissions (most permissive)', () => {
|
|
138
|
-
const evaluator = new PermissionEvaluator();
|
|
139
|
-
const ps1 = makePermSet('ps1', {}, { 'contact.email': { readable: true, editable: false } });
|
|
140
|
-
const ps2 = makePermSet('ps2', {}, { 'contact.email': { readable: false, editable: true } });
|
|
141
|
-
const result = evaluator.getFieldPermissions('contact', [ps1, ps2]);
|
|
142
|
-
expect(result['email']).toEqual({ readable: true, editable: true });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('should filter field permissions to the correct object', () => {
|
|
146
|
-
const evaluator = new PermissionEvaluator();
|
|
147
|
-
const ps = makePermSet('ps', {}, {
|
|
148
|
-
'contact.email': { readable: true, editable: false },
|
|
149
|
-
'task.title': { readable: true, editable: true },
|
|
150
|
-
});
|
|
151
|
-
const result = evaluator.getFieldPermissions('contact', [ps]);
|
|
152
|
-
expect(result['email']).toBeDefined();
|
|
153
|
-
expect(result['title']).toBeUndefined();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('should resolve permission sets from metadata service by role name', () => {
|
|
157
|
-
const evaluator = new PermissionEvaluator();
|
|
158
|
-
const ps1 = { name: 'admin' };
|
|
159
|
-
const ps2 = { name: 'viewer' };
|
|
160
|
-
const metadata = { list: vi.fn().mockReturnValue([ps1, ps2]) };
|
|
161
|
-
const result = evaluator.resolvePermissionSets(['admin'], metadata);
|
|
162
|
-
expect(result).toEqual([ps1]);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('should return empty array when metadata has no permission sets', () => {
|
|
166
|
-
const evaluator = new PermissionEvaluator();
|
|
167
|
-
const metadata = { list: vi.fn().mockReturnValue([]) };
|
|
168
|
-
expect(evaluator.resolvePermissionSets(['admin'], metadata)).toEqual([]);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// FieldMasker
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
describe('FieldMasker', () => {
|
|
176
|
-
it('should return results unchanged when no field permissions', () => {
|
|
177
|
-
const masker = new FieldMasker();
|
|
178
|
-
const records = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
|
|
179
|
-
expect(masker.maskResults(records, {}, 'contact')).toEqual(records);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('should remove non-readable fields from records', () => {
|
|
183
|
-
const masker = new FieldMasker();
|
|
184
|
-
const records = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
|
|
185
|
-
const perms = { email: { readable: false, editable: false } };
|
|
186
|
-
const result = masker.maskResults(records, perms, 'contact') as any[];
|
|
187
|
-
expect(result[0].email).toBeUndefined();
|
|
188
|
-
expect(result[0].name).toBe('Alice');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should handle single record (non-array)', () => {
|
|
192
|
-
const masker = new FieldMasker();
|
|
193
|
-
const record = { id: '1', ssn: '123-45-6789', name: 'Bob' };
|
|
194
|
-
const perms = { ssn: { readable: false, editable: false } };
|
|
195
|
-
const result = masker.maskResults(record, perms, 'person') as any;
|
|
196
|
-
expect(result.ssn).toBeUndefined();
|
|
197
|
-
expect(result.name).toBe('Bob');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('should preserve readable fields', () => {
|
|
201
|
-
const masker = new FieldMasker();
|
|
202
|
-
const record = { id: '1', name: 'Carol', secret: 'x' };
|
|
203
|
-
const perms = {
|
|
204
|
-
name: { readable: true, editable: true },
|
|
205
|
-
secret: { readable: false, editable: false },
|
|
206
|
-
};
|
|
207
|
-
const result = masker.maskResults(record, perms, 'user') as any;
|
|
208
|
-
expect(result.name).toBe('Carol');
|
|
209
|
-
expect(result.secret).toBeUndefined();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('should return non-editable fields', () => {
|
|
213
|
-
const masker = new FieldMasker();
|
|
214
|
-
const perms = {
|
|
215
|
-
email: { readable: true, editable: false },
|
|
216
|
-
name: { readable: true, editable: true },
|
|
217
|
-
};
|
|
218
|
-
const nonEditable = masker.getNonEditableFields(perms);
|
|
219
|
-
expect(nonEditable).toContain('email');
|
|
220
|
-
expect(nonEditable).not.toContain('name');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('should strip non-editable fields from write data', () => {
|
|
224
|
-
const masker = new FieldMasker();
|
|
225
|
-
const data = { name: 'Dave', email: 'dave@example.com', createdAt: '2024' };
|
|
226
|
-
const perms = {
|
|
227
|
-
email: { readable: true, editable: false },
|
|
228
|
-
createdAt: { readable: true, editable: false },
|
|
229
|
-
name: { readable: true, editable: true },
|
|
230
|
-
};
|
|
231
|
-
const result = masker.stripNonEditableFields(data, perms);
|
|
232
|
-
expect(result.name).toBe('Dave');
|
|
233
|
-
expect(result.email).toBeUndefined();
|
|
234
|
-
expect(result.createdAt).toBeUndefined();
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
// RLSCompiler
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
describe('RLSCompiler', () => {
|
|
242
|
-
it('should return null for empty policies', () => {
|
|
243
|
-
const compiler = new RLSCompiler();
|
|
244
|
-
expect(compiler.compileFilter([])).toBeNull();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('should compile equality expression with current_user property', () => {
|
|
248
|
-
const compiler = new RLSCompiler();
|
|
249
|
-
const policy: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' };
|
|
250
|
-
const ctx: any = { userId: 'user-42', tenantId: 'tenant-1', roles: [] };
|
|
251
|
-
const filter = compiler.compileFilter([policy], ctx);
|
|
252
|
-
expect(filter).toEqual({ owner_id: 'user-42' });
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('should compile literal equality expression', () => {
|
|
256
|
-
const compiler = new RLSCompiler();
|
|
257
|
-
const policy: any = { object: 'doc', operation: 'select', using: "status = 'published'" };
|
|
258
|
-
const filter = compiler.compileFilter([policy]);
|
|
259
|
-
expect(filter).toEqual({ status: 'published' });
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('should compile IN expression with array property', () => {
|
|
263
|
-
const compiler = new RLSCompiler();
|
|
264
|
-
const policy: any = { object: 'project', operation: 'select', using: 'id IN (current_user.roles)' };
|
|
265
|
-
const ctx: any = { userId: 'u1', tenantId: 't1', roles: ['role-a', 'role-b'] };
|
|
266
|
-
const filter = compiler.compileFilter([policy], ctx);
|
|
267
|
-
expect(filter).toEqual({ id: { $in: ['role-a', 'role-b'] } });
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('should OR-combine multiple policies', () => {
|
|
271
|
-
const compiler = new RLSCompiler();
|
|
272
|
-
const p1: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' };
|
|
273
|
-
const p2: any = { object: 'task', operation: 'select', using: "status = 'public'" };
|
|
274
|
-
const ctx: any = { userId: 'u99', tenantId: 't1', roles: [] };
|
|
275
|
-
const filter = compiler.compileFilter([p1, p2], ctx);
|
|
276
|
-
expect(filter).toEqual({ $or: [{ owner_id: 'u99' }, { status: 'public' }] });
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('should return null for unsupported expression', () => {
|
|
280
|
-
const compiler = new RLSCompiler();
|
|
281
|
-
const policy: any = { object: 'x', operation: 'select', using: 'complex expression WITH unsupported syntax' };
|
|
282
|
-
const filter = compiler.compileFilter([policy]);
|
|
283
|
-
expect(filter).toBeNull();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('should get applicable policies for object and operation', () => {
|
|
287
|
-
const compiler = new RLSCompiler();
|
|
288
|
-
const policies: any[] = [
|
|
289
|
-
{ object: 'task', operation: 'select', using: 'owner_id = current_user.id' },
|
|
290
|
-
{ object: 'task', operation: 'insert', using: "status = 'open'" },
|
|
291
|
-
{ object: 'contact', operation: 'all', using: 'tenant_id = current_user.tenant_id' },
|
|
292
|
-
{ object: '*', operation: 'all', using: "active = 'true'" },
|
|
293
|
-
];
|
|
294
|
-
|
|
295
|
-
const taskSelect = compiler.getApplicablePolicies('task', 'find', policies);
|
|
296
|
-
expect(taskSelect).toHaveLength(2); // task select + * all
|
|
297
|
-
const taskInsert = compiler.getApplicablePolicies('task', 'insert', policies);
|
|
298
|
-
expect(taskInsert).toHaveLength(2); // task insert + * all
|
|
299
|
-
const contactFind = compiler.getApplicablePolicies('contact', 'find', policies);
|
|
300
|
-
expect(contactFind).toHaveLength(2); // contact all + * all
|
|
301
|
-
});
|
|
302
|
-
});
|
package/src/security-plugin.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security';
|
|
5
|
-
import { PermissionEvaluator } from './permission-evaluator.js';
|
|
6
|
-
import { RLSCompiler } from './rls-compiler.js';
|
|
7
|
-
import { FieldMasker } from './field-masker.js';
|
|
8
|
-
import { SysRole, SysPermissionSet } from './objects/index.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* SecurityPlugin
|
|
12
|
-
*
|
|
13
|
-
* Provides RBAC, Row-Level Security, and Field-Level Security runtime.
|
|
14
|
-
* Registers as an engine middleware on the ObjectQL engine.
|
|
15
|
-
*
|
|
16
|
-
* This plugin is fully optional — without it, the system operates
|
|
17
|
-
* without permission checks (same as current behavior).
|
|
18
|
-
*
|
|
19
|
-
* Dependencies:
|
|
20
|
-
* - objectql service (ObjectQL engine with middleware support)
|
|
21
|
-
* - metadata service (MetadataFacade for reading permission sets and RLS policies)
|
|
22
|
-
*/
|
|
23
|
-
export class SecurityPlugin implements Plugin {
|
|
24
|
-
name = 'com.objectstack.security';
|
|
25
|
-
type = 'standard';
|
|
26
|
-
version = '1.0.0';
|
|
27
|
-
dependencies = ['com.objectstack.engine.objectql'];
|
|
28
|
-
|
|
29
|
-
private permissionEvaluator = new PermissionEvaluator();
|
|
30
|
-
private rlsCompiler = new RLSCompiler();
|
|
31
|
-
private fieldMasker = new FieldMasker();
|
|
32
|
-
|
|
33
|
-
async init(ctx: PluginContext): Promise<void> {
|
|
34
|
-
ctx.logger.info('Initializing Security Plugin...');
|
|
35
|
-
|
|
36
|
-
// Register security services
|
|
37
|
-
ctx.registerService('security.permissions', this.permissionEvaluator);
|
|
38
|
-
ctx.registerService('security.rls', this.rlsCompiler);
|
|
39
|
-
ctx.registerService('security.fieldMasker', this.fieldMasker);
|
|
40
|
-
|
|
41
|
-
// Register security system objects via the manifest service.
|
|
42
|
-
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
43
|
-
id: 'com.objectstack.security',
|
|
44
|
-
name: 'Security',
|
|
45
|
-
version: '1.0.0',
|
|
46
|
-
type: 'plugin',
|
|
47
|
-
namespace: 'sys',
|
|
48
|
-
objects: [SysRole, SysPermissionSet],
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Contribute navigation items to the Setup App (if SetupPlugin is loaded).
|
|
52
|
-
try {
|
|
53
|
-
const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
|
|
54
|
-
if (setupNav) {
|
|
55
|
-
setupNav.contribute({
|
|
56
|
-
areaId: 'area_administration',
|
|
57
|
-
items: [
|
|
58
|
-
{ id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'role', icon: 'shield-check', order: 60 },
|
|
59
|
-
{ id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'permission_set', icon: 'lock', order: 70 },
|
|
60
|
-
],
|
|
61
|
-
});
|
|
62
|
-
ctx.logger.info('Security navigation items contributed to Setup App');
|
|
63
|
-
}
|
|
64
|
-
} catch {
|
|
65
|
-
// SetupPlugin not loaded — skip silently
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
ctx.logger.info('Security Plugin initialized');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async start(ctx: PluginContext): Promise<void> {
|
|
72
|
-
ctx.logger.info('Starting Security Plugin...');
|
|
73
|
-
|
|
74
|
-
// Get required services
|
|
75
|
-
let ql: any;
|
|
76
|
-
let metadata: any;
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
ql = ctx.getService('objectql');
|
|
80
|
-
metadata = ctx.getService('metadata');
|
|
81
|
-
} catch (e) {
|
|
82
|
-
ctx.logger.warn('ObjectQL or metadata service not available, security middleware not registered');
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!ql || typeof ql.registerMiddleware !== 'function') {
|
|
87
|
-
ctx.logger.warn('ObjectQL engine does not support middleware, security middleware not registered');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Register security middleware
|
|
92
|
-
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
|
|
93
|
-
// System operations bypass security
|
|
94
|
-
if (opCtx.context?.isSystem) {
|
|
95
|
-
return next();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const roles = opCtx.context?.roles ?? [];
|
|
99
|
-
|
|
100
|
-
// Skip security checks if no roles (anonymous/unauthenticated)
|
|
101
|
-
// The auth middleware should handle authentication separately
|
|
102
|
-
if (roles.length === 0 && !opCtx.context?.userId) {
|
|
103
|
-
return next();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 1. Resolve permission sets for the user's roles
|
|
107
|
-
let permissionSets: PermissionSet[] = [];
|
|
108
|
-
try {
|
|
109
|
-
permissionSets = this.permissionEvaluator.resolvePermissionSets(roles, metadata);
|
|
110
|
-
} catch (e) {
|
|
111
|
-
// If metadata service is misconfigured, log and continue without permission checks
|
|
112
|
-
// rather than blocking all operations
|
|
113
|
-
return next();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 2. CRUD permission check
|
|
117
|
-
if (permissionSets.length > 0) {
|
|
118
|
-
const allowed = this.permissionEvaluator.checkObjectPermission(
|
|
119
|
-
opCtx.operation,
|
|
120
|
-
opCtx.object,
|
|
121
|
-
permissionSets
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
if (!allowed) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` +
|
|
127
|
-
`is not permitted for roles [${roles.join(', ')}]`
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 3. RLS filter injection
|
|
133
|
-
const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
|
|
134
|
-
if (allRlsPolicies.length > 0 && opCtx.ast) {
|
|
135
|
-
const rlsFilter = this.rlsCompiler.compileFilter(allRlsPolicies, opCtx.context);
|
|
136
|
-
if (rlsFilter) {
|
|
137
|
-
if (opCtx.ast.where) {
|
|
138
|
-
opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
|
|
139
|
-
} else {
|
|
140
|
-
opCtx.ast.where = rlsFilter;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
await next();
|
|
146
|
-
|
|
147
|
-
// 4. Field-level security: mask restricted fields in read results
|
|
148
|
-
if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) {
|
|
149
|
-
const fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
|
|
150
|
-
if (Object.keys(fieldPerms).length > 0) {
|
|
151
|
-
opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
ctx.logger.info('Security middleware registered on ObjectQL engine');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async destroy(): Promise<void> {
|
|
160
|
-
// No cleanup needed
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
165
|
-
*/
|
|
166
|
-
private collectRLSPolicies(
|
|
167
|
-
permissionSets: PermissionSet[],
|
|
168
|
-
objectName: string,
|
|
169
|
-
operation: string
|
|
170
|
-
): RowLevelSecurityPolicy[] {
|
|
171
|
-
const allPolicies: RowLevelSecurityPolicy[] = [];
|
|
172
|
-
|
|
173
|
-
for (const ps of permissionSets) {
|
|
174
|
-
if (ps.rowLevelSecurity) {
|
|
175
|
-
allPolicies.push(...ps.rowLevelSecurity);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies);
|
|
180
|
-
}
|
|
181
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../../tsconfig.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"outDir": "./dist",
|
|
5
|
-
"rootDir": "./src",
|
|
6
|
-
"types": [
|
|
7
|
-
"node"
|
|
8
|
-
]
|
|
9
|
-
},
|
|
10
|
-
"include": [
|
|
11
|
-
"src/**/*"
|
|
12
|
-
],
|
|
13
|
-
"exclude": [
|
|
14
|
-
"dist",
|
|
15
|
-
"node_modules",
|
|
16
|
-
"**/*.test.ts"
|
|
17
|
-
]
|
|
18
|
-
}
|