@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.
@@ -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,
@@ -192,7 +193,7 @@ class Generate extends base_command_1.default {
192
193
  console.log(`[apso] Finished building entity '${entity.name}' in ${entityBuildTime.toFixed(2)} ms`);
193
194
  }
194
195
  // Generate guards
195
- const guardFiles = await generator.generateGuards(entities, auth);
196
+ const guardFiles = await generator.generateGuards(entities, auth, relationshipMap);
196
197
  for (const file of guardFiles) {
197
198
  // eslint-disable-next-line no-await-in-loop
198
199
  await writeGenerated(file.path, file.content);
@@ -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,
@@ -401,7 +402,7 @@ class McpServe extends base_command_1.default {
401
402
  }
402
403
  }
403
404
  // Generate guards
404
- const guardFiles = await generator.generateGuards(entities, auth);
405
+ const guardFiles = await generator.generateGuards(entities, auth, relationshipMap);
405
406
  for (const file of guardFiles) {
406
407
  const fullPath = path.join(autogenPath, file.path);
407
408
  // eslint-disable-next-line no-await-in-loop
@@ -1,5 +1,5 @@
1
1
  import * as Eta from "eta";
2
- import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, LanguageGenerator, Entity, AuthConfig } from "../types";
2
+ import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, LanguageGenerator, Entity, AuthConfig, RelationshipMap } from "../types";
3
3
  /**
4
4
  * Abstract base class for language-specific generators.
5
5
  * Provides common functionality and enforces the LanguageGenerator interface.
@@ -60,7 +60,7 @@ export declare abstract class BaseGenerator implements LanguageGenerator {
60
60
  /**
61
61
  * Generate guard files (auth, scope, etc.)
62
62
  */
63
- abstract generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
63
+ abstract generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
64
64
  /**
65
65
  * Generate shared query utility files (filter, sort, pagination).
66
66
  * Default no-op. Override in language generators that need query utils.
@@ -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) };
@@ -1,5 +1,5 @@
1
1
  import { BaseGenerator } from "./base";
2
- import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, Entity, AuthConfig } from "../types";
2
+ import { TargetLanguage, GeneratedFile, ValidationResult, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, Entity, AuthConfig, RelationshipMap } from "../types";
3
3
  /**
4
4
  * TypeScript/NestJS generator that wraps the existing generation functions.
5
5
  * This generator produces TypeORM entities, NestJS controllers, services, and DTOs.
@@ -20,7 +20,7 @@ export declare class TypeScriptGenerator extends BaseGenerator {
20
20
  emitEvents?: boolean;
21
21
  auth?: AuthConfig;
22
22
  }): Promise<GeneratedFile[]>;
23
- generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
23
+ generateGuards(entities: Entity[], auth?: AuthConfig, relationshipMap?: RelationshipMap): Promise<GeneratedFile[]>;
24
24
  /**
25
25
  * Emits a schema-derived manifest of the entities that have opted in to
26
26
  * domain-event emission (`.apsorc` `emitEvents`).
@@ -128,6 +128,9 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
128
128
  const ctrlName = `${entityName}Controller`;
129
129
  const pluralEntityName = (0, pluralize_1.default)(entityName);
130
130
  const primaryKeyType = entity.primaryKeyType || "serial";
131
+ const scopes = auth
132
+ ? (_b = (_a = (0, guards_1.getScopedEntities)([entity], relationshipMap)[0]) === null || _a === void 0 ? void 0 : _a.scopes) !== null && _b !== void 0 ? _b : []
133
+ : [];
131
134
  const data = {
132
135
  svcName,
133
136
  ctrlName,
@@ -136,12 +139,12 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
136
139
  primaryKeyType,
137
140
  associations: [...new Set(relationshipsForTemplate)],
138
141
  nestedJoinsFile,
139
- // Direct scopeBy fields @CrudAuth adds to every query once a caller is
140
- // authenticated. Nested scopes (task.workspaceId) are checked per row by
141
- // ScopeGuard only.
142
- crudAuth: auth
143
- ? ((_b = (_a = (0, guards_1.getScopedEntities)([entity])[0]) === null || _a === void 0 ? void 0 : _a.scopes) !== null && _b !== void 0 ? _b : []).filter((s) => s.direct)
144
- : [],
142
+ // scopeBy fields @CrudAuth adds to every query once a caller is
143
+ // authenticated. Direct fields match the caller's tenant; nested paths
144
+ // (task.workspaceId) match the foreign key (taskId) against the parent
145
+ // ids ScopeGuard loaded for the caller into req.apsoScope.
146
+ crudAuth: scopes.filter((s) => s.direct),
147
+ crudAuthNested: scopes.filter((s) => s.fkField),
145
148
  };
146
149
  const controllerContent = await this.renderTemplate("./rest/controller-rest", data);
147
150
  const specContent = await this.renderTemplate("./rest/controller-rest-spec", data);
@@ -328,7 +331,7 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
328
331
  },
329
332
  ];
330
333
  }
331
- async generateGuards(entities, auth) {
334
+ async generateGuards(entities, auth, relationshipMap) {
332
335
  const hasScopes = (0, guards_1.hasScopedEntities)(entities);
333
336
  const normalizedAuth = this.normalizeAuthConfig(auth);
334
337
  const jwt = auth && (0, auth_1.isJwtAuth)(auth) ? (0, auth_1.resolveJwtAuth)(auth) : undefined;
@@ -339,7 +342,7 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
339
342
  if (!hasScopes && !hasAuth) {
340
343
  return [];
341
344
  }
342
- const scopedEntities = (0, guards_1.getScopedEntities)(entities);
345
+ const scopedEntities = (0, guards_1.getScopedEntities)(entities, relationshipMap);
343
346
  const files = [];
344
347
  const authEntities = normalizedAuth
345
348
  ? [
@@ -354,6 +357,8 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
354
357
  : [];
355
358
  const templateData = {
356
359
  scopedEntities,
360
+ // Nested paths are only enforced on list and create with a caller's tenant.
361
+ hasNestedFk: hasAuth && scopedEntities.some((e) => e.scopes.some((s) => s.fkField)),
357
362
  authConfig: normalizedAuth,
358
363
  hasAuth,
359
364
  authProvider: auth === null || auth === void 0 ? void 0 : auth.provider,
@@ -1,4 +1,4 @@
1
- import { Entity, ResolvedApiKeyAuth } from "./types";
1
+ import { Entity, RelationshipMap, ResolvedApiKeyAuth } from "./types";
2
2
  /**
3
3
  * Represents a scope field configuration for template rendering
4
4
  */
