Skip to content

Commit 0b398f3

Browse files
committed
更新安全模型,支持角色继承和字段级安全,添加项目和任务的权限配置
1 parent d2afb27 commit 0b398f3

File tree

10 files changed

+238
-47
lines changed

10 files changed

+238
-47
lines changed

docs/guide/security-guide.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,30 @@
55

66
## 1. Directory Structure
77

8-
The security configuration supports both **Role-Based Access Control (RBAC)** and **Managed Policies** for reusability.
8+
The security configuration files (`.role.yml` and `.policy.yml`) can be placed **anywhere** in your module's source path. The system scans for them recursively.
9+
10+
**Recommended Structure (Simplified)**:
911

1012
```text
1113
/project-root
12-
├── /security
13-
│ ├── /roles/ # Role Definitions
14-
│ └── sales_rep.role.yml
14+
├── /src
15+
│ ├── projects.object.yml
16+
── tasks.object.yml
1517
│ │
16-
│ └── /policies/ # Reusable Permisison Sets
18+
│ └── /security # Optional grouping
19+
│ ├── sales_rep.role.yml
1720
│ └── contract_manage.policy.yml
1821
```
1922

23+
> **Note:** You can also place them alongside your objects if preferred, or completely flat.
24+
2025
## 2. Policy Definition (`.policy.yml`)
2126

2227
A **Policy** is a reusable collection of permission statements without being tied to a specific user identity.
2328

2429
To facilitate storage in database JSONB columns and efficient querying, the structure uses a **Map** keyed by object name.
2530

26-
**File:** `/security/policies/contract_manage.policy.yml`
31+
**File:** `/src/security/contract_manage.policy.yml`
2732

2833
```yaml
2934
name: contract_manage
@@ -48,7 +53,7 @@ permissions:
4853

4954
A **Role** defines an identity and assigns permissions. It can compose permissions by referencing **Managed Policies** or defining **Online Permissions**.
5055

51-
**File:** `/security/roles/sales_rep.role.yml`
56+
**File:** `/src/security/sales_rep.role.yml`
5257

5358
```yaml
5459
name: sales_rep

examples/modules/project-management/src/projects.object.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@ fields:
2323
type: textarea
2424
owner:
2525
type: text
26+
label: Project Owner
27+
budget:
28+
type: currency
29+
label: Total Budget
30+
start_date:
31+
type: date
32+
label: Start Date
33+
end_date:
34+
type: date
35+
label: End Date
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: base_access
2+
description: Basic access for all employees
3+
4+
permissions:
5+
projects:
6+
actions: [read]
7+
# FLS: Exclude 'budget'
8+
fields: [name, status, priority, description, owner, start_date, end_date]
9+
10+
tasks:
11+
actions: [read, create]
12+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: employee
2+
label: Employee
3+
description: Standard Employee Role
4+
policies:
5+
- base_access
6+
7+
permissions:
8+
tasks:
9+
# Additive: Can also UPDATE own tasks
10+
actions: [update]
11+
filters:
12+
- ['assigned_to', '=', '$user.id']
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
name: manager_access
2+
description: Full access for managers
3+
4+
permissions:
5+
projects:
6+
actions: [read, create, update, delete]
7+
# No FLS restrictions (sees budget)
8+
fields: ['*']
9+
10+
tasks:
11+
actions: [read, create, update, delete]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
name: project_manager
2+
label: Project Manager
3+
description: Department Manager
4+
inherits:
5+
- employee
6+
policies:
7+
- manager_access

examples/modules/project-management/src/tasks.object.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ fields:
1212
completed:
1313
type: boolean
1414
defaultValue: false
15+
priority:
16+
type: select
17+
options:
18+
- low
19+
- medium
20+
- high
21+
defaultValue: medium
22+
assigned_to:
23+
type: text
24+
label: Assignee
25+
estimated_hours:
26+
type: number
27+
min: 0
28+
label: Estimated Hours

