@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.
- package/dist/commands/generate.js +1 -0
- package/dist/commands/mcp/serve.js +1 -0
- 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/guards.d.ts +22 -0
- package/dist/lib/guards.js +36 -1
- 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 +11 -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 +2 -2
- package/dist/lib/types/generator.d.ts +5 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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) };
|
package/dist/lib/guards.d.ts
CHANGED
|
@@ -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[];
|
package/dist/lib/guards.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -7,6 +7,9 @@ import (
|
|
|
7
7
|
"errors"
|
|
8
8
|
|
|
9
9
|
"app/autogen/dto"
|
|
10
|
+
<% if (it.scope) { %>
|
|
11
|
+
"app/autogen/middleware"
|
|
12
|
+
<% } %>
|
|
10
13
|
"app/autogen/models"
|
|
11
14
|
"app/autogen/utils"
|
|
12
15
|
"gorm.io/gorm"
|
|
@@ -37,6 +40,42 @@ type <%= it.entityName %>Service struct {
|
|
|
37
40
|
func New<%= it.entityName %>Service(db *gorm.DB) *<%= it.entityName %>Service {
|
|
38
41
|
return &<%= it.entityName %>Service{db: db}
|
|
39
42
|
}
|
|
43
|
+
<% if (it.scope) { %>
|
|
44
|
+
|
|
45
|
+
// scoped limits q to the caller's tenant for op ("find", "get", "create",
|
|
46
|
+
// "update", "delete") per .apsorc scopeBy, and returns the scope column values.
|
|
47
|
+
// Both are unchanged when op is not enforced or the caller holds a bypass role.
|
|
48
|
+
func (s *<%= it.entityName %>Service) scoped(ctx context.Context, q *gorm.DB, op string) (*gorm.DB, map[string]string, error) {
|
|
49
|
+
switch op {
|
|
50
|
+
case <%~ it.scope.enforceOn.map((o) => JSON.stringify(o)).join(', ') %>:
|
|
51
|
+
default:
|
|
52
|
+
return q, nil, nil
|
|
53
|
+
}
|
|
54
|
+
a, ok := middleware.FromContext(ctx)
|
|
55
|
+
if !ok {
|
|
56
|
+
return nil, nil, ErrForbidden
|
|
57
|
+
}
|
|
58
|
+
<% if (it.scope.bypassRoles.length > 0) { %>
|
|
59
|
+
if hasAnyRole(a.Roles, <%~ it.scope.bypassRoles.map((r) => JSON.stringify(r)).join(', ') %>) {
|
|
60
|
+
return q, nil, nil
|
|
61
|
+
}
|
|
62
|
+
<% } %>
|
|
63
|
+
values := map[string]string{
|
|
64
|
+
<% it.scope.fields.forEach((f) => { %>
|
|
65
|
+
<%~ JSON.stringify(f.column) %>: a.<%= f.source === 'userId' ? 'UserID' : f.source === 'email' ? 'Email' : 'WorkspaceID' %>,
|
|
66
|
+
<% }) %>
|
|
67
|
+
}
|
|
68
|
+
table := models.<%= it.entityName %>{}.TableName()
|
|
69
|
+
for col, v := range values {
|
|
70
|
+
if v == "" {
|
|
71
|
+
return nil, nil, ErrForbidden
|
|
72
|
+
}
|
|
73
|
+
// Compared as text: token claims are strings, the column may be a number or uuid.
|
|
74
|
+
q = q.Where("CAST("+table+"."+col+" AS TEXT) = ?", v)
|
|
75
|
+
}
|
|
76
|
+
return q, values, nil
|
|
77
|
+
}
|
|
78
|
+
<% } %>
|
|
40
79
|
|
|
41
80
|
// FindAll retrieves a paginated list of <%= it.pluralName %> with filtering, sorting, and joins.
|
|
42
81
|
// Used by the nestjsx dialect ({data,count,total,page,pageCount} envelope).
|
|
@@ -47,6 +86,12 @@ func (s *<%= it.entityName %>Service) FindAll(ctx context.Context, opts utils.Qu
|
|
|
47
86
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
48
87
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
49
88
|
<% }) %>
|
|
89
|
+
<% if (it.scope) { %>
|
|
90
|
+
query, _, scopeErr := s.scoped(ctx, query, "find")
|
|
91
|
+
if scopeErr != nil {
|
|
92
|
+
return nil, scopeErr
|
|
93
|
+
}
|
|
94
|
+
<% } %>
|
|
50
95
|
|
|
51
96
|
query, total, pageCount, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
|
|
52
97
|
if err != nil {
|
|
@@ -82,6 +127,12 @@ func (s *<%= it.entityName %>Service) FindAllBare(ctx context.Context, opts util
|
|
|
82
127
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
83
128
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
84
129
|
<% }) %>
|
|
130
|
+
<% if (it.scope) { %>
|
|
131
|
+
query, _, scopeErr := s.scoped(ctx, query, "find")
|
|
132
|
+
if scopeErr != nil {
|
|
133
|
+
return nil, scopeErr
|
|
134
|
+
}
|
|
135
|
+
<% } %>
|
|
85
136
|
|
|
86
137
|
query, _, _, err := utils.ApplyQueryE(query, opts, AllowedFields<%= it.entityName %>)
|
|
87
138
|
if err != nil {
|
|
@@ -107,6 +158,12 @@ func (s *<%= it.entityName %>Service) FindOne(ctx context.Context, id string) (*
|
|
|
107
158
|
<% it.associations.filter(a => a.eager).forEach((assoc) => { %>
|
|
108
159
|
query = query.Preload("<%= assoc.referenceName || assoc.name %>")
|
|
109
160
|
<% }) %>
|
|
161
|
+
<% if (it.scope) { %>
|
|
162
|
+
query, _, scopeErr := s.scoped(ctx, query, "get")
|
|
163
|
+
if scopeErr != nil {
|
|
164
|
+
return nil, scopeErr
|
|
165
|
+
}
|
|
166
|
+
<% } %>
|
|
110
167
|
|
|
111
168
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
112
169
|
if err := query.Where("id = ?", id).First(&item).Error; err != nil {
|
|
@@ -137,6 +194,21 @@ func (s *<%= it.entityName %>Service) Create(ctx context.Context, input *dto.<%=
|
|
|
137
194
|
<%= assoc.referenceName || assoc.name %>ID: input.<%= assoc.referenceName || assoc.name %>ID,
|
|
138
195
|
<% }) %>
|
|
139
196
|
}
|
|
197
|
+
<% if (it.scope && it.scope.injectOnCreate) { %>
|
|
198
|
+
|
|
199
|
+
// The caller's tenant, never one from the body (.apsorc scopeBy).
|
|
200
|
+
_, scopeVals, scopeErr := s.scoped(ctx, s.db, "create")
|
|
201
|
+
if scopeErr != nil {
|
|
202
|
+
return nil, scopeErr
|
|
203
|
+
}
|
|
204
|
+
<% it.scope.fields.forEach((f) => { %>
|
|
205
|
+
if v, ok := scopeVals[<%~ JSON.stringify(f.column) %>]; ok {
|
|
206
|
+
if err := setFromString(&item, <%~ JSON.stringify(f.goField) %>, v); err != nil {
|
|
207
|
+
return nil, err
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
<% }) %>
|
|
211
|
+
<% } %>
|
|
140
212
|
|
|
141
213
|
if err := s.db.WithContext(ctx).Create(&item).Error; err != nil {
|
|
142
214
|
return nil, err
|
|
@@ -150,10 +222,17 @@ func (s *<%= it.entityName %>Service) Create(ctx context.Context, input *dto.<%=
|
|
|
150
222
|
func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, input *dto.<%= it.entityName %>Update) (*dto.<%= it.entityName %>Response, error) {
|
|
151
223
|
var item models.<%= it.entityName %>
|
|
152
224
|
|
|
225
|
+
findQuery := s.db.WithContext(ctx)
|
|
226
|
+
<% if (it.scope) { %>
|
|
227
|
+
findQuery, scopeVals, scopeErr := s.scoped(ctx, findQuery, "update")
|
|
228
|
+
if scopeErr != nil {
|
|
229
|
+
return nil, scopeErr
|
|
230
|
+
}
|
|
231
|
+
<% } %>
|
|
153
232
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
154
|
-
if err :=
|
|
233
|
+
if err := findQuery.Where("id = ?", id).First(&item).Error; err != nil {
|
|
155
234
|
<% } else { %>
|
|
156
|
-
if err :=
|
|
235
|
+
if err := findQuery.First(&item, id).Error; err != nil {
|
|
157
236
|
<% } %>
|
|
158
237
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
159
238
|
return nil, errors.New("<%= it.entityName %> not found")
|
|
@@ -172,6 +251,12 @@ func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, inp
|
|
|
172
251
|
updates["<%= assoc.snakeReferenceName || assoc.snakeName %>_id"] = *input.<%= assoc.referenceName || assoc.name %>ID
|
|
173
252
|
}
|
|
174
253
|
<% }) %>
|
|
254
|
+
<% if (it.scope) { %>
|
|
255
|
+
// A scoped row cannot move to another tenant.
|
|
256
|
+
for col := range scopeVals {
|
|
257
|
+
delete(updates, col)
|
|
258
|
+
}
|
|
259
|
+
<% } %>
|
|
175
260
|
|
|
176
261
|
if len(updates) > 0 {
|
|
177
262
|
if err := s.db.WithContext(ctx).Model(&item).Updates(updates).Error; err != nil {
|
|
@@ -198,10 +283,17 @@ func (s *<%= it.entityName %>Service) Update(ctx context.Context, id string, inp
|
|
|
198
283
|
|
|
199
284
|
// Delete removes a <%= it.entityName %> by ID
|
|
200
285
|
func (s *<%= it.entityName %>Service) Delete(ctx context.Context, id string) error {
|
|
286
|
+
deleteQuery := s.db.WithContext(ctx)
|
|
287
|
+
<% if (it.scope) { %>
|
|
288
|
+
deleteQuery, _, scopeErr := s.scoped(ctx, deleteQuery, "delete")
|
|
289
|
+
if scopeErr != nil {
|
|
290
|
+
return scopeErr
|
|
291
|
+
}
|
|
292
|
+
<% } %>
|
|
201
293
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
202
|
-
result :=
|
|
294
|
+
result := deleteQuery.Where("id = ?", id).Delete(&models.<%= it.entityName %>{})
|
|
203
295
|
<% } else { %>
|
|
204
|
-
result :=
|
|
296
|
+
result := deleteQuery.Delete(&models.<%= it.entityName %>{}, id)
|
|
205
297
|
<% } %>
|
|
206
298
|
if result.Error != nil {
|
|
207
299
|
return result.Error
|
|
@@ -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
|
-
|
|
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
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apso/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
9
|
+
"version": "0.46.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@apso/schema-tools": "^0.2.0",
|
package/oclif.manifest.json
CHANGED