@@ -7,6 +7,14 @@ export interface ScopeFieldConfig {
7
7
  contextKey: string;
8
8
  direct: boolean;
9
9
  path?: string;
10
+ /**
11
+ * Nested paths only, set when the relationship map is given: the entity's
12
+ * foreign key to the first hop (taskId), the entity it points to (Task),
13
+ * and the rest of the path on that entity (workspaceId).
14
+ */
15
+ fkField?: string;
16
+ parentEntity?: string;
17
+ parentPath?: string;
10
18
  }
11
19
  /**
12
20
  * Represents a scoped entity configuration for template rendering
@@ -21,9 +29,10 @@ export interface ScopedEntityConfig {
21
29
  bypassRoles: string[];
22
30
  }
23
31
  /**
24
- * Extracts scoped entity configurations from all entities.
32
+ * Extracts scoped entity configurations from all entities. Pass the
33
+ * relationship map to resolve nested paths to their foreign keys.
25
34
  */
26
- export declare function getScopedEntities(entities: Entity[]): ScopedEntityConfig[];
35
+ export declare function getScopedEntities(entities: Entity[], relationshipMap?: RelationshipMap): ScopedEntityConfig[];
27
36
  /**
28
37
  * Checks if any entities have scopeBy configured.
29
38
  */
