@apso/cli 0.4.2 → 0.6.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 +796 -6
- package/dist/commands/server/new.js +1 -1
- package/dist/commands/server/scaffold.js +20 -9
- package/dist/lib/apsorc-parser.d.ts +3 -0
- package/dist/lib/apsorc-parser.js +11 -6
- package/dist/lib/controller.js +3 -1
- package/dist/lib/dto.js +9 -9
- package/dist/lib/entity.js +6 -5
- package/dist/lib/guards.d.ts +47 -0
- package/dist/lib/guards.js +176 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +5 -1
- package/dist/lib/module.js +3 -1
- package/dist/lib/templates/guards/auth.guard.eta +228 -0
- package/dist/lib/templates/guards/guards.module.eta +114 -0
- package/dist/lib/templates/guards/index.eta +22 -0
- package/dist/lib/templates/guards/scope.guard.eta +327 -0
- package/dist/lib/types/auth.d.ts +203 -0
- package/dist/lib/types/auth.js +60 -0
- package/dist/lib/types/entity.d.ts +36 -1
- package/dist/lib/types/index.d.ts +2 -1
- package/dist/lib/types/index.js +8 -0
- package/dist/lib/types/relationship.d.ts +1 -1
- package/dist/lib/utils/casing.js +5 -5
- package/dist/lib/utils/field.js +2 -1
- package/dist/lib/utils/file-system.js +1 -1
- package/dist/lib/utils/relationships/parse-v1.js +2 -1
- package/dist/lib/utils/relationships/parse.js +117 -32
- package/dist/lib/utils/relationships/parse.spec.js +168 -141
- package/dist/test-nested-relationships.js +10 -10
- package/dist/tests/templates/entities/types/array-template.spec.js +45 -45
- package/dist/tests/templates/entities/types/bigint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/boolean-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/bytea-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/char-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/date-template.spec.js +29 -29
- package/dist/tests/templates/entities/types/decimal-field-validation.spec.js +46 -46
- package/dist/tests/templates/entities/types/decimal-template.spec.js +46 -46
- package/dist/tests/templates/entities/types/double-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/enum-template.spec.js +42 -42
- package/dist/tests/templates/entities/types/float-template.spec.js +37 -34
- package/dist/tests/templates/entities/types/geometry-template.spec.js +31 -31
- package/dist/tests/templates/entities/types/inet-template.spec.js +36 -36
- package/dist/tests/templates/entities/types/int4range-template.spec.js +45 -33
- package/dist/tests/templates/entities/types/integer-template.spec.js +40 -40
- package/dist/tests/templates/entities/types/interval-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/json-plain-template.spec.js +27 -27
- package/dist/tests/templates/entities/types/json-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/jsonb-template.spec.js +19 -19
- package/dist/tests/templates/entities/types/money-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/numeric-template.spec.js +44 -44
- package/dist/tests/templates/entities/types/point-template.spec.js +37 -31
- package/dist/tests/templates/entities/types/polygon-template.spec.js +43 -31
- package/dist/tests/templates/entities/types/real-template.spec.js +34 -34
- package/dist/tests/templates/entities/types/serial-template.spec.js +62 -62
- package/dist/tests/templates/entities/types/smallint-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/string-template.spec.js +59 -59
- package/dist/tests/templates/entities/types/text-template.spec.js +37 -37
- package/dist/tests/templates/entities/types/time-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/timestamp-template.spec.js +39 -39
- package/dist/tests/templates/entities/types/timetz-template.spec.js +35 -35
- package/dist/tests/templates/entities/types/tsvector-template.spec.js +28 -28
- package/dist/tests/templates/entities/types/uuid-template.spec.js +43 -43
- package/dist/tests/templates/entities/types/varchar-template.spec.js +59 -56
- package/dist/tests/templates/entities/types/xml-template.spec.js +28 -28
- package/dist/tests/templates/entities/utils/template-test-utils.d.ts +1 -1
- package/dist/tests/templates/entities/utils/template-test-utils.js +3 -3
- package/dist/tests/templates/entities/uuid-entity-template.spec.js +138 -138
- package/dist/tests/utils/relationship-deduplication.spec.js +11 -11
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthGuard - Authentication guard for <%= it.authConfig.provider %> provider
|
|
3
|
+
*
|
|
4
|
+
* Auto-generated by Apso CLI from .apsorc auth configuration
|
|
5
|
+
* Generated at: <%= it.generatedAt %>
|
|
6
|
+
*
|
|
7
|
+
* This guard:
|
|
8
|
+
* - Validates session tokens from cookies/headers
|
|
9
|
+
* - Loads user and organization context
|
|
10
|
+
* - Attaches normalized AuthContext to requests
|
|
11
|
+
*
|
|
12
|
+
* DO NOT EDIT - Changes will be overwritten on next scaffold
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
Injectable,
|
|
16
|
+
CanActivate,
|
|
17
|
+
ExecutionContext,
|
|
18
|
+
UnauthorizedException,
|
|
19
|
+
} from '@nestjs/common';
|
|
20
|
+
import { Reflector } from '@nestjs/core';
|
|
21
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
22
|
+
import { Repository } from 'typeorm';
|
|
23
|
+
|
|
24
|
+
// Entity imports
|
|
25
|
+
import { <%= it.authConfig.sessionEntity %> } from '../autogen/<%= it.authConfig.sessionEntity %>/<%= it.authConfig.sessionEntity %>.entity';
|
|
26
|
+
import { <%= it.authConfig.userEntity %> } from '../autogen/<%= it.authConfig.userEntity %>/<%= it.authConfig.userEntity %>.entity';
|
|
27
|
+
<% if (it.authConfig.accountUserEntity) { %>
|
|
28
|
+
import { <%= it.authConfig.accountUserEntity %> } from '../autogen/<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
|
|
29
|
+
<% } %>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Metadata key for marking routes as public (no auth required)
|
|
33
|
+
*/
|
|
34
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decorator to mark a route as public (skips authentication)
|
|
38
|
+
* Usage: @Public()
|
|
39
|
+
*/
|
|
40
|
+
export const Public =
|
|
41
|
+
() => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
42
|
+
Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalized auth context attached to authenticated requests.
|
|
47
|
+
* This is what scopeBy and RBAC consume.
|
|
48
|
+
*/
|
|
49
|
+
export interface AuthContext {
|
|
50
|
+
userId?: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
workspaceId?: string;
|
|
53
|
+
organizationId?: string;
|
|
54
|
+
roles: string[];
|
|
55
|
+
user?: <%= it.authConfig.userEntity %>;
|
|
56
|
+
session?: <%= it.authConfig.sessionEntity %>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extended request type with auth context
|
|
61
|
+
*/
|
|
62
|
+
export interface AuthenticatedRequest extends Request {
|
|
63
|
+
auth: AuthContext;
|
|
64
|
+
user: <%= it.authConfig.userEntity %>;
|
|
65
|
+
session: <%= it.authConfig.sessionEntity %>;
|
|
66
|
+
// Legacy compatibility aliases
|
|
67
|
+
accountId?: string;
|
|
68
|
+
accountRole?: string;
|
|
69
|
+
organizationId?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Injectable()
|
|
73
|
+
export class AuthGuard implements CanActivate {
|
|
74
|
+
constructor(
|
|
75
|
+
private reflector: Reflector,
|
|
76
|
+
@InjectRepository(<%= it.authConfig.sessionEntity %>)
|
|
77
|
+
private sessionRepository: Repository<<%= it.authConfig.sessionEntity %>>,
|
|
78
|
+
@InjectRepository(<%= it.authConfig.userEntity %>)
|
|
79
|
+
private userRepository: Repository<<%= it.authConfig.userEntity %>>,
|
|
80
|
+
<% if (it.authConfig.accountUserEntity) { %>
|
|
81
|
+
@InjectRepository(<%= it.authConfig.accountUserEntity %>)
|
|
82
|
+
private accountUserRepository: Repository<<%= it.authConfig.accountUserEntity %>>,
|
|
83
|
+
<% } %>
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
87
|
+
// Check if route is marked as public
|
|
88
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
89
|
+
context.getHandler(),
|
|
90
|
+
context.getClass(),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
if (isPublic) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const request = context.switchToHttp().getRequest();
|
|
98
|
+
const token = this.extractToken(request);
|
|
99
|
+
|
|
100
|
+
if (!token) {
|
|
101
|
+
throw new UnauthorizedException('No session token provided');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Find session by token
|
|
106
|
+
const sessionRecord = await this.sessionRepository.findOne({
|
|
107
|
+
where: { token },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!sessionRecord) {
|
|
111
|
+
throw new UnauthorizedException('Invalid session token');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check if session is expired
|
|
115
|
+
if (new Date(sessionRecord.expiresAt) < new Date()) {
|
|
116
|
+
throw new UnauthorizedException('Session expired');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Get user from session
|
|
120
|
+
const user = await this.userRepository.findOne({
|
|
121
|
+
where: { id: sessionRecord.userId },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!user) {
|
|
125
|
+
throw new UnauthorizedException('User not found');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
<% if (it.authConfig.accountUserEntity) { %>
|
|
129
|
+
// Get user's organization through <%= it.authConfig.accountUserEntity %> junction
|
|
130
|
+
const accountUser = await this.accountUserRepository.findOne({
|
|
131
|
+
where: { userId: user.id },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!accountUser) {
|
|
135
|
+
throw new UnauthorizedException('User not associated with any organization');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const organizationId = accountUser.<%= it.authConfig.organizationField %>;
|
|
139
|
+
const role = accountUser.<%= it.authConfig.roleField %> || 'member';
|
|
140
|
+
<% } else { %>
|
|
141
|
+
// No account user entity configured - using user-level organization
|
|
142
|
+
const organizationId = (user as any).<%= it.authConfig.organizationField %> || undefined;
|
|
143
|
+
const role = (user as any).<%= it.authConfig.roleField %> || 'member';
|
|
144
|
+
<% } %>
|
|
145
|
+
|
|
146
|
+
// Build normalized AuthContext
|
|
147
|
+
const authContext: AuthContext = {
|
|
148
|
+
userId: user.id,
|
|
149
|
+
email: user.email,
|
|
150
|
+
workspaceId: organizationId,
|
|
151
|
+
organizationId: organizationId,
|
|
152
|
+
roles: [role],
|
|
153
|
+
user: user,
|
|
154
|
+
session: sessionRecord,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Attach to request
|
|
158
|
+
(request as any).auth = authContext;
|
|
159
|
+
(request as any).user = user;
|
|
160
|
+
(request as any).session = sessionRecord;
|
|
161
|
+
|
|
162
|
+
// Legacy compatibility - attach directly to request root
|
|
163
|
+
(request as any).accountId = organizationId;
|
|
164
|
+
(request as any).accountRole = role;
|
|
165
|
+
(request as any).organizationId = organizationId;
|
|
166
|
+
|
|
167
|
+
return true;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof UnauthorizedException) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
console.error('Auth guard error:', error);
|
|
173
|
+
throw new UnauthorizedException('Authentication failed');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract session token from request
|
|
179
|
+
* Checks: Authorization header (Bearer), cookies, X-Session-Token header
|
|
180
|
+
*/
|
|
181
|
+
private extractToken(request: any): string | undefined {
|
|
182
|
+
// Check Authorization header first (Bearer token)
|
|
183
|
+
const authHeader = request.headers.authorization;
|
|
184
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
185
|
+
return authHeader.substring(7);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for session token in cookies
|
|
189
|
+
const cookies = request.cookies || {};
|
|
190
|
+
<% if (it.authConfig.cookiePrefix) { %>
|
|
191
|
+
const cookieName = '<%= it.authConfig.cookiePrefix %>.session_token';
|
|
192
|
+
const sessionToken =
|
|
193
|
+
cookies[cookieName] ||
|
|
194
|
+
cookies['better-auth.session_token'] ||
|
|
195
|
+
cookies['session_token'];
|
|
196
|
+
<% } else { %>
|
|
197
|
+
const sessionToken =
|
|
198
|
+
cookies['better-auth.session_token'] ||
|
|
199
|
+
cookies['session_token'];
|
|
200
|
+
<% } %>
|
|
201
|
+
|
|
202
|
+
if (sessionToken) {
|
|
203
|
+
return sessionToken;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check X-Session-Token header
|
|
207
|
+
return request.headers['x-session-token'];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Helper function to get AuthContext from request
|
|
213
|
+
* Use this in controllers/services to access the authenticated context
|
|
214
|
+
*/
|
|
215
|
+
export function getAuthContext(request: any): AuthContext | undefined {
|
|
216
|
+
return request?.auth;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Helper function to require AuthContext (throws if not present)
|
|
221
|
+
*/
|
|
222
|
+
export function requireAuthContext(request: any): AuthContext {
|
|
223
|
+
const ctx = getAuthContext(request);
|
|
224
|
+
if (!ctx) {
|
|
225
|
+
throw new UnauthorizedException('Authentication required');
|
|
226
|
+
}
|
|
227
|
+
return ctx;
|
|
228
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
8
|
+
import { ScopeGuard } from './scope.guard';
|
|
9
|
+
<% } %>
|
|
10
|
+
<% if (it.authConfig) { %>
|
|
11
|
+
import { AuthGuard } from './auth.guard';
|
|
12
|
+
<% } %>
|
|
13
|
+
|
|
14
|
+
<% /* Import all entities for ScopeGuard */ %>
|
|
15
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
16
|
+
// Entities for ScopeGuard
|
|
17
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
18
|
+
import { <%= entity.name %> } from '../autogen/<%= entity.name %>/<%= entity.name %>.entity';
|
|
19
|
+
<% }) %>
|
|
20
|
+
<% } %>
|
|
21
|
+
|
|
22
|
+
<% /* Import entities for AuthGuard */ %>
|
|
23
|
+
<% if (it.authConfig) { %>
|
|
24
|
+
// Entities for AuthGuard
|
|
25
|
+
import { <%= it.authConfig.sessionEntity %> } from '../autogen/<%= it.authConfig.sessionEntity %>/<%= it.authConfig.sessionEntity %>.entity';
|
|
26
|
+
import { <%= it.authConfig.userEntity %> } from '../autogen/<%= it.authConfig.userEntity %>/<%= it.authConfig.userEntity %>.entity';
|
|
27
|
+
<% if (it.authConfig.accountUserEntity) { %>
|
|
28
|
+
import { <%= it.authConfig.accountUserEntity %> } from '../autogen/<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
|
|
29
|
+
<% } %>
|
|
30
|
+
<% } %>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GuardsModule provides authentication and scope-based data isolation guards.
|
|
34
|
+
*
|
|
35
|
+
* Auto-generated from .apsorc configuration.
|
|
36
|
+
* Generated at: <%= it.generatedAt %>
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* 1. Import GuardsModule in your AppModule
|
|
40
|
+
* 2. Guards are NOT enabled globally by default (for backward compatibility)
|
|
41
|
+
* 3. To enable guards globally, uncomment the APP_GUARD providers below
|
|
42
|
+
* 4. Or apply guards selectively using @UseGuards(AuthGuard, ScopeGuard) on controllers
|
|
43
|
+
*
|
|
44
|
+
<% if (it.authConfig) { %>
|
|
45
|
+
* AuthGuard:
|
|
46
|
+
* - Validates session tokens from cookies/headers
|
|
47
|
+
* - Loads user and organization context
|
|
48
|
+
* - Attaches normalized AuthContext to request.auth
|
|
49
|
+
* - Provider: <%= it.authConfig.provider %>
|
|
50
|
+
*
|
|
51
|
+
<% } %>
|
|
52
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
53
|
+
* ScopeGuard:
|
|
54
|
+
* - Enforces data isolation based on scopeBy config
|
|
55
|
+
* - POST requests auto-inject scope fields from request.auth
|
|
56
|
+
* - GET requests auto-filter by scope fields
|
|
57
|
+
* - GET/PUT/PATCH/DELETE by ID verify resource ownership
|
|
58
|
+
*
|
|
59
|
+
<% } %>
|
|
60
|
+
* Decorators:
|
|
61
|
+
* - @Public() - Skip all guards for a route
|
|
62
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
63
|
+
* - @SkipScopeCheck() - Skip only scope checking
|
|
64
|
+
<% } %>
|
|
65
|
+
*/
|
|
66
|
+
@Global()
|
|
67
|
+
@Module({
|
|
68
|
+
imports: [
|
|
69
|
+
TypeOrmModule.forFeature([
|
|
70
|
+
<% /* Collect all unique entities */ %>
|
|
71
|
+
<% const allEntities = new Set(); %>
|
|
72
|
+
<% if (it.scopedEntities) { it.scopedEntities.forEach(e => allEntities.add(e.name)); } %>
|
|
73
|
+
<% if (it.authConfig) { %>
|
|
74
|
+
<% allEntities.add(it.authConfig.sessionEntity); %>
|
|
75
|
+
<% allEntities.add(it.authConfig.userEntity); %>
|
|
76
|
+
<% if (it.authConfig.accountUserEntity) { allEntities.add(it.authConfig.accountUserEntity); } %>
|
|
77
|
+
<% } %>
|
|
78
|
+
<% const entityArray = Array.from(allEntities); %>
|
|
79
|
+
<% entityArray.forEach((entity, index) => { %>
|
|
80
|
+
<%= entity %><%= index < entityArray.length - 1 ? ',' : '' %>
|
|
81
|
+
<% }) %>
|
|
82
|
+
]),
|
|
83
|
+
],
|
|
84
|
+
providers: [
|
|
85
|
+
<% if (it.authConfig) { %>
|
|
86
|
+
AuthGuard,
|
|
87
|
+
<% } %>
|
|
88
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
89
|
+
ScopeGuard,
|
|
90
|
+
<% } %>
|
|
91
|
+
// UNCOMMENT BELOW TO ENABLE GLOBAL GUARDS
|
|
92
|
+
<% if (it.authConfig) { %>
|
|
93
|
+
// {
|
|
94
|
+
// provide: APP_GUARD,
|
|
95
|
+
// useClass: AuthGuard,
|
|
96
|
+
// },
|
|
97
|
+
<% } %>
|
|
98
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
99
|
+
// {
|
|
100
|
+
// provide: APP_GUARD,
|
|
101
|
+
// useClass: ScopeGuard,
|
|
102
|
+
// },
|
|
103
|
+
<% } %>
|
|
104
|
+
],
|
|
105
|
+
exports: [
|
|
106
|
+
<% if (it.authConfig) { %>
|
|
107
|
+
AuthGuard,
|
|
108
|
+
<% } %>
|
|
109
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
110
|
+
ScopeGuard,
|
|
111
|
+
<% } %>
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
export class GuardsModule {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%~ includeFile('../header.eta') %>
|
|
2
|
+
|
|
3
|
+
<% if (it.authConfig) { %>
|
|
4
|
+
export * from './auth.guard';
|
|
5
|
+
<% } %>
|
|
6
|
+
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
7
|
+
<% if (it.authConfig) { %>
|
|
8
|
+
// When auth is configured, only export scope-specific items from scope.guard
|
|
9
|
+
// to avoid duplicate exports (Public, IS_PUBLIC_KEY come from auth.guard)
|
|
10
|
+
export {
|
|
11
|
+
ScopeGuard,
|
|
12
|
+
SKIP_SCOPE_CHECK,
|
|
13
|
+
SkipScopeCheck,
|
|
14
|
+
ScopeConfig,
|
|
15
|
+
ScopeField,
|
|
16
|
+
ENTITY_SCOPES,
|
|
17
|
+
} from './scope.guard';
|
|
18
|
+
<% } else { %>
|
|
19
|
+
export * from './scope.guard';
|
|
20
|
+
<% } %>
|
|
21
|
+
<% } %>
|
|
22
|
+
export * from './guards.module';
|
|
@@ -0,0 +1,327 @@
|
|
|
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
|
+
<% if (it.authConfig) { %>
|
|
32
|
+
/**
|
|
33
|
+
* Import Public decorator from auth.guard to avoid duplicate exports
|
|
34
|
+
*/
|
|
35
|
+
import { IS_PUBLIC_KEY, Public } from './auth.guard';
|
|
36
|
+
export { IS_PUBLIC_KEY, Public };
|
|
37
|
+
<% } else { %>
|
|
38
|
+
/**
|
|
39
|
+
* Decorator to mark routes as public (no auth required)
|
|
40
|
+
*/
|
|
41
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
42
|
+
export const Public = () =>
|
|
43
|
+
(target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
44
|
+
Reflect.defineMetadata(
|
|
45
|
+
IS_PUBLIC_KEY,
|
|
46
|
+
true,
|
|
47
|
+
descriptor?.value || target,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
<% } %>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scope configuration for each entity
|
|
54
|
+
* Generated from .apsorc scopeBy definitions
|
|
55
|
+
*/
|
|
56
|
+
export interface ScopeConfig {
|
|
57
|
+
/** The scope fields or paths (e.g., 'workspaceId' or 'task.workspaceId') */
|
|
58
|
+
scopes: ScopeField[];
|
|
59
|
+
/** Whether to inject scope on create */
|
|
60
|
+
injectOnCreate: boolean;
|
|
61
|
+
/** Which operations to enforce scope on */
|
|
62
|
+
enforceOn: string[];
|
|
63
|
+
/** Roles that can bypass scope enforcement */
|
|
64
|
+
bypassRoles: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ScopeField {
|
|
68
|
+
/** The field name on the entity (e.g., 'workspaceId') or first segment of path */
|
|
69
|
+
field: string;
|
|
70
|
+
/** The context key to get the value from (defaults to field name) */
|
|
71
|
+
contextKey: string;
|
|
72
|
+
/** Whether this is a direct field (true) or nested path (false) */
|
|
73
|
+
direct: boolean;
|
|
74
|
+
/** For nested paths, the full path (e.g., 'task.workspaceId') */
|
|
75
|
+
path?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Entity scope configuration map
|
|
80
|
+
* Auto-generated from .apsorc
|
|
81
|
+
*/
|
|
82
|
+
export const ENTITY_SCOPES: Record<string, ScopeConfig> = {
|
|
83
|
+
<% it.scopedEntities.forEach((entity, index) => { %>
|
|
84
|
+
'<%= entity.routeName %>': {
|
|
85
|
+
scopes: [
|
|
86
|
+
<% entity.scopes.forEach((scope, scopeIndex) => { %>
|
|
87
|
+
{
|
|
88
|
+
field: '<%= scope.field %>',
|
|
89
|
+
contextKey: '<%= scope.contextKey %>',
|
|
90
|
+
direct: <%= scope.direct %>,
|
|
91
|
+
<% if (scope.path) { %>
|
|
92
|
+
path: '<%= scope.path %>',
|
|
93
|
+
<% } %>
|
|
94
|
+
}<%= scopeIndex < entity.scopes.length - 1 ? ',' : '' %>
|
|
95
|
+
<% }) %>
|
|
96
|
+
],
|
|
97
|
+
injectOnCreate: <%= entity.injectOnCreate %>,
|
|
98
|
+
enforceOn: [<%~ entity.enforceOn.map(op => `'${op}'`).join(', ') %>],
|
|
99
|
+
bypassRoles: [<%~ entity.bypassRoles.map(role => `'${role}'`).join(', ') %>],
|
|
100
|
+
}<%= index < it.scopedEntities.length - 1 ? ',' : '' %>
|
|
101
|
+
<% }) %>
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
@Injectable()
|
|
105
|
+
export class ScopeGuard implements CanActivate {
|
|
106
|
+
constructor(
|
|
107
|
+
private reflector: Reflector,
|
|
108
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
109
|
+
@InjectRepository(<%= entity.name %>)
|
|
110
|
+
private <%= entity.repoName %>: Repository<<%= entity.name %>>,
|
|
111
|
+
<% }) %>
|
|
112
|
+
) {}
|
|
113
|
+
|
|
114
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
115
|
+
// Check if route should skip scope check
|
|
116
|
+
const skipCheck = this.reflector.getAllAndOverride<boolean>(
|
|
117
|
+
SKIP_SCOPE_CHECK,
|
|
118
|
+
[context.getHandler(), context.getClass()],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (skipCheck) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check if route is public
|
|
126
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
127
|
+
context.getHandler(),
|
|
128
|
+
context.getClass(),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
if (isPublic) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const request = context.switchToHttp().getRequest();
|
|
136
|
+
|
|
137
|
+
// Get the resource path from the request
|
|
138
|
+
const path = request.route?.path || request.path;
|
|
139
|
+
const method = request.method;
|
|
140
|
+
|
|
141
|
+
// Extract entity type from path (e.g., /Projects/:id -> projects)
|
|
142
|
+
const entityMatch = path.match(/^\/([^\/]+)/);
|
|
143
|
+
if (!entityMatch) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entityType = entityMatch[1].toLowerCase();
|
|
148
|
+
|
|
149
|
+
// Check if this is a scoped entity
|
|
150
|
+
const scopeConfig = ENTITY_SCOPES[entityType];
|
|
151
|
+
if (!scopeConfig) {
|
|
152
|
+
// Not a scoped entity, allow through
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check if user has bypass role
|
|
157
|
+
const userRoles: string[] = request.user?.roles || request.roles || [];
|
|
158
|
+
if (scopeConfig.bypassRoles.some(role => userRoles.includes(role))) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Map HTTP method to operation
|
|
163
|
+
const operation = this.mapMethodToOperation(method, request.params?.id);
|
|
164
|
+
|
|
165
|
+
// Check if this operation should be enforced
|
|
166
|
+
if (!scopeConfig.enforceOn.includes(operation)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// For GET requests with an ID, verify the resource belongs to this scope
|
|
171
|
+
if ((operation === 'get' || operation === 'update' || operation === 'delete') && request.params?.id) {
|
|
172
|
+
const hasAccess = await this.verifyResourceAccess(
|
|
173
|
+
entityType,
|
|
174
|
+
request.params.id,
|
|
175
|
+
request,
|
|
176
|
+
scopeConfig,
|
|
177
|
+
);
|
|
178
|
+
if (!hasAccess) {
|
|
179
|
+
throw new ForbiddenException('You do not have access to this resource');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// For POST requests, inject scope values if configured
|
|
184
|
+
if (operation === 'create' && scopeConfig.injectOnCreate) {
|
|
185
|
+
this.injectScopeValues(request, scopeConfig);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// For GET (list) requests, add scope filter
|
|
189
|
+
if (operation === 'find') {
|
|
190
|
+
this.addScopeFilter(request, scopeConfig);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private mapMethodToOperation(method: string, hasId: boolean): string {
|
|
197
|
+
switch (method) {
|
|
198
|
+
case 'GET':
|
|
199
|
+
return hasId ? 'get' : 'find';
|
|
200
|
+
case 'POST':
|
|
201
|
+
return 'create';
|
|
202
|
+
case 'PUT':
|
|
203
|
+
case 'PATCH':
|
|
204
|
+
return 'update';
|
|
205
|
+
case 'DELETE':
|
|
206
|
+
return 'delete';
|
|
207
|
+
default:
|
|
208
|
+
return 'find';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async verifyResourceAccess(
|
|
213
|
+
entityType: string,
|
|
214
|
+
resourceId: string,
|
|
215
|
+
request: any,
|
|
216
|
+
scopeConfig: ScopeConfig,
|
|
217
|
+
): Promise<boolean> {
|
|
218
|
+
try {
|
|
219
|
+
const repo = this.getRepository(entityType);
|
|
220
|
+
if (!repo) {
|
|
221
|
+
return true; // Unknown entity, allow through
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Build relations array for nested scopes
|
|
225
|
+
const relations: string[] = [];
|
|
226
|
+
for (const scope of scopeConfig.scopes) {
|
|
227
|
+
if (!scope.direct && scope.path) {
|
|
228
|
+
// Extract relation name from path (e.g., 'task.workspaceId' -> 'task')
|
|
229
|
+
const relationName = scope.path.split('.')[0];
|
|
230
|
+
if (!relations.includes(relationName)) {
|
|
231
|
+
relations.push(relationName);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const entity = await repo.findOne({
|
|
237
|
+
where: { id: resourceId },
|
|
238
|
+
relations,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (!entity) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Verify all scope conditions
|
|
246
|
+
for (const scope of scopeConfig.scopes) {
|
|
247
|
+
const expectedValue = this.getScopeValue(request, scope.contextKey);
|
|
248
|
+
if (!expectedValue) {
|
|
249
|
+
continue; // No scope value in context, skip this check
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let actualValue: any;
|
|
253
|
+
if (scope.direct) {
|
|
254
|
+
actualValue = (entity as any)[scope.field];
|
|
255
|
+
} else if (scope.path) {
|
|
256
|
+
// Navigate the path (e.g., 'task.workspaceId')
|
|
257
|
+
actualValue = this.getNestedValue(entity, scope.path);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (actualValue !== expectedValue) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return true;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error('Scope verification error:', error);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private injectScopeValues(request: any, scopeConfig: ScopeConfig): void {
|
|
273
|
+
if (!request.body || typeof request.body !== 'object') {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const scope of scopeConfig.scopes) {
|
|
278
|
+
if (scope.direct) {
|
|
279
|
+
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
280
|
+
if (scopeValue && !request.body[scope.field]) {
|
|
281
|
+
request.body[scope.field] = scopeValue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private addScopeFilter(request: any, scopeConfig: ScopeConfig): void {
|
|
288
|
+
if (!request.query) {
|
|
289
|
+
request.query = {};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const scope of scopeConfig.scopes) {
|
|
293
|
+
if (scope.direct) {
|
|
294
|
+
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
295
|
+
if (scopeValue) {
|
|
296
|
+
// Add filter for @nestjsx/crud
|
|
297
|
+
request.query[`filter.${scope.field}`] = `$eq:${scopeValue}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private getScopeValue(request: any, contextKey: string): string | undefined {
|
|
304
|
+
// Try multiple locations for the scope value
|
|
305
|
+
return (
|
|
306
|
+
request[contextKey] ||
|
|
307
|
+
request.user?.[contextKey] ||
|
|
308
|
+
request.scope?.[contextKey] ||
|
|
309
|
+
request.context?.[contextKey]
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private getNestedValue(obj: any, path: string): any {
|
|
314
|
+
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private getRepository(entityType: string): Repository<any> | null {
|
|
318
|
+
switch (entityType) {
|
|
319
|
+
<% it.scopedEntities.forEach((entity) => { %>
|
|
320
|
+
case '<%= entity.routeName %>':
|
|
321
|
+
return this.<%= entity.repoName %>;
|
|
322
|
+
<% }) %>
|
|
323
|
+
default:
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|