@apso/cli 0.45.2 → 0.46.1
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/dist/commands/generate.js +2 -1
- package/dist/commands/mcp/serve.js +2 -1
- package/dist/lib/generators/base.d.ts +2 -2
- package/dist/lib/generators/go.d.ts +6 -0
- package/dist/lib/generators/go.js +39 -8
- package/dist/lib/generators/python.js +4 -2
- package/dist/lib/generators/typescript.d.ts +2 -2
- package/dist/lib/generators/typescript.js +13 -8
- package/dist/lib/guards.d.ts +34 -3
- package/dist/lib/guards.js +62 -4
- package/dist/lib/templates/go/handlers/handler.eta +30 -0
- package/dist/lib/templates/go/handlers/scope.eta +27 -0
- package/dist/lib/templates/go/middleware/auth.eta +18 -3
- package/dist/lib/templates/go/services/scope.eta +62 -0
- package/dist/lib/templates/go/services/service.eta +96 -4
- package/dist/lib/templates/guards/scope.guard.eta +72 -1
- package/dist/lib/templates/python/routers/router.eta +12 -7
- package/dist/lib/templates/python/services/service.eta +81 -8
- package/dist/lib/templates/python/utils/query.eta +5 -3
- package/dist/lib/templates/rest/controller-rest.eta +14 -2
- package/dist/lib/types/generator.d.ts +6 -1
- package/dist/lib/utils/relationships/parse.d.ts +1 -0
- package/dist/lib/utils/relationships/parse.js +4 -3
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -7,6 +7,9 @@ import (
|
|
|
7
7
|
"errors"
|
|
8
8
|
|
|
9
9
|
"app/autogen/dto"
|
|
10
|
+
<% if (it.scope) { %>
|
|
11
|
+
"app/autogen/middleware"
|
|
12
|
+
<% } %>
|
|
10
13
|
"app/autogen/models"
|
|
11
14
|
"app/autogen/utils"
|
|
12
15
|
"gorm.io/gorm"
|
|
@@ -37,6 +40,42 @@ type <%= it.entityName %>Service struct {
|
|
|
37
40
|
func New<%= it.entityName %>Service(db *gorm.DB) *<%= it.entityName %>Service {
|
|
38
41
|
return &<%= it.entityName %>Service{db: db}
|
|
39
42
|
}
|
|
43
|
+
<% if (it.scope) { %>
|
|
44
|
+
|
|
45
|
+
// scoped limits q to the caller's tenant for op ("find", "get", "create",
|
|
46
|
+
// "update", "delete") per .apsorc scopeBy, and returns the scope column values.
|
|
47
|
+
// Both are unchanged when op is not enforced or the caller holds a bypass role.
|
|
48
|
+
func (s *<%= it.entityName %>Service) scoped(ctx context.Context, q *gorm.DB, op string) (*gorm.DB, map[string]string, error) {
|
|
49
|
+
switch op {
|
|
50
|
+
case <%~ it.scope.enforceOn.map((o) => JSON.stringify(o)).join(', ') %>:
|
|
51
|
+
default:
|
|
52
|
+
return q, nil, nil
|
|
53
|
+
}
|
|
54
|
+
a, ok := middleware.FromContext(ctx)
|
|
55
|
+
if !ok {
|
|
56
|
+
return nil, nil, ErrForbidden
|
|
57
|
+
}
|
|
58
|
+
<% if (it.scope.bypassRoles.length > 0) { %>
|
|
59
|
+
if hasAnyRole(a.Roles, <%~ it.scope.bypassRoles.map((r) => JSON.stringify(r)).join(', ') %>) {
|
|
60
|
+
return q, nil, nil
|
|
61
|
+
}
|
|
62
|
+
<% } %>
|
|
63
|
+
values := map[string]string{
|
|
64
|
+
<% it.scope.fields.forEach((f) => { %>
|
|
65
|
+
<%~ JSON.stringify(f.column) %>: a.<%= f.source === 'userId' ? 'UserID' : f.source === 'email' ? 'Email' : 'WorkspaceID' %>,
|
|
66
|
+
<% }) %>
|
|
67
|
+
}
|
|
68
|
+
table := models.<%= it.entityName %>{}.TableName()
|
|
69
|
+
for col, v := range values {
|
|
70
|
+
if v == "" {
|
|
71
|
+
return nil, nil, ErrForbidden
|
|
72
|
+
}
|
|
73
|
+
// Compared as text: token claims are strings, the column may be a number or uuid.
|
|
74
|
+
q = q.Where("CAST("+table+"."+col+" AS TEXT) = ?", v)
|
|
75
|
+
}
|
|
76
|
+
return q, values, nil
|
|
77
|
+
}
|
|
78
|
+
<% } %>
|
|
40
79
|
|
|
41
80
|
// FindAll retrieves a paginated list of <%= it.pluralName %> with filtering, sorting, and joins.
|
|
42
81
|
// Used by the nestjsx dialect ({data,count,total,page,pageCount} envelope).
|
|
@@ -47,6 +86,12 @@ func (s *<%= it.entityName %>Service) FindAll(ctx context.Context, opts utils.Qu
|
|
|
47
86
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
48
87
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
49
88
|
<% }) %>
|
|
89
|
+
<% if (it.scope) { %>
|
|
90
|
+
query, _, scopeErr := s.scoped(ctx, query, "find")
|
|
91
|
+
if scopeErr != nil {
|
|
92
|
+
return nil, scopeErr
|
|
93
|
+
}
|
|
94
|
+
<% } %>
|
|
50
95
|
|
|
51
96
|
query, total, pageCount, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
|
|
52
97
|
if err != nil {
|
|
@@ -82,6 +127,12 @@ func (s *<%= it.entityName %>Service) FindAllBare(ctx context.Context, opts util
|
|
|
82
127
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
83
128
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
84
129
|
<% }) %>
|
|
130
|
+
<% if (it.scope) { %>
|
|
131
|
+
query, _, scopeErr := s.scoped(ctx, query, "find")
|
|
132
|
+
if scopeErr != nil {
|
|
133
|
+
return nil, scopeErr
|
|
134
|
+
}
|
|
135
|
+
<% } %>
|
|
85
136
|
|
|
86
137
|
query, _, _, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
|
|
87
138
|
if err != nil {
|
|
@@ -107,6 +158,12 @@ func (s *<%= it.entityName %>Service) FindOne(ctx context.Context, id string) (*
|
|
|
107
158
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
108
159
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
109
160
|
<% }) %>
|
|
161
|
+
<% if (it.scope) { %>
|
|
162
|
+
query, _, scopeErr := s.scoped(ctx, query, "get")
|
|
163
|
+
if scopeErr != nil {
|
|
164
|
+
return nil, scopeErr
|
|
165
|
+
}
|
|
166
|
+
<% } %>
|
|
110
167
|
|
|
111
168
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
112
169
|
if err := query.Where("id = ?", id).First(&item).Error; err != nil {
|
|
@@ -137,6 +194,21 @@ func (s *<%= it.entityName %>Service) Create(ctx context.Context, input *dto.<%=
|
|
|
137
194
|
<%= assoc.referenceName || assoc.name %>ID: input.<%= assoc.referenceName || assoc.name %>ID,
|
|
138
195
|
<% }) %>
|
|
139
196
|
}
|
|
197
|
+
<% if (it.scope && it.scope.injectOnCreate) { %>
|
|
198
|
+
|
|
199
|
+
// The caller's tenant, never one from the body (.apsorc scopeBy).
|
|
200
|
+
_, scopeVals, scopeErr := s.scoped(ctx, s.db, "create")
|
|
201
|
+
if scopeErr != nil {
|
|
202
|
+
return nil, scopeErr
|
|
203
|
+
}
|
|
204
|
+
<% it.scope.fields.forEach((f) => { %>
|
|
205
|
+
if v, ok := scopeVals[<%~ JSON.stringify(f.column) %>]; ok {
|
|
206
|
+
if err := setFromString(&item, <%~ JSON.stringify(f.goField) %>, v); err != nil {
|
|
207
|
+
return nil, err
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
<% }) %>
|
|
211
|
+
<% } %>
|
|
140
212
|
|
|
141
213
|
if err := s.db.WithContext(ctx).Create(&item).Error; err != nil {
|
|
142
214
|
return nil, err
|
|
@@ -150,10 +222,17 @@ func (s *<%= it.entityName %>Service) Create(ctx context.Context, input *dto.<%=
|
|
|
150
222
|
func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, input *dto.<%= it.entityName %>Update) (*dto.<%= it.entityName %>Response, error) {
|
|
151
223
|
var item models.<%= it.entityName %>
|
|
152
224
|
|
|
225
|
+
findQuery := s.db.WithContext(ctx)
|
|
226
|
+
<% if (it.scope) { %>
|
|
227
|
+
findQuery, scopeVals, scopeErr := s.scoped(ctx, findQuery, "update")
|
|
228
|
+
if scopeErr != nil {
|
|
229
|
+
return nil, scopeErr
|
|
230
|
+
}
|
|
231
|
+
<% } %>
|
|
153
232
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
154
|
-
if err :=
|
|
233
|
+
if err := findQuery.Where("id = ?", id).First(&item).Error; err != nil {
|
|
155
234
|
<% } else { %>
|
|
156
|
-
if err :=
|
|
235
|
+
if err := findQuery.First(&item, id).Error; err != nil {
|
|
157
236
|
<% } %>
|
|
158
237
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
159
238
|
return nil, errors.New("<%= it.entityName %> not found")
|
|
@@ -172,6 +251,12 @@ func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, inp
|
|
|
172
251
|
updates["<%= assoc.snakeReferenceName || assoc.snakeName %>_id"] = *input.<%= assoc.referenceName || assoc.name %>ID
|
|
173
252
|
}
|
|
174
253
|
<% }) %>
|
|
254
|
+
<% if (it.scope) { %>
|
|
255
|
+
// A scoped row cannot move to another tenant.
|
|
256
|
+
for col := range scopeVals {
|
|
257
|
+
delete(updates, col)
|
|
258
|
+
}
|
|
259
|
+
<% } %>
|
|
175
260
|
|
|
176
261
|
if len(updates) > 0 {
|
|
177
262
|
if err := s.db.WithContext(ctx).Model(&item).Updates(updates).Error; err != nil {
|
|
@@ -198,10 +283,17 @@ func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, inp
|
|
|
198
283
|
|
|
199
284
|
// Delete removes a <%= it.entityName %> by ID
|
|
200
285
|
func (s *<%= it.entityName %>Service) Delete(ctx context.Context, id string) error {
|
|
286
|
+
deleteQuery := s.db.WithContext(ctx)
|
|
287
|
+
<% if (it.scope) { %>
|
|
288
|
+
deleteQuery, _, scopeErr := s.scoped(ctx, deleteQuery, "delete")
|
|
289
|
+
if scopeErr != nil {
|
|
290
|
+
return scopeErr
|
|
291
|
+
}
|
|
292
|
+
<% } %>
|
|
201
293
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
202
|
-
result :=
|
|
294
|
+
result := deleteQuery.Where("id = ?", id).Delete(&models.<%= it.entityName %>{})
|
|
203
295
|
<% } else { %>
|
|
204
|
-
result :=
|
|
296
|
+
result := deleteQuery.Delete(&models.<%= it.entityName %>{}, id)
|
|
205
297
|
<% } %>
|
|
206
298
|
if result.Error != nil {
|
|
207
299
|
return result.Error
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '@nestjs/common';
|
|
9
9
|
import { Reflector } from '@nestjs/core';
|
|
10
10
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
11
|
-
import { Repository } from 'typeorm';
|
|
11
|
+
import { <% if (it.hasNestedFk) { %>DataSource, <% } %>Repository } from 'typeorm';
|
|
12
12
|
|
|
13
13
|
<% /* Import all entities that have scoped relationships (from sibling autogen folders) */ %>
|
|
14
14
|
<% it.scopedEntities.forEach((entity) => { %>
|
|
@@ -73,6 +73,12 @@ export interface ScopeField {
|
|
|
73
73
|
direct: boolean;
|
|
74
74
|
/** For nested paths, the full path (e.g., 'task.workspaceId') */
|
|
75
75
|
path?: string;
|
|
76
|
+
/** Nested paths: the foreign key to the first hop (e.g., 'taskId') */
|
|
77
|
+
fkField?: string;
|
|
78
|
+
/** Nested paths: the entity the foreign key points to (e.g., 'Task') */
|
|
79
|
+
parentEntity?: string;
|
|
80
|
+
/** Nested paths: the rest of the path on that entity (e.g., 'workspaceId') */
|
|
81
|
+
parentPath?: string;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
|
@@ -90,6 +96,11 @@ export const ENTITY_SCOPES: Record<string, ScopeConfig> = {
|
|
|
90
96
|
direct: <%= scope.direct %>,
|
|
91
97
|
<% if (scope.path) { %>
|
|
92
98
|
path: '<%= scope.path %>',
|
|
99
|
+
<% } %>
|
|
100
|
+
<% if (scope.fkField) { %>
|
|
101
|
+
fkField: '<%= scope.fkField %>',
|
|
102
|
+
parentEntity: '<%= scope.parentEntity %>',
|
|
103
|
+
parentPath: '<%= scope.parentPath %>',
|
|
93
104
|
<% } %>
|
|
94
105
|
}<%= scopeIndex < entity.scopes.length - 1 ? ',' : '' %>
|
|
95
106
|
<% }) %>
|
|
@@ -109,6 +120,9 @@ export class ScopeGuard implements CanActivate {
|
|
|
109
120
|
@InjectRepository(<%= entity.name %>)
|
|
110
121
|
private <%= entity.repoName %>: Repository<<%= entity.name %>>,
|
|
111
122
|
<% }) %>
|
|
123
|
+
<% if (it.hasNestedFk) { %>
|
|
124
|
+
private dataSource: DataSource,
|
|
125
|
+
<% } %>
|
|
112
126
|
) {}
|
|
113
127
|
|
|
114
128
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
@@ -166,6 +180,12 @@ export class ScopeGuard implements CanActivate {
|
|
|
166
180
|
if (!scopeConfig.enforceOn.includes(operation)) {
|
|
167
181
|
return true;
|
|
168
182
|
}
|
|
183
|
+
<% if (it.hasNestedFk) { %>
|
|
184
|
+
|
|
185
|
+
// Nested paths (task.workspaceId): load the caller's parent ids for the
|
|
186
|
+
// controller's list filter, and refuse a parent from another tenant.
|
|
187
|
+
await this.loadNestedScopes(request, scopeConfig, operation);
|
|
188
|
+
<% } %>
|
|
169
189
|
|
|
170
190
|
// For GET requests with an ID, verify the resource belongs to this scope
|
|
171
191
|
if ((operation === 'get' || operation === 'update' || operation === 'delete') && request.params?.id) {
|
|
@@ -184,6 +204,13 @@ export class ScopeGuard implements CanActivate {
|
|
|
184
204
|
if (operation === 'create' && scopeConfig.injectOnCreate) {
|
|
185
205
|
this.injectScopeValues(request, scopeConfig);
|
|
186
206
|
}
|
|
207
|
+
<% if (it.hasAuth) { %>
|
|
208
|
+
|
|
209
|
+
// A scoped row cannot move to another tenant on update.
|
|
210
|
+
if (operation === 'update') {
|
|
211
|
+
this.injectScopeValues(request, scopeConfig);
|
|
212
|
+
}
|
|
213
|
+
<% } %>
|
|
187
214
|
|
|
188
215
|
// For GET (list) requests, add scope filter
|
|
189
216
|
if (operation === 'find') {
|
|
@@ -331,6 +358,50 @@ export class ScopeGuard implements CanActivate {
|
|
|
331
358
|
);
|
|
332
359
|
}
|
|
333
360
|
|
|
361
|
+
<% if (it.hasNestedFk) { %>
|
|
362
|
+
/**
|
|
363
|
+
* For each nested scope, find the ids of the parent rows in the caller's
|
|
364
|
+
* tenant and put them on request.apsoScope, where the controller's
|
|
365
|
+
* @CrudAuth filter reads them. A create or update that names a parent
|
|
366
|
+
* outside that set is refused.
|
|
367
|
+
*/
|
|
368
|
+
private async loadNestedScopes(
|
|
369
|
+
request: any,
|
|
370
|
+
scopeConfig: ScopeConfig,
|
|
371
|
+
operation: string,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
for (const scope of scopeConfig.scopes) {
|
|
374
|
+
if (!scope.fkField || !scope.parentEntity || !scope.parentPath) continue;
|
|
375
|
+
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
376
|
+
if (!scopeValue) {
|
|
377
|
+
throw new ForbiddenException('No tenant in the auth context');
|
|
378
|
+
}
|
|
379
|
+
// ponytail: loads every parent id in the tenant per request; switch to a
|
|
380
|
+
// subquery filter if a tenant grows past tens of thousands of parents.
|
|
381
|
+
const repo = this.dataSource.getRepository(scope.parentEntity);
|
|
382
|
+
const pk = repo.metadata.primaryColumns[0].propertyName;
|
|
383
|
+
const where = scope.parentPath
|
|
384
|
+
.split('.')
|
|
385
|
+
.reverse()
|
|
386
|
+
.reduce<any>((inner, key) => ({ [key]: inner }), scopeValue);
|
|
387
|
+
const parents = await repo.find({ select: { [pk]: true } as any, where });
|
|
388
|
+
const ids = parents.map((p: any) => p[pk]);
|
|
389
|
+
request.apsoScope = { ...request.apsoScope, [scope.fkField]: ids };
|
|
390
|
+
|
|
391
|
+
if (operation === 'create' || operation === 'update') {
|
|
392
|
+
const body = request.body && typeof request.body === 'object' ? request.body : {};
|
|
393
|
+
const items = Array.isArray(body.bulk) ? body.bulk : [body];
|
|
394
|
+
for (const item of items) {
|
|
395
|
+
const fk = item?.[scope.fkField] ?? item?.[scope.field]?.[pk];
|
|
396
|
+
if (fk != null && !ids.some((id: any) => String(id) === String(fk))) {
|
|
397
|
+
throw new ForbiddenException('You do not have access to this resource');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
<% } %>
|
|
334
405
|
private getNestedValue(obj: any, path: string): any {
|
|
335
406
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
|
336
407
|
}
|
|
@@ -55,7 +55,7 @@ async def get_many(
|
|
|
55
55
|
- order=col.asc|desc[.nullsfirst|nullslast]
|
|
56
56
|
- limit=..&offset=.. (windows the bare array)
|
|
57
57
|
"""
|
|
58
|
-
service = <%= it.entityName %>Service(db)
|
|
58
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
59
59
|
try:
|
|
60
60
|
options = parse_query_params(request.query_params, x_crud_dialect)
|
|
61
61
|
except QueryParseError as exc:
|
|
@@ -113,7 +113,7 @@ async def get_one(
|
|
|
113
113
|
Supports query parameters:
|
|
114
114
|
- join=relation (repeatable)
|
|
115
115
|
"""
|
|
116
|
-
service = <%= it.entityName %>Service(db)
|
|
116
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
117
117
|
|
|
118
118
|
options = parse_query_params(request.query_params)
|
|
119
119
|
joins = [j.relation for j in options.joins] if options.joins else None
|
|
@@ -134,13 +134,14 @@ async def get_one(
|
|
|
134
134
|
summary="Create a single <%= it.entityName %>",
|
|
135
135
|
)
|
|
136
136
|
async def create_one(
|
|
137
|
+
request: Request,
|
|
137
138
|
data: <%= it.entityName %>Create,
|
|
138
139
|
db: AsyncSession = Depends(get_db),
|
|
139
140
|
) -> <%= it.entityName %>Schema:
|
|
140
141
|
"""
|
|
141
142
|
Create a new <%= it.entityName %>.
|
|
142
143
|
"""
|
|
143
|
-
service = <%= it.entityName %>Service(db)
|
|
144
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
144
145
|
return await service.create_one(data)
|
|
145
146
|
|
|
146
147
|
|
|
@@ -151,13 +152,14 @@ async def create_one(
|
|
|
151
152
|
summary="Create multiple <%= it.pluralEntityName %>",
|
|
152
153
|
)
|
|
153
154
|
async def create_many(
|
|
155
|
+
request: Request,
|
|
154
156
|
data: List[<%= it.entityName %>Create],
|
|
155
157
|
db: AsyncSession = Depends(get_db),
|
|
156
158
|
) -> List[<%= it.entityName %>Schema]:
|
|
157
159
|
"""
|
|
158
160
|
Create multiple <%= it.pluralEntityName %>.
|
|
159
161
|
"""
|
|
160
|
-
service = <%= it.entityName %>Service(db)
|
|
162
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
161
163
|
return await service.create_many(data)
|
|
162
164
|
|
|
163
165
|
|
|
@@ -167,6 +169,7 @@ async def create_many(
|
|
|
167
169
|
summary="Update a single <%= it.entityName %>",
|
|
168
170
|
)
|
|
169
171
|
async def update_one(
|
|
172
|
+
request: Request,
|
|
170
173
|
id: <% if (it.primaryKeyType === 'uuid') { %>str<% } else { %>int<% } %>,
|
|
171
174
|
data: <%= it.entityName %>Update,
|
|
172
175
|
db: AsyncSession = Depends(get_db),
|
|
@@ -174,7 +177,7 @@ async def update_one(
|
|
|
174
177
|
"""
|
|
175
178
|
Update an existing <%= it.entityName %>.
|
|
176
179
|
"""
|
|
177
|
-
service = <%= it.entityName %>Service(db)
|
|
180
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
178
181
|
item = await service.update_one(id, data)
|
|
179
182
|
if not item:
|
|
180
183
|
raise HTTPException(
|
|
@@ -190,6 +193,7 @@ async def update_one(
|
|
|
190
193
|
summary="Replace a single <%= it.entityName %>",
|
|
191
194
|
)
|
|
192
195
|
async def replace_one(
|
|
196
|
+
request: Request,
|
|
193
197
|
id: <% if (it.primaryKeyType === 'uuid') { %>str<% } else { %>int<% } %>,
|
|
194
198
|
data: <%= it.entityName %>Create,
|
|
195
199
|
db: AsyncSession = Depends(get_db),
|
|
@@ -197,7 +201,7 @@ async def replace_one(
|
|
|
197
201
|
"""
|
|
198
202
|
Replace an existing <%= it.entityName %>.
|
|
199
203
|
"""
|
|
200
|
-
service = <%= it.entityName %>Service(db)
|
|
204
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
201
205
|
item = await service.replace_one(id, data)
|
|
202
206
|
if not item:
|
|
203
207
|
raise HTTPException(
|
|
@@ -213,13 +217,14 @@ async def replace_one(
|
|
|
213
217
|
summary="Delete a single <%= it.entityName %>",
|
|
214
218
|
)
|
|
215
219
|
async def delete_one(
|
|
220
|
+
request: Request,
|
|
216
221
|
id: <% if (it.primaryKeyType === 'uuid') { %>str<% } else { %>int<% } %>,
|
|
217
222
|
db: AsyncSession = Depends(get_db),
|
|
218
223
|
) -> None:
|
|
219
224
|
"""
|
|
220
225
|
Delete a <%= it.entityName %> by ID.
|
|
221
226
|
"""
|
|
222
|
-
service = <%= it.entityName %>Service(db)
|
|
227
|
+
service = <%= it.entityName %>Service(db, getattr(request.state, "auth", None))
|
|
223
228
|
deleted = await service.delete_one(id)
|
|
224
229
|
if not deleted:
|
|
225
230
|
raise HTTPException(
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
from typing import List, Optional, Any
|
|
4
4
|
|
|
5
5
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
<% if (it.scope) { %>
|
|
7
|
+
from fastapi import HTTPException, status
|
|
8
|
+
from sqlalchemy import String, cast
|
|
9
|
+
<% } %>
|
|
6
10
|
|
|
7
11
|
from ..models.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>
|
|
8
12
|
from ..schemas.<%= it.entityName.toLowerCase() %> import <%= it.entityName %>Create, <%= it.entityName %>Update
|
|
@@ -14,38 +18,94 @@ class <%= it.entityName %>Service:
|
|
|
14
18
|
Service layer for <%= it.entityName %> CRUD operations.
|
|
15
19
|
"""
|
|
16
20
|
|
|
17
|
-
def __init__(self, db: AsyncSession):
|
|
21
|
+
def __init__(self, db: AsyncSession, auth: Any = None):
|
|
18
22
|
self.db = db
|
|
19
|
-
|
|
23
|
+
# The caller (app.autogen.auth.AuthContext), when auth is configured.
|
|
24
|
+
self.auth = auth
|
|
25
|
+
<% if (it.scope) { %>
|
|
26
|
+
|
|
27
|
+
def _scope_values(self, op: str) -> dict:
|
|
28
|
+
"""
|
|
29
|
+
The caller's value for each scopeBy field for op ("find", "get",
|
|
30
|
+
"create", "update", "delete"); empty when op is not enforced or the
|
|
31
|
+
caller holds a bypass role. 403 when the caller has no tenant.
|
|
32
|
+
"""
|
|
33
|
+
if op not in <%~ JSON.stringify(it.scope.enforceOn) %>:
|
|
34
|
+
return {}
|
|
35
|
+
a = self.auth
|
|
36
|
+
if a is None:
|
|
37
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tenant in the auth context")
|
|
38
|
+
<% if (it.scope.bypassRoles.length > 0) { %>
|
|
39
|
+
if set(a.roles) & set(<%~ JSON.stringify(it.scope.bypassRoles) %>):
|
|
40
|
+
return {}
|
|
41
|
+
<% } %>
|
|
42
|
+
values = {
|
|
43
|
+
<% it.scope.fields.forEach((f) => { %>
|
|
44
|
+
<%~ JSON.stringify(f.field) %>: a.<%= f.source === 'userId' ? 'user_id' : f.source === 'email' ? 'email' : 'workspace_id' %>,
|
|
45
|
+
<% }) %>
|
|
46
|
+
}
|
|
47
|
+
if any(v in (None, "") for v in values.values()):
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tenant in the auth context")
|
|
49
|
+
return values
|
|
50
|
+
|
|
51
|
+
def _qb(self, op: str) -> QueryBuilder:
|
|
52
|
+
"""A query builder limited to the caller's tenant (compared as text)."""
|
|
53
|
+
scope = [
|
|
54
|
+
cast(getattr(<%= it.entityName %>, field), String) == str(value)
|
|
55
|
+
for field, value in self._scope_values(op).items()
|
|
56
|
+
]
|
|
57
|
+
return QueryBuilder(<%= it.entityName %>, self.db, scope=scope)
|
|
58
|
+
|
|
59
|
+
def _with_tenant(self, payload: dict) -> dict:
|
|
60
|
+
"""Set every scopeBy field to the caller's value, converted to the column's type."""
|
|
61
|
+
<% if (it.scope.injectOnCreate) { %>
|
|
62
|
+
for field, value in self._scope_values("create").items():
|
|
63
|
+
try:
|
|
64
|
+
value = getattr(<%= it.entityName %>, field).type.python_type(value)
|
|
65
|
+
except (NotImplementedError, TypeError, ValueError):
|
|
66
|
+
pass
|
|
67
|
+
payload[field] = value
|
|
68
|
+
<% } %>
|
|
69
|
+
return payload
|
|
70
|
+
<% } else { %>
|
|
71
|
+
|
|
72
|
+
def _qb(self, op: str) -> QueryBuilder:
|
|
73
|
+
return QueryBuilder(<%= it.entityName %>, self.db)
|
|
74
|
+
<% } %>
|
|
20
75
|
|
|
21
76
|
async def get_many(self, options: QueryOptions) -> PaginatedResult:
|
|
22
77
|
"""
|
|
23
78
|
Retrieve a paginated list of <%= it.pluralEntityName %> with filtering, sorting, and joins.
|
|
24
79
|
(nestjsx dialect — {data,count,total,page,pageCount} envelope.)
|
|
25
80
|
"""
|
|
26
|
-
return await self._qb.get_many(options)
|
|
81
|
+
return await self._qb("find").get_many(options)
|
|
27
82
|
|
|
28
83
|
async def get_many_list(self, options: QueryOptions) -> List[Any]:
|
|
29
84
|
"""
|
|
30
85
|
Retrieve <%= it.pluralEntityName %> as a bare list (PostgREST dialect).
|
|
31
86
|
"""
|
|
32
|
-
return await self._qb.get_many_list(options)
|
|
87
|
+
return await self._qb("find").get_many_list(options)
|
|
33
88
|
|
|
34
89
|
async def get_one(
|
|
35
90
|
self,
|
|
36
91
|
id: Any,
|
|
37
92
|
joins: Optional[List[str]] = None,
|
|
93
|
+
op: str = "get",
|
|
38
94
|
) -> Optional[<%= it.entityName %>]:
|
|
39
95
|
"""
|
|
40
96
|
Retrieve a single <%= it.entityName %> by ID.
|
|
41
97
|
"""
|
|
42
|
-
return await self._qb.get_one(id, joins=joins)
|
|
98
|
+
return await self._qb(op).get_one(id, joins=joins)
|
|
43
99
|
|
|
44
100
|
async def create_one(self, data: <%= it.entityName %>Create) -> <%= it.entityName %>:
|
|
45
101
|
"""
|
|
46
102
|
Create a new <%= it.entityName %>.
|
|
47
103
|
"""
|
|
104
|
+
<% if (it.scope) { %>
|
|
105
|
+
item = <%= it.entityName %>(**self._with_tenant(data.model_dump(exclude_unset=True)))
|
|
106
|
+
<% } else { %>
|
|
48
107
|
item = <%= it.entityName %>(**data.model_dump(exclude_unset=True))
|
|
108
|
+
<% } %>
|
|
49
109
|
self.db.add(item)
|
|
50
110
|
await self.db.commit()
|
|
51
111
|
await self.db.refresh(item)
|
|
@@ -55,7 +115,11 @@ class <%= it.entityName %>Service:
|
|
|
55
115
|
"""
|
|
56
116
|
Create multiple <%= it.pluralEntityName %>.
|
|
57
117
|
"""
|
|
118
|
+
<% if (it.scope) { %>
|
|
119
|
+
items = [<%= it.entityName %>(**self._with_tenant(item.model_dump(exclude_unset=True))) for item in data]
|
|
120
|
+
<% } else { %>
|
|
58
121
|
items = [<%= it.entityName %>(**item.model_dump(exclude_unset=True)) for item in data]
|
|
122
|
+
<% } %>
|
|
59
123
|
self.db.add_all(items)
|
|
60
124
|
await self.db.commit()
|
|
61
125
|
for item in items:
|
|
@@ -70,11 +134,16 @@ class <%= it.entityName %>Service:
|
|
|
70
134
|
"""
|
|
71
135
|
Update an existing <%= it.entityName %>.
|
|
72
136
|
"""
|
|
73
|
-
item = await self.get_one(id)
|
|
137
|
+
item = await self.get_one(id, op="update")
|
|
74
138
|
if not item:
|
|
75
139
|
return None
|
|
76
140
|
|
|
77
141
|
update_data = data.model_dump(exclude_unset=True)
|
|
142
|
+
<% if (it.scope) { %>
|
|
143
|
+
# A scoped row cannot move to another tenant.
|
|
144
|
+
for field in self._scope_values("update"):
|
|
145
|
+
update_data.pop(field, None)
|
|
146
|
+
<% } %>
|
|
78
147
|
for field, value in update_data.items():
|
|
79
148
|
setattr(item, field, value)
|
|
80
149
|
|
|
@@ -90,11 +159,15 @@ class <%= it.entityName %>Service:
|
|
|
90
159
|
"""
|
|
91
160
|
Replace an existing <%= it.entityName %>.
|
|
92
161
|
"""
|
|
93
|
-
item = await self.get_one(id)
|
|
162
|
+
item = await self.get_one(id, op="update")
|
|
94
163
|
if not item:
|
|
95
164
|
return None
|
|
96
165
|
|
|
97
166
|
replace_data = data.model_dump()
|
|
167
|
+
<% if (it.scope) { %>
|
|
168
|
+
for field in self._scope_values("update"):
|
|
169
|
+
replace_data.pop(field, None)
|
|
170
|
+
<% } %>
|
|
98
171
|
for field, value in replace_data.items():
|
|
99
172
|
setattr(item, field, value)
|
|
100
173
|
|
|
@@ -106,7 +179,7 @@ class <%= it.entityName %>Service:
|
|
|
106
179
|
"""
|
|
107
180
|
Delete a <%= it.entityName %> by ID.
|
|
108
181
|
"""
|
|
109
|
-
item = await self.get_one(id)
|
|
182
|
+
item = await self.get_one(id, op="delete")
|
|
110
183
|
if not item:
|
|
111
184
|
return False
|
|
112
185
|
|
|
@@ -701,9 +701,11 @@ def parse_query_params(params: Any, dialect_header: Optional[str] = None) -> Que
|
|
|
701
701
|
class QueryBuilder:
|
|
702
702
|
"""Build and execute filtered/sorted/paginated SQLAlchemy queries."""
|
|
703
703
|
|
|
704
|
-
def __init__(self, model: Type[T], db: AsyncSession):
|
|
704
|
+
def __init__(self, model: Type[T], db: AsyncSession, scope: Optional[List[Any]] = None):
|
|
705
705
|
self.model = model
|
|
706
706
|
self.db = db
|
|
707
|
+
# Conditions every query must meet (the caller's tenant, .apsorc scopeBy).
|
|
708
|
+
self.scope = list(scope or [])
|
|
707
709
|
|
|
708
710
|
# -- shared predicate builders ----------------------------------------
|
|
709
711
|
|
|
@@ -735,7 +737,7 @@ class QueryBuilder:
|
|
|
735
737
|
return or_(*exprs) if group.operator == "$or" else and_(*exprs)
|
|
736
738
|
|
|
737
739
|
def _where_clauses(self, options: QueryOptions) -> List[Any]:
|
|
738
|
-
clauses: List[Any] =
|
|
740
|
+
clauses: List[Any] = list(self.scope)
|
|
739
741
|
for f in options.filters:
|
|
740
742
|
expr = self._filter_expr(f)
|
|
741
743
|
if expr is not None:
|
|
@@ -889,7 +891,7 @@ class QueryBuilder:
|
|
|
889
891
|
id: Any,
|
|
890
892
|
joins: Optional[List[str]] = None,
|
|
891
893
|
) -> Optional[Any]:
|
|
892
|
-
query = select(self.model).where(self.model.id == id)
|
|
894
|
+
query = select(self.model).where(self.model.id == id, *self.scope)
|
|
893
895
|
|
|
894
896
|
if joins:
|
|
895
897
|
for join_name in joins:
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
<%~ includeFile('./header.eta') %>
|
|
2
2
|
|
|
3
3
|
import { Controller, Body } from "@nestjs/common";
|
|
4
|
-
import { Crud, <% if (it.crudAuth.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
|
|
4
|
+
import { Crud, <% if (it.crudAuth.length || it.crudAuthNested.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
|
|
5
5
|
import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
|
|
6
6
|
import {<%= it.entityName %>, <%= it.entityName %>Create, <%= it.entityName %>Update} from './dtos/<%= it.entityName%>.dto'
|
|
7
7
|
import {<%= it.svcName %>} from './<%= it.entityName %>.service';
|
|
8
8
|
|
|
9
|
+
<% if (it.crudAuthNested.length) { %>
|
|
10
|
+
// A nested scopeBy path (task.workspaceId) limits rows to the parents ScopeGuard
|
|
11
|
+
// found in the caller's tenant (req.apsoScope). An empty list matches no rows.
|
|
12
|
+
const nestedScope = (req: any, fk: string) => {
|
|
13
|
+
const ids = req.apsoScope?.[fk];
|
|
14
|
+
if (!ids) return {};
|
|
15
|
+
return { [fk]: ids.length ? { $in: ids } : { $isnull: true, $notnull: true } };
|
|
16
|
+
};
|
|
17
|
+
<% } %>
|
|
9
18
|
@Crud({
|
|
10
19
|
model: {
|
|
11
20
|
type: <%= it.entityName %>
|
|
@@ -51,12 +60,15 @@ import {<%= it.svcName %>} from './<%= it.entityName %>.service';
|
|
|
51
60
|
<% } %>
|
|
52
61
|
}
|
|
53
62
|
})
|
|
54
|
-
<% if (it.crudAuth.length) { %>
|
|
63
|
+
<% if (it.crudAuth.length || it.crudAuthNested.length) { %>
|
|
55
64
|
// Every query and write is limited to the caller's tenant (.apsorc scopeBy).
|
|
56
65
|
@CrudAuth({
|
|
57
66
|
filter: (req: any) => ({
|
|
58
67
|
<% it.crudAuth.forEach((s) => { %>
|
|
59
68
|
<%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %> ?? req.auth?.<%= s.contextKey %> ?? req.auth?.workspaceId,
|
|
69
|
+
<% }) %>
|
|
70
|
+
<% it.crudAuthNested.forEach((s) => { %>
|
|
71
|
+
...nestedScope(req, '<%= s.fkField %>'),
|
|
60
72
|
<% }) %>
|
|
61
73
|
}),
|
|
62
74
|
persist: (req: any) => ({
|
|
@@ -167,6 +167,11 @@ export interface ServiceGenerationOptions extends EntityGenerationOptions {
|
|
|
167
167
|
* Relationship map for service methods
|
|
168
168
|
*/
|
|
169
169
|
relationshipMap: RelationshipMap;
|
|
170
|
+
/**
|
|
171
|
+
* The .apsorc auth config; with scopeBy, Go and Python services filter
|
|
172
|
+
* every query to the caller's tenant
|
|
173
|
+
*/
|
|
174
|
+
auth?: AuthConfig;
|
|
170
175
|
}
|
|
171
176
|
/**
|
|
172
177
|
* Options for generating DTOs/schemas
|
|
@@ -257,7 +262,7 @@ export interface LanguageGenerator {
|
|
|
257
262
|
/**
|
|
258
263
|
* Generate guard files (auth, scope, etc.)
|
|
259
264
|
*/
|
|
260
|
-
generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
|
|
265
|
+
generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
|
|
261
266
|
/**
|
|
262
267
|
* Generate shared query utility files (filter, sort, pagination)
|
|
263
268
|
*/
|
|
@@ -48,6 +48,7 @@ export declare const parseRelationship: (relationship: ApsorcRelationship, allRe
|
|
|
48
48
|
* @returns A comprehensive RelationshipMap mapping entity names to their Relationship objects.
|
|
49
49
|
*/
|
|
50
50
|
export declare const parseRelationships: (relationships: ApsorcRelationship[]) => RelationshipMap;
|
|
51
|
+
export declare const getRelationshipName: (relationship: Relationship) => string;
|
|
51
52
|
export declare const getRelationshipIdField: (relationship: Relationship) => string;
|
|
52
53
|
/**
|
|
53
54
|
* Transforms the parsed Relationship data into the final format needed by the entity/controller templates.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getRelationshipsForImport = exports.streamNestedRelationships = exports.getNestedRelationships = exports.generateNestedRelationships = exports.getRelationshipForTemplate = exports.getRelationshipIdField = exports.parseRelationships = exports.parseRelationship = exports.parseManyToMany = exports.parseManytoOne = exports.parseOneToMany = void 0;
|
|
3
|
+
exports.getRelationshipsForImport = exports.streamNestedRelationships = exports.getNestedRelationships = exports.generateNestedRelationships = exports.getRelationshipForTemplate = exports.getRelationshipIdField = exports.getRelationshipName = exports.parseRelationships = exports.parseRelationship = exports.parseManyToMany = exports.parseManytoOne = exports.parseOneToMany = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
|
|
6
6
|
const casing_1 = require("../casing");
|
|
@@ -413,8 +413,9 @@ const getRelationshipName = (relationship) => {
|
|
|
413
413
|
}
|
|
414
414
|
return (0, casing_1.camelCase)(relationship.name);
|
|
415
415
|
};
|
|
416
|
+
exports.getRelationshipName = getRelationshipName;
|
|
416
417
|
const getRelationshipIdField = (relationship) => {
|
|
417
|
-
return `${getRelationshipName(relationship)}Id`;
|
|
418
|
+
return `${(0, exports.getRelationshipName)(relationship)}Id`;
|
|
418
419
|
};
|
|
419
420
|
exports.getRelationshipIdField = getRelationshipIdField;
|
|
420
421
|
/**
|
|
@@ -572,7 +573,7 @@ function* generateNestedRelationships(entityName, relationshipMap, maxDepth = 4,
|
|
|
572
573
|
if (!relationships)
|
|
573
574
|
return;
|
|
574
575
|
for (const relationship of relationships) {
|
|
575
|
-
const referenceName = getRelationshipName(relationship);
|
|
576
|
+
const referenceName = (0, exports.getRelationshipName)(relationship);
|
|
576
577
|
const type = relationship.type;
|
|
577
578
|
// Format the property name based on relationship type
|
|
578
579
|
// OneToMany and ManyToMany relationships use plural forms
|