@apso/cli 0.45.2 → 0.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/generate.js +2 -1
- package/dist/commands/mcp/serve.js +2 -1
- package/dist/lib/generators/base.d.ts +2 -2
- package/dist/lib/generators/go.d.ts +6 -0
- package/dist/lib/generators/go.js +39 -8
- package/dist/lib/generators/python.js +4 -2
- package/dist/lib/generators/typescript.d.ts +2 -2
- package/dist/lib/generators/typescript.js +13 -8
- package/dist/lib/guards.d.ts +34 -3
- package/dist/lib/guards.js +62 -4
- package/dist/lib/templates/go/handlers/handler.eta +30 -0
- package/dist/lib/templates/go/handlers/scope.eta +27 -0
- package/dist/lib/templates/go/middleware/auth.eta +18 -3
- package/dist/lib/templates/go/services/scope.eta +62 -0
- package/dist/lib/templates/go/services/service.eta +96 -4
- package/dist/lib/templates/guards/scope.guard.eta +72 -1
- package/dist/lib/templates/python/routers/router.eta +12 -7
- package/dist/lib/templates/python/services/service.eta +81 -8
- package/dist/lib/templates/python/utils/query.eta +5 -3
- package/dist/lib/templates/rest/controller-rest.eta +14 -2
- package/dist/lib/types/generator.d.ts +6 -1
- package/dist/lib/utils/relationships/parse.d.ts +1 -0
- package/dist/lib/utils/relationships/parse.js +4 -3
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
//
|
|
140
|
-
// authenticated.
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
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,
|
package/dist/lib/guards.d.ts
CHANGED
|
@@ -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[];
|
package/dist/lib/guards.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
}
|