@apso/cli 0.45.1 → 0.46.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.
@@ -159,6 +159,7 @@ class Generate extends base_command_1.default {
159
159
  allEntities: entities,
160
160
  apiType: lowerCaseApiType,
161
161
  relationshipMap,
162
+ auth,
162
163
  }),
163
164
  generator.generateModule({
164
165
  entity,
@@ -375,6 +375,7 @@ class McpServe extends base_command_1.default {
375
375
  allEntities: entities,
376
376
  apiType: lowerCaseApiType,
377
377
  relationshipMap,
378
+ auth,
378
379
  }),
379
380
  generator.generateController({
380
381
  entity,
@@ -55,6 +55,12 @@ export declare class GoGenerator extends BaseGenerator {
55
55
  generateDomainEvents(entities: Entity[], _apiType?: string, opts?: {
56
56
  emitEvents?: boolean;
57
57
  }): Promise<GeneratedFile[]>;
58
+ /**
59
+ * Scope data for a Go service: each direct scopeBy field's struct field and
60
+ * gorm column (snake_case of the Go field). A field declared on the entity is
61
+ * pascalCase; a relation's foreign key (organizationId) is OrganizationID.
62
+ */
63
+ private goScope;
58
64
  generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
59
65
  generateQueryUtils(_entities: Entity[], _apiType: string): Promise<GeneratedFile[]>;
60
66
  }
@@ -196,6 +196,7 @@ class GoGenerator extends base_1.BaseGenerator {
196
196
  primaryKeyGoType: this.getPrimaryKeyGoType(primaryKeyType),
197
197
  primaryKeySwaggerType: this.getPrimaryKeySwaggerType(primaryKeyType),
198
198
  associations: [...new Set(relationshipsForTemplate)],
199
+ scope: (0, guards_1.entityScope)(entity, Boolean(options.auth)),
199
200
  };
200
201
  const content = await this.renderTemplate("./handlers/handler", data);
201
202
  return [
@@ -232,6 +233,7 @@ class GoGenerator extends base_1.BaseGenerator {
232
233
  updatedAt,
233
234
  columns,
234
235
  associations: [...new Set(goRelationships)],
236
+ scope: this.goScope(entity, Boolean(options.auth)),
235
237
  };
236
238
  const content = await this.renderTemplate("./services/service", data);
237
239
  return [
@@ -420,11 +422,34 @@ ${modelRefs}
420
422
  },
421
423
  ];
422
424
  }
425
+ /**
426
+ * Scope data for a Go service: each direct scopeBy field's struct field and
427
+ * gorm column (snake_case of the Go field). A field declared on the entity is
428
+ * pascalCase; a relation's foreign key (organizationId) is OrganizationID.
429
+ */
430
+ goScope(entity, hasAuth) {
431
+ var _a;
432
+ const scope = (0, guards_1.entityScope)(entity, hasAuth);
433
+ if (!scope)
434
+ return;
435
+ const declared = new Set(((_a = entity.fields) !== null && _a !== void 0 ? _a : []).map((f) => f.name));
436
+ return {
437
+ ...scope,
438
+ fields: scope.fields.map((f) => ({
439
+ ...f,
440
+ goField: declared.has(f.field) || !f.field.endsWith("Id")
441
+ ? (0, casing_1.pascalCase)(f.field)
442
+ : `${(0, casing_1.pascalCase)(f.field.slice(0, -2))}ID`,
443
+ column: (0, casing_1.snakeCase)(f.field),
444
+ })),
445
+ };
446
+ }
423
447
  async generateGuards(entities, auth) {
424
448
  if (!auth)
425
449
  return [];
426
- if ((0, guards_1.hasScopedEntities)(entities))
427
- console.warn("[apso] scopeBy is enforced in TypeScript services only; this Go service authenticates requests but does not filter rows by tenant");
450
+ const nested = (0, guards_1.nestedScopeEntities)(entities);
451
+ if (nested.length > 0)
452
+ console.warn(`[apso] Nested scopeBy paths are enforced in TypeScript services only; ${nested.join(", ")} will not be filtered by those paths in this Go service`);
428
453
  let data;
429
454
  if ((0, types_1.isJwtAuth)(auth)) {
430
455
  data = { jwt: (0, types_1.resolveJwtAuth)(auth) };
@@ -449,12 +474,18 @@ ${modelRefs}
449
474
  throw new Error(`auth.provider "${auth.provider}" is generated for TypeScript services only. For Go, use "jwt" (Better Auth issues JWTs with its JWT plugin) or "api-key".`);
450
475
  }
451
476
  const content = await this.renderTemplate("./middleware/auth", data);
452
- return [
453
- {
454
- path: "middleware/auth.go",
455
- content,
456
- },
457
- ];
477
+ const files = [{ path: "middleware/auth.go", content }];
478
+ // Shared helpers for services and handlers of entities with a direct scopeBy.
479
+ if (entities.some((e) => (0, guards_1.entityScope)(e, true))) {
480
+ files.push({
481
+ path: "services/scope.go",
482
+ content: await this.renderTemplate("./services/scope", {}),
483
+ }, {
484
+ path: "handlers/scope.go",
485
+ content: await this.renderTemplate("./handlers/scope", {}),
486
+ });
487
+ }
488
+ return files;
458
489
  }
459
490
  async generateQueryUtils(_entities, _apiType) {
460
491
  const typesContent = await this.renderTemplate("./utils/types", {});
@@ -155,6 +155,7 @@ class PythonGenerator extends base_1.BaseGenerator {
155
155
  pluralEntityName,
156
156
  primaryKeyType,
157
157
  associations: [...new Set(relationshipsForTemplate)],
158
+ scope: (0, guards_1.entityScope)(entity, Boolean(options.auth)),
158
159
  };
159
160
  const content = await this.renderTemplate("./services/service", data);
160
161
  return [
@@ -328,8 +329,9 @@ ${downStatements || " pass"}
328
329
  async generateGuards(entities, auth) {
329
330
  if (!auth)
330
331
  return [];
331
- if ((0, guards_1.hasScopedEntities)(entities))
332
- console.warn("[apso] scopeBy is enforced in TypeScript services only; this Python service authenticates requests but does not filter rows by tenant");
332
+ const nested = (0, guards_1.nestedScopeEntities)(entities);
333
+ if (nested.length > 0)
334
+ console.warn(`[apso] Nested scopeBy paths are enforced in TypeScript services only; ${nested.join(", ")} will not be filtered by those paths in this Python service`);
333
335
  let data;
334
336
  if ((0, types_1.isJwtAuth)(auth)) {
335
337
  data = { jwt: (0, types_1.resolveJwtAuth)(auth) };
@@ -38,3 +38,25 @@ export interface ApiKeyTemplateData extends ResolvedApiKeyAuth {
38
38
  * has `status` and `expiresAt`, which the guard then enforces.
39
39
  */
40
40
  export declare function apiKeyTemplateData(apiKey: ResolvedApiKeyAuth, entities: Entity[]): ApiKeyTemplateData;
41
+ /** A direct scopeBy field and the auth context value that fills it. */
42
+ export interface DirectScopeField {
43
+ field: string;
44
+ /** userId and email map to the caller's own; anything else is the tenant. */
45
+ source: "userId" | "email" | "tenant";
46
+ }
47
+ /** What Go and Python services need to scope one entity. */
48
+ export interface EntityScopeData {
49
+ fields: DirectScopeField[];
50
+ enforceOn: string[];
51
+ bypassRoles: string[];
52
+ injectOnCreate: boolean;
53
+ }
54
+ /**
55
+ * The direct scopeBy fields of an entity, for services that filter in their
56
+ * own queries (Go, Python). Undefined without auth: with no caller identity
57
+ * there is no tenant to scope by. Nested paths (task.workspaceId) are not
58
+ * included; see hasNestedScopes.
59
+ */
60
+ export declare function entityScope(entity: Entity, hasAuth: boolean): EntityScopeData | undefined;
61
+ /** Entities whose scopeBy uses a nested path, which Go and Python do not enforce. */
62
+ export declare function nestedScopeEntities(entities: Entity[]): string[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.apiKeyTemplateData = exports.hasScopedEntities = exports.getScopedEntities = void 0;
3
+ exports.nestedScopeEntities = exports.entityScope = exports.apiKeyTemplateData = exports.hasScopedEntities = exports.getScopedEntities = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
6
6
  /**
@@ -81,3 +81,38 @@ function apiKeyTemplateData(apiKey, entities) {
81
81
  };
82
82
  }
83
83
  exports.apiKeyTemplateData = apiKeyTemplateData;
84
+ /**
85
+ * The direct scopeBy fields of an entity, for services that filter in their
86
+ * own queries (Go, Python). Undefined without auth: with no caller identity
87
+ * there is no tenant to scope by. Nested paths (task.workspaceId) are not
88
+ * included; see hasNestedScopes.
89
+ */
90
+ function entityScope(entity, hasAuth) {
91
+ if (!hasAuth || !entity.scopeBy)
92
+ return undefined;
93
+ const [config] = getScopedEntities([entity]);
94
+ const fields = config.scopes
95
+ .filter((s) => s.direct)
96
+ .map((s) => ({
97
+ field: s.field,
98
+ source: s.contextKey === "userId" || s.contextKey === "email"
99
+ ? s.contextKey
100
+ : "tenant",
101
+ }));
102
+ if (fields.length === 0 || config.enforceOn.length === 0)
103
+ return undefined;
104
+ return {
105
+ fields,
106
+ enforceOn: config.enforceOn,
107
+ bypassRoles: config.bypassRoles,
108
+ injectOnCreate: config.injectOnCreate,
109
+ };
110
+ }
111
+ exports.entityScope = entityScope;
112
+ /** Entities whose scopeBy uses a nested path, which Go and Python do not enforce. */
113
+ function nestedScopeEntities(entities) {
114
+ return getScopedEntities(entities)
115
+ .filter((e) => e.scopes.some((s) => !s.direct))
116
+ .map((e) => e.name);
117
+ }
118
+ exports.nestedScopeEntities = nestedScopeEntities;
@@ -76,6 +76,11 @@ func (h *<%= it.entityName %>Handler) FindAll(c *gin.Context) {
76
76
  if opts.Bare {
77
77
  rows, err := h.service.FindAllBare(c.Request.Context(), opts)
78
78
  if err != nil {
79
+ <% if (it.scope) { %>
80
+ if scopeError(c, err) {
81
+ return
82
+ }
83
+ <% } %>
79
84
  if qe, ok := utils.AsQueryError(err); ok {
80
85
  c.JSON(http.StatusBadRequest, gin.H{"error": qe.Error()})
81
86
  return
@@ -89,6 +94,11 @@ func (h *<%= it.entityName %>Handler) FindAll(c *gin.Context) {
89
94
 
90
95
  result, err := h.service.FindAll(c.Request.Context(), opts)
91
96
  if err != nil {
97
+ <% if (it.scope) { %>
98
+ if scopeError(c, err) {
99
+ return
100
+ }
101
+ <% } %>
92
102
  if qe, ok := utils.AsQueryError(err); ok {
93
103
  c.JSON(http.StatusBadRequest, gin.H{"error": qe.Error()})
94
104
  return
@@ -126,6 +136,11 @@ func (h *<%= it.entityName %>Handler) FindOne(c *gin.Context) {
126
136
 
127
137
  result, err := h.service.FindOne(c.Request.Context(), id)
128
138
  if err != nil {
139
+ <% if (it.scope) { %>
140
+ if scopeError(c, err) {
141
+ return
142
+ }
143
+ <% } %>
129
144
  c.JSON(http.StatusNotFound, gin.H{"error": "<%= it.entityName %> not found"})
130
145
  return
131
146
  }
@@ -158,6 +173,11 @@ func (h *<%= it.entityName %>Handler) Create(c *gin.Context) {
158
173
 
159
174
  result, err := h.service.Create(c.Request.Context(), &input)
160
175
  if err != nil {
176
+ <% if (it.scope) { %>
177
+ if scopeError(c, err) {
178
+ return
179
+ }
180
+ <% } %>
161
181
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
162
182
  return
163
183
  }
@@ -204,6 +224,11 @@ func (h *<%= it.entityName %>Handler) Update(c *gin.Context) {
204
224
 
205
225
  result, err := h.service.Update(c.Request.Context(), id, &input)
206
226
  if err != nil {
227
+ <% if (it.scope) { %>
228
+ if scopeError(c, err) {
229
+ return
230
+ }
231
+ <% } %>
207
232
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
208
233
  return
209
234
  }
@@ -236,6 +261,11 @@ func (h *<%= it.entityName %>Handler) Delete(c *gin.Context) {
236
261
  <% } %>
237
262
 
238
263
  if err := h.service.Delete(c.Request.Context(), id); err != nil {
264
+ <% if (it.scope) { %>
265
+ if scopeError(c, err) {
266
+ return
267
+ }
268
+ <% } %>
239
269
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
240
270
  return
241
271
  }
@@ -0,0 +1,27 @@
1
+ <%~ includeFile('../../header.eta', it) %>
2
+
3
+ package handlers
4
+
5
+ import (
6
+ "errors"
7
+ "net/http"
8
+ "strings"
9
+
10
+ "app/autogen/services"
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ // scopeError answers a scoped entity's service error: 403 when the caller has
15
+ // no tenant, 404 when the row is missing or belongs to another tenant.
16
+ // It reports whether it wrote a response.
17
+ func scopeError(c *gin.Context, err error) bool {
18
+ if errors.Is(err, services.ErrForbidden) {
19
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
20
+ return true
21
+ }
22
+ if strings.HasSuffix(err.Error(), "not found") {
23
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
24
+ return true
25
+ }
26
+ return false
27
+ }
@@ -17,8 +17,8 @@ package middleware
17
17
  <% } %>
18
18
 
19
19
  import (
20
- <% if (it.jwt) { %>
21
20
  "context"
21
+ <% if (it.jwt) { %>
22
22
  "encoding/json"
23
23
  <% } else { %>
24
24
  "crypto/sha256"
@@ -63,6 +63,21 @@ type AuthContext struct {
63
63
  // AuthContextKey is the gin context key holding *AuthContext.
64
64
  const AuthContextKey = "auth"
65
65
 
66
+ type authKey struct{}
67
+
68
+ // setAuth attaches the caller to the gin context and to the request context,
69
+ // where generated services read it for scopeBy.
70
+ func setAuth(c *gin.Context, a *AuthContext) {
71
+ c.Set(AuthContextKey, a)
72
+ c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), authKey{}, a))
73
+ }
74
+
75
+ // FromContext returns the caller stored on a request context, if any.
76
+ func FromContext(ctx context.Context) (*AuthContext, bool) {
77
+ a, ok := ctx.Value(authKey{}).(*AuthContext)
78
+ return a, ok
79
+ }
80
+
66
81
  // GetAuthContext returns the authenticated caller, if any.
67
82
  func GetAuthContext(c *gin.Context) (*AuthContext, bool) {
68
83
  v, ok := c.Get(AuthContextKey)
@@ -239,7 +254,7 @@ func AuthMiddleware(_ *gorm.DB) gin.HandlerFunc {
239
254
  if tenant == "" {
240
255
  tenant = str(claim(claims, <%~ JSON.stringify(it.jwt.claims.organizationId || '') %>))
241
256
  }
242
- c.Set(AuthContextKey, &AuthContext{
257
+ setAuth(c, &AuthContext{
243
258
  UserID: userID,
244
259
  Email: str(claim(claims, <%~ JSON.stringify(it.jwt.claims.email || '') %>)),
245
260
  WorkspaceID: tenant,
@@ -329,7 +344,7 @@ func AuthMiddleware(db *gorm.DB) gin.HandlerFunc {
329
344
  roles = append(roles, fmt.Sprint(v))
330
345
  }
331
346
  <% } %>
332
- c.Set(AuthContextKey, &AuthContext{
347
+ setAuth(c, &AuthContext{
333
348
  ServiceID: fmt.Sprint(row["id"]),
334
349
  WorkspaceID: tenant,
335
350
  OrganizationID: tenant,
@@ -0,0 +1,62 @@
1
+ <%~ includeFile('../../header.eta', it) %>
2
+
3
+ package services
4
+
5
+ import (
6
+ "encoding"
7
+ "errors"
8
+ "fmt"
9
+ "reflect"
10
+ "strconv"
11
+ )
12
+
13
+ // ErrForbidden means the caller has no tenant to scope by (.apsorc scopeBy).
14
+ var ErrForbidden = errors.New("no tenant in the auth context")
15
+
16
+ // hasAnyRole reports whether the caller holds one of the bypass roles.
17
+ func hasAnyRole(roles []string, bypass ...string) bool {
18
+ for _, r := range roles {
19
+ for _, b := range bypass {
20
+ if r == b {
21
+ return true
22
+ }
23
+ }
24
+ }
25
+ return false
26
+ }
27
+
28
+ // setFromString sets a scope field on a model from the caller's tenant id,
29
+ // converting it to the field's type (string, integer, or a TextUnmarshaler such
30
+ // as uuid.UUID; pointers are allocated).
31
+ func setFromString(model interface{}, field, value string) error {
32
+ f := reflect.ValueOf(model).Elem().FieldByName(field)
33
+ if !f.IsValid() {
34
+ return fmt.Errorf("model has no field %s", field)
35
+ }
36
+ if f.Kind() == reflect.Ptr {
37
+ f.Set(reflect.New(f.Type().Elem()))
38
+ f = f.Elem()
39
+ }
40
+ if u, ok := f.Addr().Interface().(encoding.TextUnmarshaler); ok {
41
+ return u.UnmarshalText([]byte(value))
42
+ }
43
+ switch f.Kind() {
44
+ case reflect.String:
45
+ f.SetString(value)
46
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
47
+ n, err := strconv.ParseInt(value, 10, 64)
48
+ if err != nil {
49
+ return fmt.Errorf("tenant id %q is not a number: %w", value, err)
50
+ }
51
+ f.SetInt(n)
52
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
53
+ n, err := strconv.ParseUint(value, 10, 64)
54
+ if err != nil {
55
+ return fmt.Errorf("tenant id %q is not a number: %w", value, err)
56
+ }
57
+ f.SetUint(n)
58
+ default:
59
+ return fmt.Errorf("cannot set %s from a tenant id", field)
60
+ }
61
+ return nil
62
+ }
@@ -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
@@ -184,6 +184,13 @@ export class ScopeGuard implements CanActivate {
184
184
  if (operation === 'create' && scopeConfig.injectOnCreate) {
185
185
  this.injectScopeValues(request, scopeConfig);
186
186
  }
187
+ <% if (it.hasAuth) { %>
188
+
189
+ // A scoped row cannot move to another tenant on update.
190
+ if (operation === 'update') {
191
+ this.injectScopeValues(request, scopeConfig);
192
+ }
193
+ <% } %>
187
194
 
188
195
  // For GET (list) requests, add scope filter
189
196
  if (operation === 'find') {
@@ -324,7 +331,10 @@ export class ScopeGuard implements CanActivate {
324
331
  request[contextKey] ||
325
332
  request.user?.[contextKey] ||
326
333
  request.scope?.[contextKey] ||
327
- request.context?.[contextKey]
334
+ request.context?.[contextKey]<% if (it.hasAuth) { %> ||
335
+ // A scope field with another name (orgId, tenantId) is the caller's tenant.
336
+ request.auth?.[contextKey] ||
337
+ request.auth?.workspaceId<% } %>
328
338
  );
329
339
  }
330
340
 
@@ -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:
@@ -56,12 +56,12 @@ import {<%= it.svcName %>} from './<%= it.entityName %>.service';
56
56
  @CrudAuth({
57
57
  filter: (req: any) => ({
58
58
  <% it.crudAuth.forEach((s) => { %>
59
- <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
59
+ <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %> ?? req.auth?.<%= s.contextKey %> ?? req.auth?.workspaceId,
60
60
  <% }) %>
61
61
  }),
62
62
  persist: (req: any) => ({
63
63
  <% it.crudAuth.forEach((s) => { %>
64
- <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
64
+ <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %> ?? req.auth?.<%= s.contextKey %> ?? req.auth?.workspaceId,
65
65
  <% }) %>
66
66
  }),
67
67
  })
@@ -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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.45.1",
3
+ "version": "0.46.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@apso/cli",
9
- "version": "0.45.1",
9
+ "version": "0.46.0",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@apso/schema-tools": "^0.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.45.1",
2
+ "version": "0.46.0",
3
3
  "commands": {
4
4
  "config": {
5
5
  "id": "config",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apso/cli",
3
- "version": "0.45.1",
3
+ "version": "0.46.0",
4
4
  "mcpName": "io.github.apsoai/apso",
5
5
  "description": "Apso CLI",
6
6
  "author": "Apso by Mavric - @mavric",