@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 CHANGED
@@ -1,14 +1,46 @@
1
1
  # Apso CLI
2
2
 
3
- - [Apso CLI](#apso-cli)
4
- - [Prerequisites](#prerequisites)
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
- - [Debugging](##debugging)
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;
@@ -8,3 +8,4 @@ export { createIndexAppModule } from "./index-module";
8
8
  export { createDto } from "./dto";
9
9
  export { createEnums } from "./enums";
10
10
  export { createGqlDTO } from "./gql-dto";
11
+ export { createGuards, hasScopedEntities, getScopedEntities } from "./guards";
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,4 @@
1
+ <%~ includeFile('../header.eta') %>
2
+
3
+ export * from './scope.guard';
4
+ export * from './guards.module';
@@ -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
  }
@@ -1,4 +1,4 @@
1
- export { Entity } from "./entity";
1
+ export { Entity, ScopeOptions, ScopeOperation } from "./entity";
2
2
  export { FieldType, Field, ComputedField } from "./field";
3
3
  export { Index } from "./indices";
4
4
  export { JSONValue } from "./json";
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.2",
2
+ "version": "0.5.0",
3
3
  "commands": {
4
4
  "server:new": {
5
5
  "id": "server:new",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Apso CLI",
5
5
  "author": "Apso by Mavric - @mavric",
6
6
  "bin": {