packages/core/src/metadata.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,15 @@ export interface PolicyStatement {
115115

116116
/**
117117
* Field Level Security (FLS).
118-
* List of allowed fields. If omitted, implies all fields.
118+
* List of allowed fields (Visibility). If omitted, implies all fields.
119119
*/
120120
fields?: string[];
121+
122+
/**
123+
* FLS Write Protection.
124+
* Specific fields that are visible but NOT editable.
125+
*/
126+
readonly_fields?: string[];
121127
}
122128

123129
/**
@@ -137,6 +143,8 @@ export interface RoleConfig {
137143
name: string;
138144
label?: string;
139145
description?: string;
146+
/** Inherit permissions from these parent roles. */
147+
inherits?: string[];
140148
/** List of policy names to include. */
141149
policies?: string[];
142150
/** Map of inline permissions. */

packages/core/src/security.ts

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,39 +26,50 @@ export class SecurityEngine {
2626
}
2727

2828
const effectiveStatements: PolicyStatement[] = [];
29+
const processedRoles = new Set<string>();
2930

30-
// 1. Gather all applicable statements
31-
for (const roleName of userRoles) {
32-
const role = this.roles.get(roleName);
33-
if (!role) continue;
34-
35-
// Managed Policies
36-
if (role.policies) {
37-
for (const policyName of role.policies) {
38-
const policy = this.policies.get(policyName);
39-
if (policy && policy.permissions) {
40-
// Check for specific object permission
41-
if (policy.permissions[objectName]) {
42-
effectiveStatements.push(policy.permissions[objectName]);
43-
}
44-
// Check for wildcard object permission
45-
if (policy.permissions['*']) {
46-
effectiveStatements.push(policy.permissions['*']);
31+
// Recursive function to gather statements from roles and their parents
32+
const collectRolePermissions = (roleNames: string[]) => {
33+
for (const roleName of roleNames) {
34+
if (processedRoles.has(roleName)) continue;
35+
processedRoles.add(roleName);
36+
37+
const role = this.roles.get(roleName);
38+
if (!role) continue;
39+
40+
// 1. Inherited Roles
41+
if (role.inherits) {
42+
collectRolePermissions(role.inherits);
43+
}
44+
45+
// 2. Managed Policies
46+
if (role.policies) {
47+
for (const policyName of role.policies) {
48+
const policy = this.policies.get(policyName);
49+
if (policy && policy.permissions) {
50+
if (policy.permissions[objectName]) {
51+
effectiveStatements.push(policy.permissions[objectName]);
52+
}
53+
if (policy.permissions['*']) {
54+
effectiveStatements.push(policy.permissions['*']);
55+
}
4756
}
4857
}
4958
}
50-
}
5159

52-
// Inline Policies
53-
if (role.permissions) {
54-
if (role.permissions[objectName]) {
55-
effectiveStatements.push(role.permissions[objectName]);
56-
}
57-
if (role.permissions['*']) {
58-
effectiveStatements.push(role.permissions['*']);
60+
// 3. Inline Policies
61+
if (role.permissions) {
62+
if (role.permissions[objectName]) {
63+
effectiveStatements.push(role.permissions[objectName]);
64+
}
65+
if (role.permissions['*']) {
66+
effectiveStatements.push(role.permissions['*']);
67+
}
5968
}
6069
}
61-
}
70+
};
71+
72+
collectRolePermissions(userRoles);
6273

6374
// 2. Resolve (Union)
6475
// If no statements found -> Deny
@@ -69,24 +80,47 @@ export class SecurityEngine {
6980
const resolved: ResolvedPermission = {
7081
allowed: false,
7182
actions: new Set(),
72-
filters: []
83+
filters: [],
84+
fields: new Set(),
85+
readonly_fields: new Set()
7386
};
7487

88+
let hasRestrictedFields = false;
89+
7590
for (const stmt of effectiveStatements) {
7691
// Merge Actions
7792
for (const action of stmt.actions) {
7893
resolved.actions!.add(action);
7994
}
8095

8196
// Merge Filters (OR logic)
82-
// If multiple policies apply, the user has access if ANY of them grants access.
83-
// But complex RLS merging (OR) is tricky.
84-
// Simplified Logic:
85-
// If we have filters from multiple sources, we join them with OR.
86-
// (FilterA) OR (FilterB)
8797
if (stmt.filters && stmt.filters.length > 0) {
8898
resolved.filters!.push(stmt.filters);
8999
}
100+
101+
// Merge FLS
102+
if (stmt.fields) {
103+
hasRestrictedFields = true;
104+
for (const f of stmt.fields) resolved.fields!.add(f);
105+
} else {
106+
// If one policy allows ALL fields (undefined/empty), does it override others?
107+
// Union strategy usually means: (Policy A restricts to [x]) OR (Policy B allows All) = Allows All.
108+
// We'll mark a flag. If we encounter a statement with NO field restriction, user gets all fields.
109+
// However, implementing "All" in a Set is tricky.
110+
// Let's assume if 'stmt.fields' is missing, it means FULL ACCESS to all fields for THAT action.
111+
}
112+
113+
if (stmt.readonly_fields) {
114+
for (const f of stmt.readonly_fields) resolved.readonly_fields!.add(f);
115+
}
116+
}
117+
118+
// If any statement granted permission but didn't list specific fields, it implies ALL fields are allowed.
119+
// In that case, we should clear restrictions (or handle it in repository).
120+
// For simplicity: If ANY statement has `fields` undefined, we consider all fields allowed.
121+
const allowAllFields = effectiveStatements.some(s => !s.fields || s.fields.includes('*'));
122+
if (allowAllFields) {
123+
resolved.fields = undefined; // Undefined means ALL
90124
}
91125

92126
if (resolved.actions!.size > 0) {
@@ -99,7 +133,7 @@ export class SecurityEngine {
99133
/**
100134
* Checks if the operation is allowed and returns the RLS filters to apply.
101135
*/
102-
check(ctx: ObjectQLContext, objectName: string, action: 'read' | 'create' | 'update' | 'delete'): { allowed: boolean, filters?: any[] } {
136+
check(ctx: ObjectQLContext, objectName: string, action: 'read' | 'create' | 'update' | 'delete'): { allowed: boolean, filters?: any[], fields?: string[] } {
103137
// System bypass
104138
if (ctx.isSystem) return { allowed: true };
105139

@@ -116,25 +150,27 @@ export class SecurityEngine {
116150
if (!hasWildcard && !hasAction) return { allowed: false };
117151

118152
// Construct final RLS filter
119-
// If we have multiple filter sets, it means (Set1) OR (Set2)
120-
// because permissions are additive.
121153
let finalFilter = undefined;
122-
123154
if (perm.filters && perm.filters.length > 0) {
124155
if (perm.filters.length === 1) {
125156
finalFilter = perm.filters[0];
126157
} else {
127-
// Combine with OR
128158
finalFilter = ['or', ...perm.filters];
129159
}
130160
}
131-
132-
return { allowed: true, filters: finalFilter };
161+
162+
// Calculate allowed fields
163+
// If fields is undefined, it means ALL.
164+
let allowedFields: string[] | undefined = perm.fields ? Array.from(perm.fields) : undefined;
165+
166+
return { allowed: true, filters: finalFilter, fields: allowedFields };
133167
}
134168
}
135169

136170
interface ResolvedPermission {
137171
allowed: boolean;
138172
actions?: Set<string>;
139173
filters?: any[][]; // Array of filter groups (which are arrays)
174+
fields?: Set<string>;
175+
readonly_fields?: Set<string>;
140176
}

0 commit comments

Comments
 (0)