@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.
@@ -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 := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
233
+ if err := findQuery.Where("id = ?", id).First(&item).Error; err != nil {
155
234
  <% } else { %>
156
- if err := s.db.WithContext(ctx).First(&item, id).Error; err != nil {
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 := s.db.WithContext(ctx).Where("id = ?", id).Delete(&models.<%= it.entityName %>{})
294
+ result := deleteQuery.Where("id = ?", id).Delete(&models.<%= it.entityName %>{})
203
295
  <% } else { %>
204
- result := s.db.WithContext(ctx).Delete(&models.<%= it.entityName %>{}, id)
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
- self._qb = QueryBuilder(<%= it.entityName %>, db)
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