@apso/cli 0.4.2 → 0.5.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 +287 -6
- package/dist/commands/server/scaffold.js +3 -1
- package/dist/lib/guards.d.ts +46 -0
- package/dist/lib/guards.js +137 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +5 -1
- package/dist/lib/templates/guards/guards.module.eta +54 -0
- package/dist/lib/templates/guards/index.eta +4 -0
- package/dist/lib/templates/guards/scope.guard.eta +319 -0
- package/dist/lib/types/entity.d.ts +35 -0
- package/dist/lib/types/index.d.ts +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
# Apso CLI
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
3
|
+
Generate production-ready NestJS backends from schema definitions.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Install CLI
|
|
9
|
+
npm install -g @apso/apso-cli
|
|
10
|
+
|
|
11
|
+
# 2. Create new project
|
|
12
|
+
apso server new --name myapp
|
|
13
|
+
|
|
14
|
+
# 3. Edit .apsorc to define your schema (see examples below)
|
|
15
|
+
|
|
16
|
+
# 4. Generate code
|
|
17
|
+
apso server scaffold
|
|
18
|
+
|
|
19
|
+
# 5. Start database & provision schema
|
|
20
|
+
npm run compose && npm run provision
|
|
21
|
+
|
|
22
|
+
# 6. Run development server
|
|
23
|
+
npm run start:dev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Table of Contents
|
|
29
|
+
|
|
30
|
+
- [Quick Start](#quick-start)
|
|
5
31
|
- [Usage](#usage)
|
|
32
|
+
- [Important: Never Modify Autogen Files](#-important-never-add-custom-code-to-autogen-files)
|
|
33
|
+
- [Common First-Time Mistakes](#️-common-first-time-mistakes)
|
|
6
34
|
- [Local Development](#local-development)
|
|
7
35
|
- [Populating an .apsorc File](#populating-an-apsorc-file)
|
|
8
36
|
- [Auto-Generated Code Reference](#auto-generated-code-reference)
|
|
9
|
-
- [
|
|
37
|
+
- [Relationships](#relationships)
|
|
38
|
+
- [Data Scoping (Multi-Tenant Isolation)](#data-scoping-multi-tenant-isolation)
|
|
39
|
+
- [Schema Reference](#schema-reference)
|
|
40
|
+
- [Debugging](#debugging)
|
|
10
41
|
- [Commands](#commands)
|
|
11
42
|
|
|
43
|
+
---
|
|
12
44
|
|
|
13
45
|
# Usage
|
|
14
46
|
|
|
@@ -139,13 +171,47 @@ export class LambdaDeploymentController {
|
|
|
139
171
|
|
|
140
172
|
---
|
|
141
173
|
|
|
142
|
-
**Summary:**
|
|
143
|
-
> Always put your custom code in `src/extensions/[[EntityName]]/`.
|
|
144
|
-
> Never modify files in `src/autogen/`.
|
|
174
|
+
**Summary:**
|
|
175
|
+
> Always put your custom code in `src/extensions/[[EntityName]]/`.
|
|
176
|
+
> Never modify files in `src/autogen/`.
|
|
145
177
|
> This ensures your work is safe and your project remains maintainable as you evolve your data model with Apso CLI.
|
|
146
178
|
|
|
147
179
|
---
|
|
148
180
|
|
|
181
|
+
## ⚠️ Common First-Time Mistakes
|
|
182
|
+
|
|
183
|
+
### 1. Modifying `autogen/` Files
|
|
184
|
+
**Problem:** Changes get overwritten on next scaffold
|
|
185
|
+
**Solution:** Always use `extensions/` directory for custom code
|
|
186
|
+
|
|
187
|
+
### 2. Defining Both Sides of Relationships
|
|
188
|
+
**Problem:** Duplicate properties and TypeScript errors
|
|
189
|
+
**Solution:** Define relationships **once** - Apso auto-generates the inverse side
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
```json
|
|
193
|
+
// ❌ WRONG - Creates conflicts
|
|
194
|
+
{ "from": "User", "to": "Workspace", "type": "OneToMany" }
|
|
195
|
+
{ "from": "Workspace", "to": "User", "type": "ManyToOne" }
|
|
196
|
+
|
|
197
|
+
// ✅ CORRECT - Define one side only
|
|
198
|
+
{ "from": "Workspace", "to": "User", "type": "ManyToOne", "to_name": "owner" }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 3. Skipping Database Provisioning
|
|
202
|
+
**Problem:** Server fails to start with missing table errors
|
|
203
|
+
**Solution:** Always run `npm run provision` after scaffold
|
|
204
|
+
|
|
205
|
+
### 4. Wrong .apsorc Version
|
|
206
|
+
**Problem:** Schema doesn't generate correctly
|
|
207
|
+
**Solution:** Ensure `"version": 2` at top of .apsorc
|
|
208
|
+
|
|
209
|
+
### 5. Forgetting to Start Docker
|
|
210
|
+
**Problem:** Database connection refused errors
|
|
211
|
+
**Solution:** Run `npm run compose` before starting server
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
149
215
|
# Local Development
|
|
150
216
|
|
|
151
217
|
Clone apso-cli on your machine. Navigate to the repo in your code editor and run the below commands
|
|
@@ -680,6 +746,221 @@ Refer to the [example v2 file](#example-apsorc-v2-file) for usage.
|
|
|
680
746
|
> - Avoid deep nesting and duplicate definitions.
|
|
681
747
|
> - Always inspect generated code and test your build after scaffolding.
|
|
682
748
|
|
|
749
|
+
## Data Scoping (Multi-Tenant Isolation)
|
|
750
|
+
|
|
751
|
+
Apso CLI supports automatic generation of scope guards that enforce data isolation at the application layer. This is useful for multi-tenant applications where users should only access data within their assigned scope (e.g., workspace, organization, team).
|
|
752
|
+
|
|
753
|
+
### What is scopeBy?
|
|
754
|
+
|
|
755
|
+
The `scopeBy` property on entities defines which field(s) determine the authorization scope for that entity. When configured, Apso generates NestJS guards that:
|
|
756
|
+
|
|
757
|
+
- **Auto-inject** scope values on create operations (POST requests)
|
|
758
|
+
- **Auto-filter** queries by scope values on list operations (GET without ID)
|
|
759
|
+
- **Verify ownership** on single-resource operations (GET/PUT/PATCH/DELETE by ID)
|
|
760
|
+
|
|
761
|
+
This is Apso's answer to PostgreSQL Row-Level Security (RLS), implemented at the application layer for flexibility and visibility.
|
|
762
|
+
|
|
763
|
+
### Basic Example
|
|
764
|
+
|
|
765
|
+
```json
|
|
766
|
+
{
|
|
767
|
+
"version": 2,
|
|
768
|
+
"entities": [
|
|
769
|
+
{
|
|
770
|
+
"name": "Project",
|
|
771
|
+
"scopeBy": "workspaceId",
|
|
772
|
+
"fields": [
|
|
773
|
+
{ "name": "name", "type": "text" }
|
|
774
|
+
]
|
|
775
|
+
}
|
|
776
|
+
]
|
|
777
|
+
}
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
This generates a guard that ensures:
|
|
781
|
+
- All Project queries filter by `workspaceId` from the request context
|
|
782
|
+
- New Projects automatically get the `workspaceId` injected
|
|
783
|
+
- Single Project access verifies the Project belongs to the user's workspace
|
|
784
|
+
|
|
785
|
+
### scopeBy Configuration Options
|
|
786
|
+
|
|
787
|
+
#### Single Field Scoping
|
|
788
|
+
```json
|
|
789
|
+
{
|
|
790
|
+
"name": "Project",
|
|
791
|
+
"scopeBy": "workspaceId"
|
|
792
|
+
}
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
#### Multiple Field Scoping
|
|
796
|
+
```json
|
|
797
|
+
{
|
|
798
|
+
"name": "Task",
|
|
799
|
+
"scopeBy": ["workspaceId", "projectId"]
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
#### Nested Path Scoping
|
|
804
|
+
For entities that don't have a direct scope field but inherit scope through a relationship:
|
|
805
|
+
```json
|
|
806
|
+
{
|
|
807
|
+
"name": "Comment",
|
|
808
|
+
"scopeBy": "task.workspaceId"
|
|
809
|
+
}
|
|
810
|
+
```
|
|
811
|
+
This tells the guard to look up the Task relationship and verify the workspaceId through that path.
|
|
812
|
+
|
|
813
|
+
### scopeOptions
|
|
814
|
+
|
|
815
|
+
Fine-tune scoping behavior with `scopeOptions`:
|
|
816
|
+
|
|
817
|
+
```json
|
|
818
|
+
{
|
|
819
|
+
"name": "AuditLog",
|
|
820
|
+
"scopeBy": "workspaceId",
|
|
821
|
+
"scopeOptions": {
|
|
822
|
+
"injectOnCreate": false,
|
|
823
|
+
"enforceOn": ["find", "get"],
|
|
824
|
+
"bypassRoles": ["admin", "superadmin"]
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
| Option | Type | Default | Description |
|
|
830
|
+
|--------|------|---------|-------------|
|
|
831
|
+
| `injectOnCreate` | boolean | `true` | Auto-inject scope value on POST requests |
|
|
832
|
+
| `enforceOn` | string[] | `["find", "get", "create", "update", "delete"]` | Operations where scope is enforced |
|
|
833
|
+
| `bypassRoles` | string[] | `[]` | Roles that skip scope checking |
|
|
834
|
+
|
|
835
|
+
### Generated Files
|
|
836
|
+
|
|
837
|
+
When entities have `scopeBy` configured, `apso server scaffold` generates:
|
|
838
|
+
|
|
839
|
+
```
|
|
840
|
+
src/
|
|
841
|
+
guards/
|
|
842
|
+
scope.guard.ts # Main guard implementation
|
|
843
|
+
guards.module.ts # NestJS module with providers
|
|
844
|
+
index.ts # Exports
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### Enabling Guards
|
|
848
|
+
|
|
849
|
+
Guards are generated but **not enabled globally by default** (for backward compatibility). To enable:
|
|
850
|
+
|
|
851
|
+
#### Option 1: Global Enable (Recommended)
|
|
852
|
+
Uncomment the APP_GUARD provider in `src/guards/guards.module.ts`:
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
providers: [
|
|
856
|
+
ScopeGuard,
|
|
857
|
+
// Uncomment to enable globally:
|
|
858
|
+
{
|
|
859
|
+
provide: APP_GUARD,
|
|
860
|
+
useClass: ScopeGuard,
|
|
861
|
+
},
|
|
862
|
+
],
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
#### Option 2: Per-Controller Enable
|
|
866
|
+
Apply to specific controllers:
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
import { ScopeGuard } from '../guards';
|
|
870
|
+
|
|
871
|
+
@UseGuards(ScopeGuard)
|
|
872
|
+
@Controller('projects')
|
|
873
|
+
export class ProjectController { }
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
#### Option 3: Per-Route Enable
|
|
877
|
+
Apply to specific routes:
|
|
878
|
+
|
|
879
|
+
```typescript
|
|
880
|
+
@UseGuards(ScopeGuard)
|
|
881
|
+
@Get(':id')
|
|
882
|
+
findOne(@Param('id') id: string) { }
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
### Decorators
|
|
886
|
+
|
|
887
|
+
The generated guard supports these decorators:
|
|
888
|
+
|
|
889
|
+
```typescript
|
|
890
|
+
import { Public, SkipScopeCheck } from './guards';
|
|
891
|
+
|
|
892
|
+
@Public() // Skip ALL guards for this route
|
|
893
|
+
@Get('public-endpoint')
|
|
894
|
+
publicRoute() { }
|
|
895
|
+
|
|
896
|
+
@SkipScopeCheck() // Skip only scope checking (other guards still run)
|
|
897
|
+
@Get('admin-dashboard')
|
|
898
|
+
adminRoute() { }
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
### Request Context Integration
|
|
902
|
+
|
|
903
|
+
The guard expects scope values in the request object. Set these in your authentication middleware:
|
|
904
|
+
|
|
905
|
+
```typescript
|
|
906
|
+
// In your auth middleware
|
|
907
|
+
request.workspaceId = user.currentWorkspaceId;
|
|
908
|
+
request.user = { roles: ['user'] };
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
### Complete Example
|
|
912
|
+
|
|
913
|
+
```json
|
|
914
|
+
{
|
|
915
|
+
"version": 2,
|
|
916
|
+
"entities": [
|
|
917
|
+
{
|
|
918
|
+
"name": "Workspace",
|
|
919
|
+
"fields": [{ "name": "name", "type": "text" }]
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
"name": "Project",
|
|
923
|
+
"scopeBy": "workspaceId",
|
|
924
|
+
"fields": [{ "name": "name", "type": "text" }]
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
"name": "Task",
|
|
928
|
+
"scopeBy": ["workspaceId", "projectId"],
|
|
929
|
+
"fields": [{ "name": "title", "type": "text" }]
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
"name": "Comment",
|
|
933
|
+
"scopeBy": "task.workspaceId",
|
|
934
|
+
"scopeOptions": {
|
|
935
|
+
"enforceOn": ["find", "get", "create", "delete"]
|
|
936
|
+
},
|
|
937
|
+
"fields": [{ "name": "text", "type": "text" }]
|
|
938
|
+
}
|
|
939
|
+
],
|
|
940
|
+
"relationships": [
|
|
941
|
+
{ "from": "Project", "to": "Workspace", "type": "ManyToOne" },
|
|
942
|
+
{ "from": "Task", "to": "Project", "type": "ManyToOne" },
|
|
943
|
+
{ "from": "Comment", "to": "Task", "type": "ManyToOne" }
|
|
944
|
+
]
|
|
945
|
+
}
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
### Scoping vs Authorization
|
|
949
|
+
|
|
950
|
+
**Scoping** (what `scopeBy` provides):
|
|
951
|
+
- Answers: "Which rows can this user see/modify?"
|
|
952
|
+
- Data isolation based on tenant/workspace membership
|
|
953
|
+
- Automatic filtering and injection
|
|
954
|
+
|
|
955
|
+
**Authorization** (separate concern, not covered by `scopeBy`):
|
|
956
|
+
- Answers: "Can this user perform this action?"
|
|
957
|
+
- Role-based access control (RBAC)
|
|
958
|
+
- Permission checking (create, read, update, delete)
|
|
959
|
+
|
|
960
|
+
These are intentionally separate. Use `scopeBy` for data isolation, and implement authorization guards separately for permission checking.
|
|
961
|
+
|
|
962
|
+
---
|
|
963
|
+
|
|
683
964
|
## Schema Reference
|
|
684
965
|
|
|
685
966
|
The `.apsorc` file must conform to the [APSO Configuration Schema](./apsorc.schema.json). This schema defines all valid properties, types, and constraints for your configuration file.
|
|
@@ -90,12 +90,14 @@ class Scaffold extends base_command_1.default {
|
|
|
90
90
|
console.log(`[mem] heapUsed after ${entity.name}: ${(used.heapUsed / 1024 / 1024).toFixed(2)} MB`);
|
|
91
91
|
}
|
|
92
92
|
}));
|
|
93
|
+
// Generate scope guards if any entities have scopeBy configured
|
|
94
|
+
await (0, lib_1.createGuards)(rootPath, entities);
|
|
93
95
|
await (0, lib_1.createIndexAppModule)(autogenPath, entities, lowerCaseApiType);
|
|
94
96
|
const totalBuildTime = perf_hooks_1.performance.now() - totalBuildStart;
|
|
95
97
|
console.log(`[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms`);
|
|
96
98
|
const formatStart = perf_hooks_1.performance.now();
|
|
97
99
|
console.log("[apso] Formatting files...");
|
|
98
|
-
await this.runNpmCommand(["run", "format", "src/autogen/**/*.ts"], true);
|
|
100
|
+
await this.runNpmCommand(["run", "format", "src/autogen/**/*.ts", "src/guards/**/*.ts"], true);
|
|
99
101
|
const formatTime = perf_hooks_1.performance.now() - formatStart;
|
|
100
102
|
console.log(`[apso] Finished formatting in ${formatTime.toFixed(2)} ms`);
|
|
101
103
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Entity } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Represents a scope field configuration for template rendering
|
|
4
|
+
*/
|
|
5
|
+
interface ScopeFieldConfig {
|
|
6
|
+
field: string;
|
|
7
|
+
contextKey: string;
|
|
8
|
+
direct: boolean;
|
|
9
|
+
path?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Represents a scoped entity configuration for template rendering
|
|
13
|
+
*/
|
|
14
|
+
interface ScopedEntityConfig {
|
|
15
|
+
name: string;
|
|
16
|
+
routeName: string;
|
|
17
|
+
repoName: string;
|
|
18
|
+
scopes: ScopeFieldConfig[];
|
|
19
|
+
injectOnCreate: boolean;
|
|
20
|
+
enforceOn: string[];
|
|
21
|
+
bypassRoles: string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Extracts scoped entity configurations from all entities.
|
|
25
|
+
*
|
|
26
|
+
* @param entities Array of all entities from the parsed .apsorc.
|
|
27
|
+
* @returns Array of scoped entity configurations for entities that have scopeBy defined.
|
|
28
|
+
*/
|
|
29
|
+
export declare function getScopedEntities(entities: Entity[]): ScopedEntityConfig[];
|
|
30
|
+
/**
|
|
31
|
+
* Checks if any entities have scopeBy configured.
|
|
32
|
+
*
|
|
33
|
+
* @param entities Array of all entities from the parsed .apsorc.
|
|
34
|
+
* @returns True if at least one entity has scopeBy defined.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hasScopedEntities(entities: Entity[]): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Generates the guards module directory and files.
|
|
39
|
+
* Creates scope.guard.ts, guards.module.ts, and index.ts in the guards directory.
|
|
40
|
+
*
|
|
41
|
+
* @param rootPath The root path of the generated source (e.g., 'src').
|
|
42
|
+
* @param entities All entities from the parsed .apsorc.
|
|
43
|
+
* @returns {Promise<void>} A promise that resolves when the guard files are created.
|
|
44
|
+
*/
|
|
45
|
+
export declare const createGuards: (rootPath: string, entities: Entity[]) => Promise<void>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createGuards = exports.hasScopedEntities = exports.getScopedEntities = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const Eta = tslib_1.__importStar(require("eta"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
8
|
+
const file_system_1 = require("./utils/file-system");
|
|
9
|
+
const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
|
|
10
|
+
/**
|
|
11
|
+
* Default scope options when not specified
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_SCOPE_OPTIONS = {
|
|
14
|
+
injectOnCreate: true,
|
|
15
|
+
enforceOn: ['find', 'get', 'create', 'update', 'delete'],
|
|
16
|
+
bypassRoles: [],
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Parses scopeBy configuration and returns structured scope fields.
|
|
20
|
+
*
|
|
21
|
+
* @param scopeBy The scope configuration - either a single field/path string or an array of them.
|
|
22
|
+
* @returns An array of scope field configurations.
|
|
23
|
+
*/
|
|
24
|
+
function parseScopeBy(scopeBy) {
|
|
25
|
+
const scopes = Array.isArray(scopeBy) ? scopeBy : [scopeBy];
|
|
26
|
+
return scopes.map(scope => {
|
|
27
|
+
const isDirect = !scope.includes('.');
|
|
28
|
+
const field = isDirect ? scope : scope.split('.')[0];
|
|
29
|
+
// For direct fields, contextKey is the same as field
|
|
30
|
+
// For nested paths like 'task.workspaceId', we extract 'workspaceId' as contextKey
|
|
31
|
+
const contextKey = isDirect ? scope : scope.split('.').pop();
|
|
32
|
+
return {
|
|
33
|
+
field,
|
|
34
|
+
contextKey,
|
|
35
|
+
direct: isDirect,
|
|
36
|
+
path: isDirect ? undefined : scope,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Converts entity name to lowercase pluralized route name.
|
|
42
|
+
* e.g., "Project" -> "projects", "DiscoverySession" -> "discoverysessions"
|
|
43
|
+
*
|
|
44
|
+
* @param entityName The entity name in PascalCase.
|
|
45
|
+
* @returns The lowercase pluralized route name.
|
|
46
|
+
*/
|
|
47
|
+
function toRouteName(entityName) {
|
|
48
|
+
return (0, pluralize_1.default)(entityName).toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Converts entity name to repository variable name.
|
|
52
|
+
* e.g., "Project" -> "projectRepository", "DiscoverySession" -> "discoverySessionRepository"
|
|
53
|
+
*
|
|
54
|
+
* @param entityName The entity name in PascalCase.
|
|
55
|
+
* @returns The camelCase repository variable name.
|
|
56
|
+
*/
|
|
57
|
+
function toRepoName(entityName) {
|
|
58
|
+
const camelCase = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
59
|
+
return `${camelCase}Repository`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extracts scoped entity configurations from all entities.
|
|
63
|
+
*
|
|
64
|
+
* @param entities Array of all entities from the parsed .apsorc.
|
|
65
|
+
* @returns Array of scoped entity configurations for entities that have scopeBy defined.
|
|
66
|
+
*/
|
|
67
|
+
function getScopedEntities(entities) {
|
|
68
|
+
return entities
|
|
69
|
+
.filter(entity => entity.scopeBy)
|
|
70
|
+
.map(entity => {
|
|
71
|
+
const options = { ...DEFAULT_SCOPE_OPTIONS, ...entity.scopeOptions };
|
|
72
|
+
return {
|
|
73
|
+
name: entity.name,
|
|
74
|
+
routeName: toRouteName(entity.name),
|
|
75
|
+
repoName: toRepoName(entity.name),
|
|
76
|
+
scopes: parseScopeBy(entity.scopeBy),
|
|
77
|
+
injectOnCreate: options.injectOnCreate,
|
|
78
|
+
enforceOn: options.enforceOn,
|
|
79
|
+
bypassRoles: options.bypassRoles,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
exports.getScopedEntities = getScopedEntities;
|
|
84
|
+
/**
|
|
85
|
+
* Checks if any entities have scopeBy configured.
|
|
86
|
+
*
|
|
87
|
+
* @param entities Array of all entities from the parsed .apsorc.
|
|
88
|
+
* @returns True if at least one entity has scopeBy defined.
|
|
89
|
+
*/
|
|
90
|
+
function hasScopedEntities(entities) {
|
|
91
|
+
return entities.some(entity => entity.scopeBy);
|
|
92
|
+
}
|
|
93
|
+
exports.hasScopedEntities = hasScopedEntities;
|
|
94
|
+
/**
|
|
95
|
+
* Generates the guards module directory and files.
|
|
96
|
+
* Creates scope.guard.ts, guards.module.ts, and index.ts in the guards directory.
|
|
97
|
+
*
|
|
98
|
+
* @param rootPath The root path of the generated source (e.g., 'src').
|
|
99
|
+
* @param entities All entities from the parsed .apsorc.
|
|
100
|
+
* @returns {Promise<void>} A promise that resolves when the guard files are created.
|
|
101
|
+
*/
|
|
102
|
+
const createGuards = async (rootPath, entities) => {
|
|
103
|
+
// Only generate guards if there are scoped entities
|
|
104
|
+
if (!hasScopedEntities(entities)) {
|
|
105
|
+
console.log('[apso] No scopeBy configurations found, skipping guards generation');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const guardsDir = path.join(rootPath, 'guards');
|
|
109
|
+
// Ensure guards directory exists
|
|
110
|
+
if (!fs.existsSync(guardsDir)) {
|
|
111
|
+
fs.mkdirSync(guardsDir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
const scopedEntities = getScopedEntities(entities);
|
|
114
|
+
// Common template data
|
|
115
|
+
const templateData = {
|
|
116
|
+
scopedEntities,
|
|
117
|
+
generatedAt: new Date().toISOString(),
|
|
118
|
+
generatedBy: 'Apso CLI',
|
|
119
|
+
};
|
|
120
|
+
// Generate scope.guard.ts
|
|
121
|
+
const scopeGuardPath = path.join(guardsDir, 'scope.guard.ts');
|
|
122
|
+
const scopeGuardContent = await Eta.renderFileAsync('./guards/scope.guard', templateData);
|
|
123
|
+
await (0, file_system_1.createFile)(scopeGuardPath, scopeGuardContent);
|
|
124
|
+
console.log(`[apso] Generated ${scopeGuardPath}`);
|
|
125
|
+
// Generate guards.module.ts
|
|
126
|
+
const guardsModulePath = path.join(guardsDir, 'guards.module.ts');
|
|
127
|
+
const guardsModuleContent = await Eta.renderFileAsync('./guards/guards.module', templateData);
|
|
128
|
+
await (0, file_system_1.createFile)(guardsModulePath, guardsModuleContent);
|
|
129
|
+
console.log(`[apso] Generated ${guardsModulePath}`);
|
|
130
|
+
// Generate index.ts
|
|
131
|
+
const indexPath = path.join(guardsDir, 'index.ts');
|
|
132
|
+
const indexContent = await Eta.renderFileAsync('./guards/index', templateData);
|
|
133
|
+
await (0, file_system_1.createFile)(indexPath, indexContent);
|
|
134
|
+
console.log(`[apso] Generated ${indexPath}`);
|
|
135
|
+
console.log(`[apso] Generated guards for ${scopedEntities.length} scoped entities`);
|
|
136
|
+
};
|
|
137
|
+
exports.createGuards = createGuards;
|
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createGqlDTO = exports.createEnums = exports.createDto = exports.createIndexAppModule = exports.createModule = exports.createService = exports.createController = exports.createEntity = exports.parseApsorc = void 0;
|
|
3
|
+
exports.getScopedEntities = exports.hasScopedEntities = exports.createGuards = exports.createGqlDTO = exports.createEnums = exports.createDto = exports.createIndexAppModule = exports.createModule = exports.createService = exports.createController = exports.createEntity = exports.parseApsorc = void 0;
|
|
4
4
|
var apsorc_parser_1 = require("./apsorc-parser");
|
|
5
5
|
Object.defineProperty(exports, "parseApsorc", { enumerable: true, get: function () { return apsorc_parser_1.parseApsorc; } });
|
|
6
6
|
var entity_1 = require("./entity");
|
|
@@ -19,3 +19,7 @@ var enums_1 = require("./enums");
|
|
|
19
19
|
Object.defineProperty(exports, "createEnums", { enumerable: true, get: function () { return enums_1.createEnums; } });
|
|
20
20
|
var gql_dto_1 = require("./gql-dto");
|
|
21
21
|
Object.defineProperty(exports, "createGqlDTO", { enumerable: true, get: function () { return gql_dto_1.createGqlDTO; } });
|
|
22
|
+
var guards_1 = require("./guards");
|
|
23
|
+
Object.defineProperty(exports, "createGuards", { enumerable: true, get: function () { return guards_1.createGuards; } });
|
|
24
|
+
Object.defineProperty(exports, "hasScopedEntities", { enumerable: true, get: function () { return guards_1.hasScopedEntities; } });
|
|
25
|
+
Object.defineProperty(exports, "getScopedEntities", { enumerable: true, get: function () { return guards_1.getScopedEntities; } });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import { Module, Global } from '@nestjs/common';
|
|
4
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
5
|
+
import { APP_GUARD } from '@nestjs/core';
|
|
6
|
+
|
|
7
|
+
import { ScopeGuard } from './scope.guard';
|
|
8
|
+
|
|
9
|
+
<% /* Import all entities that have scopeBy defined */ %>
|
|
10
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
11
|
+
import { <%= entity.name %> } from '../autogen/<%= entity.name %>/<%= entity.name %>.entity';
|
|
12
|
+
<% }) %>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GuardsModule provides scope-based data isolation guards.
|
|
16
|
+
*
|
|
17
|
+
* Auto-generated from .apsorc scopeBy definitions.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* 1. Import GuardsModule in your AppModule
|
|
21
|
+
* 2. Guards are NOT enabled globally by default (for backward compatibility)
|
|
22
|
+
* 3. To enable guards globally, uncomment the APP_GUARD provider below
|
|
23
|
+
* 4. Or apply guards selectively using @UseGuards(ScopeGuard) on controllers/routes
|
|
24
|
+
*
|
|
25
|
+
* When guards are enabled:
|
|
26
|
+
* - All scoped routes enforce data isolation based on scopeBy config
|
|
27
|
+
* - POST requests auto-inject scope fields from request context
|
|
28
|
+
* - GET requests auto-filter by scope fields
|
|
29
|
+
* - GET/PUT/PATCH/DELETE by ID verify resource ownership
|
|
30
|
+
*
|
|
31
|
+
* Decorators:
|
|
32
|
+
* - @Public() - Skip all guards for a route
|
|
33
|
+
* - @SkipScopeCheck() - Skip only scope checking
|
|
34
|
+
*/
|
|
35
|
+
@Global()
|
|
36
|
+
@Module({
|
|
37
|
+
imports: [
|
|
38
|
+
TypeOrmModule.forFeature([
|
|
39
|
+
<% it.scopedEntities.forEach((entity, index) => { %>
|
|
40
|
+
<%= entity.name %><%= index < it.scopedEntities.length - 1 ? ',' : '' %>
|
|
41
|
+
<% }) %>
|
|
42
|
+
]),
|
|
43
|
+
],
|
|
44
|
+
providers: [
|
|
45
|
+
ScopeGuard,
|
|
46
|
+
// UNCOMMENT BELOW TO ENABLE GLOBAL SCOPE ENFORCEMENT
|
|
47
|
+
// {
|
|
48
|
+
// provide: APP_GUARD,
|
|
49
|
+
// useClass: ScopeGuard,
|
|
50
|
+
// },
|
|
51
|
+
],
|
|
52
|
+
exports: [ScopeGuard],
|
|
53
|
+
})
|
|
54
|
+
export class GuardsModule {}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Injectable,
|
|
5
|
+
CanActivate,
|
|
6
|
+
ExecutionContext,
|
|
7
|
+
ForbiddenException,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { Reflector } from '@nestjs/core';
|
|
10
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
11
|
+
import { Repository } from 'typeorm';
|
|
12
|
+
|
|
13
|
+
<% /* Import all entities that have scoped relationships */ %>
|
|
14
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
15
|
+
import { <%= entity.name %> } from '../autogen/<%= entity.name %>/<%= entity.name %>.entity';
|
|
16
|
+
<% }) %>
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decorator to skip scope check for certain routes
|
|
20
|
+
*/
|
|
21
|
+
export const SKIP_SCOPE_CHECK = 'skipScopeCheck';
|
|
22
|
+
export const SkipScopeCheck = () =>
|
|
23
|
+
(target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
24
|
+
Reflect.defineMetadata(
|
|
25
|
+
SKIP_SCOPE_CHECK,
|
|
26
|
+
true,
|
|
27
|
+
descriptor?.value || target,
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decorator to mark routes as public (no auth required)
|
|
33
|
+
*/
|
|
34
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
35
|
+
export const Public = () =>
|
|
36
|
+
(target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
37
|
+
Reflect.defineMetadata(
|
|
38
|
+
IS_PUBLIC_KEY,
|
|
39
|
+
true,
|
|
40
|
+
descriptor?.value || target,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scope configuration for each entity
|
|
46
|
+
* Generated from .apsorc scopeBy definitions
|
|
47
|
+
*/
|
|
48
|
+
export interface ScopeConfig {
|
|
49
|
+
/** The scope fields or paths (e.g., 'workspaceId' or 'task.workspaceId') */
|
|
50
|
+
scopes: ScopeField[];
|
|
51
|
+
/** Whether to inject scope on create */
|
|
52
|
+
injectOnCreate: boolean;
|
|
53
|
+
/** Which operations to enforce scope on */
|
|
54
|
+
enforceOn: string[];
|
|
55
|
+
/** Roles that can bypass scope enforcement */
|
|
56
|
+
bypassRoles: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ScopeField {
|
|
60
|
+
/** The field name on the entity (e.g., 'workspaceId') or first segment of path */
|
|
61
|
+
field: string;
|
|
62
|
+
/** The context key to get the value from (defaults to field name) */
|
|
63
|
+
contextKey: string;
|
|
64
|
+
/** Whether this is a direct field (true) or nested path (false) */
|
|
65
|
+
direct: boolean;
|
|
66
|
+
/** For nested paths, the full path (e.g., 'task.workspaceId') */
|
|
67
|
+
path?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Entity scope configuration map
|
|
72
|
+
* Auto-generated from .apsorc
|
|
73
|
+
*/
|
|
74
|
+
export const ENTITY_SCOPES: Record<string, ScopeConfig> = {
|
|
75
|
+
<% it.scopedEntities.forEach((entity, index) => { %>
|
|
76
|
+
'<%= entity.routeName %>': {
|
|
77
|
+
scopes: [
|
|
78
|
+
<% entity.scopes.forEach((scope, scopeIndex) => { %>
|
|
79
|
+
{
|
|
80
|
+
field: '<%= scope.field %>',
|
|
81
|
+
contextKey: '<%= scope.contextKey %>',
|
|
82
|
+
direct: <%= scope.direct %>,
|
|
83
|
+
<% if (scope.path) { %>
|
|
84
|
+
path: '<%= scope.path %>',
|
|
85
|
+
<% } %>
|
|
86
|
+
}<%= scopeIndex < entity.scopes.length - 1 ? ',' : '' %>
|
|
87
|
+
<% }) %>
|
|
88
|
+
],
|
|
89
|
+
injectOnCreate: <%= entity.injectOnCreate %>,
|
|
90
|
+
enforceOn: [<%= entity.enforceOn.map(op => `'${op}'`).join(', ') %>],
|
|
91
|
+
bypassRoles: [<%= entity.bypassRoles.map(role => `'${role}'`).join(', ') %>],
|
|
92
|
+
}<%= index < it.scopedEntities.length - 1 ? ',' : '' %>
|
|
93
|
+
<% }) %>
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
@Injectable()
|
|
97
|
+
export class ScopeGuard implements CanActivate {
|
|
98
|
+
constructor(
|
|
99
|
+
private reflector: Reflector,
|
|
100
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
101
|
+
@InjectRepository(<%= entity.name %>)
|
|
102
|
+
private <%= entity.repoName %>: Repository<<%= entity.name %>>,
|
|
103
|
+
<% }) %>
|
|
104
|
+
) {}
|
|
105
|
+
|
|
106
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
107
|
+
// Check if route should skip scope check
|
|
108
|
+
const skipCheck = this.reflector.getAllAndOverride<boolean>(
|
|
109
|
+
SKIP_SCOPE_CHECK,
|
|
110
|
+
[context.getHandler(), context.getClass()],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (skipCheck) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if route is public
|
|
118
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
119
|
+
context.getHandler(),
|
|
120
|
+
context.getClass(),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
if (isPublic) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const request = context.switchToHttp().getRequest();
|
|
128
|
+
|
|
129
|
+
// Get the resource path from the request
|
|
130
|
+
const path = request.route?.path || request.path;
|
|
131
|
+
const method = request.method;
|
|
132
|
+
|
|
133
|
+
// Extract entity type from path (e.g., /Projects/:id -> projects)
|
|
134
|
+
const entityMatch = path.match(/^\/([^\/]+)/);
|
|
135
|
+
if (!entityMatch) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entityType = entityMatch[1].toLowerCase();
|
|
140
|
+
|
|
141
|
+
// Check if this is a scoped entity
|
|
142
|
+
const scopeConfig = ENTITY_SCOPES[entityType];
|
|
143
|
+
if (!scopeConfig) {
|
|
144
|
+
// Not a scoped entity, allow through
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check if user has bypass role
|
|
149
|
+
const userRoles: string[] = request.user?.roles || request.roles || [];
|
|
150
|
+
if (scopeConfig.bypassRoles.some(role => userRoles.includes(role))) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Map HTTP method to operation
|
|
155
|
+
const operation = this.mapMethodToOperation(method, request.params?.id);
|
|
156
|
+
|
|
157
|
+
// Check if this operation should be enforced
|
|
158
|
+
if (!scopeConfig.enforceOn.includes(operation)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// For GET requests with an ID, verify the resource belongs to this scope
|
|
163
|
+
if ((operation === 'get' || operation === 'update' || operation === 'delete') && request.params?.id) {
|
|
164
|
+
const hasAccess = await this.verifyResourceAccess(
|
|
165
|
+
entityType,
|
|
166
|
+
request.params.id,
|
|
167
|
+
request,
|
|
168
|
+
scopeConfig,
|
|
169
|
+
);
|
|
170
|
+
if (!hasAccess) {
|
|
171
|
+
throw new ForbiddenException('You do not have access to this resource');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// For POST requests, inject scope values if configured
|
|
176
|
+
if (operation === 'create' && scopeConfig.injectOnCreate) {
|
|
177
|
+
this.injectScopeValues(request, scopeConfig);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// For GET (list) requests, add scope filter
|
|
181
|
+
if (operation === 'find') {
|
|
182
|
+
this.addScopeFilter(request, scopeConfig);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private mapMethodToOperation(method: string, hasId: boolean): string {
|
|
189
|
+
switch (method) {
|
|
190
|
+
case 'GET':
|
|
191
|
+
return hasId ? 'get' : 'find';
|
|
192
|
+
case 'POST':
|
|
193
|
+
return 'create';
|
|
194
|
+
case 'PUT':
|
|
195
|
+
case 'PATCH':
|
|
196
|
+
return 'update';
|
|
197
|
+
case 'DELETE':
|
|
198
|
+
return 'delete';
|
|
199
|
+
default:
|
|
200
|
+
return 'find';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async verifyResourceAccess(
|
|
205
|
+
entityType: string,
|
|
206
|
+
resourceId: string,
|
|
207
|
+
request: any,
|
|
208
|
+
scopeConfig: ScopeConfig,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
try {
|
|
211
|
+
const repo = this.getRepository(entityType);
|
|
212
|
+
if (!repo) {
|
|
213
|
+
return true; // Unknown entity, allow through
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build relations array for nested scopes
|
|
217
|
+
const relations: string[] = [];
|
|
218
|
+
for (const scope of scopeConfig.scopes) {
|
|
219
|
+
if (!scope.direct && scope.path) {
|
|
220
|
+
// Extract relation name from path (e.g., 'task.workspaceId' -> 'task')
|
|
221
|
+
const relationName = scope.path.split('.')[0];
|
|
222
|
+
if (!relations.includes(relationName)) {
|
|
223
|
+
relations.push(relationName);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const entity = await repo.findOne({
|
|
229
|
+
where: { id: resourceId },
|
|
230
|
+
relations,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (!entity) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Verify all scope conditions
|
|
238
|
+
for (const scope of scopeConfig.scopes) {
|
|
239
|
+
const expectedValue = this.getScopeValue(request, scope.contextKey);
|
|
240
|
+
if (!expectedValue) {
|
|
241
|
+
continue; // No scope value in context, skip this check
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let actualValue: any;
|
|
245
|
+
if (scope.direct) {
|
|
246
|
+
actualValue = (entity as any)[scope.field];
|
|
247
|
+
} else if (scope.path) {
|
|
248
|
+
// Navigate the path (e.g., 'task.workspaceId')
|
|
249
|
+
actualValue = this.getNestedValue(entity, scope.path);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (actualValue !== expectedValue) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return true;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error('Scope verification error:', error);
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private injectScopeValues(request: any, scopeConfig: ScopeConfig): void {
|
|
265
|
+
if (!request.body || typeof request.body !== 'object') {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const scope of scopeConfig.scopes) {
|
|
270
|
+
if (scope.direct) {
|
|
271
|
+
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
272
|
+
if (scopeValue && !request.body[scope.field]) {
|
|
273
|
+
request.body[scope.field] = scopeValue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private addScopeFilter(request: any, scopeConfig: ScopeConfig): void {
|
|
280
|
+
if (!request.query) {
|
|
281
|
+
request.query = {};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const scope of scopeConfig.scopes) {
|
|
285
|
+
if (scope.direct) {
|
|
286
|
+
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
287
|
+
if (scopeValue) {
|
|
288
|
+
// Add filter for @nestjsx/crud
|
|
289
|
+
request.query[`filter.${scope.field}`] = `$eq:${scopeValue}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private getScopeValue(request: any, contextKey: string): string | undefined {
|
|
296
|
+
// Try multiple locations for the scope value
|
|
297
|
+
return (
|
|
298
|
+
request[contextKey] ||
|
|
299
|
+
request.user?.[contextKey] ||
|
|
300
|
+
request.scope?.[contextKey] ||
|
|
301
|
+
request.context?.[contextKey]
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private getNestedValue(obj: any, path: string): any {
|
|
306
|
+
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private getRepository(entityType: string): Repository<any> | null {
|
|
310
|
+
switch (entityType) {
|
|
311
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
312
|
+
case '<%= entity.routeName %>':
|
|
313
|
+
return this.<%= entity.repoName %>;
|
|
314
|
+
<% }) %>
|
|
315
|
+
default:
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -2,6 +2,28 @@ import { Unique } from "./constraints";
|
|
|
2
2
|
import { Field } from "./field";
|
|
3
3
|
import { Index } from "./indices";
|
|
4
4
|
import { Association } from "./relationship";
|
|
5
|
+
/**
|
|
6
|
+
* Operations that can have scope enforcement applied
|
|
7
|
+
*/
|
|
8
|
+
export type ScopeOperation = 'find' | 'get' | 'create' | 'update' | 'delete';
|
|
9
|
+
/**
|
|
10
|
+
* Configuration options for scope enforcement behavior
|
|
11
|
+
*/
|
|
12
|
+
export interface ScopeOptions {
|
|
13
|
+
/**
|
|
14
|
+
* If true (default), scope fields are automatically injected from
|
|
15
|
+
* the request context on create when not provided.
|
|
16
|
+
*/
|
|
17
|
+
injectOnCreate?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Which operations should enforce scope. Defaults to all operations.
|
|
20
|
+
*/
|
|
21
|
+
enforceOn?: ScopeOperation[];
|
|
22
|
+
/**
|
|
23
|
+
* Roles allowed to bypass scope enforcement (e.g., 'system_admin').
|
|
24
|
+
*/
|
|
25
|
+
bypassRoles?: string[];
|
|
26
|
+
}
|
|
5
27
|
export interface Entity {
|
|
6
28
|
name: string;
|
|
7
29
|
created_at?: boolean;
|
|
@@ -10,5 +32,18 @@ export interface Entity {
|
|
|
10
32
|
fields?: Field[];
|
|
11
33
|
indexes?: Index[];
|
|
12
34
|
uniques?: Unique[];
|
|
35
|
+
/**
|
|
36
|
+
* Fields or paths that define the authorization scope for this entity.
|
|
37
|
+
* Can be a single field name (e.g., 'workspaceId') or an array of fields/paths
|
|
38
|
+
* (e.g., ['workspaceId', 'serviceId'] or ['task.workspaceId']).
|
|
39
|
+
*
|
|
40
|
+
* - Direct fields: 'workspaceId' - enforces WHERE workspaceId = ctx.workspaceId
|
|
41
|
+
* - Nested paths: 'task.workspaceId' - joins to related entity and enforces scope
|
|
42
|
+
*/
|
|
43
|
+
scopeBy?: string | string[];
|
|
44
|
+
/**
|
|
45
|
+
* Optional configuration for how scope enforcement should behave.
|
|
46
|
+
*/
|
|
47
|
+
scopeOptions?: ScopeOptions;
|
|
13
48
|
associations?: Association[];
|
|
14
49
|
}
|
package/oclif.manifest.json
CHANGED