@@ -38,3 +47,25 @@ export interface ApiKeyTemplateData extends ResolvedApiKeyAuth {
38
47
  * has `status` and `expiresAt`, which the guard then enforces.
39
48
  */
40
49
  export declare function apiKeyTemplateData(apiKey: ResolvedApiKeyAuth, entities: Entity[]): ApiKeyTemplateData;
50
+ /** A direct scopeBy field and the auth context value that fills it. */
51
+ export interface DirectScopeField {
52
+ field: string;
53
+ /** userId and email map to the caller's own; anything else is the tenant. */
54
+ source: "userId" | "email" | "tenant";
55
+ }
56
+ /** What Go and Python services need to scope one entity. */
57
+ export interface EntityScopeData {
58
+ fields: DirectScopeField[];
59
+ enforceOn: string[];
60
+ bypassRoles: string[];
61
+ injectOnCreate: boolean;
62
+ }
63
+ /**
64
+ * The direct scopeBy fields of an entity, for services that filter in their
65
+ * own queries (Go, Python). Undefined without auth: with no caller identity
66
+ * there is no tenant to scope by. Nested paths (task.workspaceId) are not
67
+ * included; see hasNestedScopes.
68
+ */
69
+ export declare function entityScope(entity: Entity, hasAuth: boolean): EntityScopeData | undefined;
70
+ /** Entities whose scopeBy uses a nested path, which Go and Python do not enforce. */
71
+ export declare function nestedScopeEntities(entities: Entity[]): string[];
@@ -1,8 +1,9 @@
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
+ const parse_1 = require("./utils/relationships/parse");
6
7
  /**
7
8
  * Default scope options when not specified
8
9
  */
@@ -36,18 +37,40 @@ function toRepoName(entityName) {
36
37
  return `${camelCase}Repository`;
37
38
  }
38
39
  /**
39
- * Extracts scoped entity configurations from all entities.
40
+ * A nested path's first segment must be a ManyToOne relationship of the
41
+ * entity: its foreign key is what list queries filter on.
40
42
  */
41
- function getScopedEntities(entities) {
43
+ function resolveNestedScope(entityName, scope, relationshipMap) {
44
+ var _a;
45
+ if (scope.direct || !scope.path)
46
+ return scope;
47
+ const relation = ((_a = relationshipMap[entityName]) !== null && _a !== void 0 ? _a : []).find((r) => r.type === "ManyToOne" && (0, parse_1.getRelationshipName)(r) === scope.field);
48
+ if (!relation)
49
+ throw new Error(`${entityName}.scopeBy "${scope.path}": "${scope.field}" must be a many-to-one relationship of ${entityName}`);
50
+ return {
51
+ ...scope,
52
+ fkField: (0, parse_1.getRelationshipIdField)(relation),
53
+ parentEntity: relation.name,
54
+ parentPath: scope.path.split(".").slice(1).join("."),
55
+ };
56
+ }
57
+ /**
58
+ * Extracts scoped entity configurations from all entities. Pass the
59
+ * relationship map to resolve nested paths to their foreign keys.
60
+ */
61
+ function getScopedEntities(entities, relationshipMap) {
42
62
  return entities
43
63
  .filter((entity) => entity.scopeBy)
44
64
  .map((entity) => {
45
65
  const options = { ...DEFAULT_SCOPE_OPTIONS, ...entity.scopeOptions };
66
+ const scopes = parseScopeBy(entity.scopeBy);
46
67
  return {
47
68
  name: entity.name,
48
69
  routeName: toRouteName(entity.name),
49
70
  repoName: toRepoName(entity.name),
50
- scopes: parseScopeBy(entity.scopeBy),
71
+ scopes: relationshipMap
72
+ ? scopes.map((s) => resolveNestedScope(entity.name, s, relationshipMap))
73
+ : scopes,
51
74
  injectOnCreate: options.injectOnCreate,
52
75
  enforceOn: options.enforceOn,
53
76
  bypassRoles: options.bypassRoles,
@@ -81,3 +104,38 @@ function apiKeyTemplateData(apiKey, entities) {
81
104
  };
82
105
  }
83
106
  exports.apiKeyTemplateData = apiKeyTemplateData;
107
+ /**
108
+ * The direct scopeBy fields of an entity, for services that filter in their
109
+ * own queries (Go, Python). Undefined without auth: with no caller identity
110
+ * there is no tenant to scope by. Nested paths (task.workspaceId) are not
111
+ * included; see hasNestedScopes.
112
+ */
113
+ function entityScope(entity, hasAuth) {
114
+ if (!hasAuth || !entity.scopeBy)
115
+ return undefined;
116
+ const [config] = getScopedEntities([entity]);
117
+ const fields = config.scopes
118
+ .filter((s) => s.direct)
119
+ .map((s) => ({
120
+ field: s.field,
121
+ source: s.contextKey === "userId" || s.contextKey === "email"
122
+ ? s.contextKey
123
+ : "tenant",
124
+ }));
125
+ if (fields.length === 0 || config.enforceOn.length === 0)
126
+ return undefined;
127
+ return {
128
+ fields,
129
+ enforceOn: config.enforceOn,
130
+ bypassRoles: config.bypassRoles,
131
+ injectOnCreate: config.injectOnCreate,
132
+ };
133
+ }
134
+ exports.entityScope = entityScope;
135
+ /** Entities whose scopeBy uses a nested path, which Go and Python do not enforce. */
136
+ function nestedScopeEntities(entities) {
137
+ return getScopedEntities(entities)
138
+ .filter((e) => e.scopes.some((s) => !s.direct))
139
+ .map((e) => e.name);
140
+ }
141
+ 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
+ }