Skip to content

Commit 10c152b

Browse files
Copilothotlong
andcommitted
Phase 4: Create plugin-security package with RBAC, RLS compiler, and field masker
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 013141d commit 10c152b

10 files changed

Lines changed: 548 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@objectstack/plugin-security",
3+
"version": "2.0.4",
4+
"license": "Apache-2.0",
5+
"description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.2.2",
25+
"typescript": "^5.0.0",
26+
"vitest": "^4.0.18"
27+
}
28+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { FieldPermission } from '@objectstack/spec/security';
4+
5+
/**
6+
* FieldMasker
7+
*
8+
* Applies field-level security by stripping restricted fields from query results.
9+
*/
10+
export class FieldMasker {
11+
/**
12+
* Mask fields in query results based on field permissions.
13+
* Removes fields that the user does not have read access to.
14+
*/
15+
maskResults(
16+
results: any | any[],
17+
fieldPermissions: Record<string, FieldPermission>,
18+
_objectName: string
19+
): any | any[] {
20+
// If no field permissions defined, return results as-is
21+
if (Object.keys(fieldPermissions).length === 0) return results;
22+
23+
// Get list of non-readable fields
24+
const hiddenFields = Object.entries(fieldPermissions)
25+
.filter(([, perm]) => !perm.readable)
26+
.map(([field]) => field);
27+
28+
if (hiddenFields.length === 0) return results;
29+
30+
if (Array.isArray(results)) {
31+
return results.map(record => this.maskRecord(record, hiddenFields));
32+
}
33+
34+
return this.maskRecord(results, hiddenFields);
35+
}
36+
37+
/**
38+
* Get non-editable fields for use in write operations.
39+
* Returns a list of field names that should be stripped from incoming data.
40+
*/
41+
getNonEditableFields(
42+
fieldPermissions: Record<string, FieldPermission>
43+
): string[] {
44+
return Object.entries(fieldPermissions)
45+
.filter(([, perm]) => !perm.editable)
46+
.map(([field]) => field);
47+
}
48+
49+
/**
50+
* Strip non-editable fields from write data.
51+
*/
52+
stripNonEditableFields(
53+
data: Record<string, any>,
54+
fieldPermissions: Record<string, FieldPermission>
55+
): Record<string, any> {
56+
const nonEditable = this.getNonEditableFields(fieldPermissions);
57+
if (nonEditable.length === 0) return data;
58+
59+
const result = { ...data };
60+
for (const field of nonEditable) {
61+
delete result[field];
62+
}
63+
return result;
64+
}
65+
66+
private maskRecord(record: any, hiddenFields: string[]): any {
67+
if (!record || typeof record !== 'object') return record;
68+
69+
const result = { ...record };
70+
for (const field of hiddenFields) {
71+
delete result[field];
72+
}
73+
return result;
74+
}
75+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/plugin-security
5+
*
6+
* Security Plugin for ObjectStack
7+
* Provides RBAC, Row-Level Security (RLS), and Field-Level Security runtime.
8+
*/
9+
10+
export { SecurityPlugin } from './security-plugin.js';
11+
export { PermissionEvaluator } from './permission-evaluator.js';
12+
export { RLSCompiler } from './rls-compiler.js';
13+
export { FieldMasker } from './field-masker.js';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { PermissionSet, ObjectPermission, FieldPermission } from '@objectstack/spec/security';
4+
5+
/**
6+
* Operation type mapping to permission checks
7+
*/
8+
const OPERATION_TO_PERMISSION: Record<string, keyof ObjectPermission> = {
9+
find: 'allowRead',
10+
findOne: 'allowRead',
11+
count: 'allowRead',
12+
aggregate: 'allowRead',
13+
insert: 'allowCreate',
14+
update: 'allowEdit',
15+
delete: 'allowDelete',
16+
};
17+
18+
/**
19+
* PermissionEvaluator
20+
*
21+
* Runtime evaluator for PermissionSet definitions.
22+
* Resolves aggregated permissions from roles to concrete allow/deny decisions.
23+
*/
24+
export class PermissionEvaluator {
25+
/**
26+
* Check if an operation is allowed on an object for the given permission sets.
27+
* Uses "most permissive" merging: if ANY permission set allows, it's allowed.
28+
*/
29+
checkObjectPermission(
30+
operation: string,
31+
objectName: string,
32+
permissionSets: PermissionSet[]
33+
): boolean {
34+
const permKey = OPERATION_TO_PERMISSION[operation];
35+
if (!permKey) return true; // Unknown operations are allowed by default
36+
37+
for (const ps of permissionSets) {
38+
const objPerm = ps.objects?.[objectName];
39+
if (objPerm) {
40+
// Check if modifyAllRecords is set (super-user bypass for write ops)
41+
if (['allowEdit', 'allowDelete'].includes(permKey) && objPerm.modifyAllRecords) {
42+
return true;
43+
}
44+
// Check if viewAllRecords is set (super-user bypass for read ops)
45+
if (permKey === 'allowRead' && (objPerm.viewAllRecords || objPerm.modifyAllRecords)) {
46+
return true;
47+
}
48+
// Check the specific permission
49+
if (objPerm[permKey]) {
50+
return true;
51+
}
52+
}
53+
}
54+
55+
return false;
56+
}
57+
58+
/**
59+
* Get the merged field permissions for an object.
60+
* Returns a map of field names to their effective permissions.
61+
* Uses "most permissive" merging.
62+
*/
63+
getFieldPermissions(
64+
objectName: string,
65+
permissionSets: PermissionSet[]
66+
): Record<string, FieldPermission> {
67+
const result: Record<string, FieldPermission> = {};
68+
69+
for (const ps of permissionSets) {
70+
if (!ps.fields) continue;
71+
72+
for (const [key, perm] of Object.entries(ps.fields)) {
73+
// Field keys are in format: "object_name.field_name"
74+
if (!key.startsWith(`${objectName}.`)) continue;
75+
const fieldName = key.substring(objectName.length + 1);
76+
77+
if (!result[fieldName]) {
78+
result[fieldName] = { readable: false, editable: false };
79+
}
80+
81+
// Most permissive merge
82+
if (perm.readable) result[fieldName].readable = true;
83+
if (perm.editable) result[fieldName].editable = true;
84+
}
85+
}
86+
87+
return result;
88+
}
89+
90+
/**
91+
* Resolve permission sets for a list of role names from metadata.
92+
*/
93+
resolvePermissionSets(
94+
roles: string[],
95+
metadataService: any
96+
): PermissionSet[] {
97+
const result: PermissionSet[] = [];
98+
99+
// Get all permission sets from metadata
100+
const allPermSets = metadataService.list?.('permissions') || [];
101+
102+
for (const ps of allPermSets) {
103+
// A permission set is relevant if it's a profile assigned to any of the user's roles,
104+
// or if the role name matches the permission set name
105+
if (roles.includes(ps.name)) {
106+
result.push(ps);
107+
}
108+
}
109+
110+
return result;
111+
}
112+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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 filter applied - fail-safe is to deny)
97+
return null;
98+
}
99+
100+
/**
101+
* Get applicable RLS policies for a given object and operation.
102+
*/
103+
getApplicablePolicies(
104+
objectName: string,
105+
operation: string,
106+
allPolicies: RowLevelSecurityPolicy[]
107+
): RowLevelSecurityPolicy[] {
108+
// Map engine operation to RLS operation type
109+
const rlsOp = this.mapOperationToRLS(operation);
110+
111+
return allPolicies.filter(policy => {
112+
// Check object match
113+
if (policy.object !== objectName && policy.object !== '*') return false;
114+
115+
// Check operation match
116+
if (policy.operation === 'all') return true;
117+
if (policy.operation === rlsOp) return true;
118+
119+
return false;
120+
});
121+
}
122+
123+
private mapOperationToRLS(operation: string): string {
124+
switch (operation) {
125+
case 'find':
126+
case 'findOne':
127+
case 'count':
128+
case 'aggregate':
129+
return 'select';
130+
case 'insert':
131+
return 'insert';
132+
case 'update':
133+
return 'update';
134+
case 'delete':
135+
return 'delete';
136+
default:
137+
return 'select';
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)