@apso/cli 0.44.0 → 0.45.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.
Files changed (35) hide show
  1. package/dist/commands/generate.js +21 -1
  2. package/dist/commands/mcp/serve.js +30 -9
  3. package/dist/lib/generators/base.d.ts +1 -0
  4. package/dist/lib/generators/go.d.ts +2 -1
  5. package/dist/lib/generators/go.js +34 -4
  6. package/dist/lib/generators/python.d.ts +4 -2
  7. package/dist/lib/generators/python.js +28 -6
  8. package/dist/lib/generators/typescript.d.ts +2 -1
  9. package/dist/lib/generators/typescript.js +43 -6
  10. package/dist/lib/guards.d.ts +11 -1
  11. package/dist/lib/guards.js +20 -1
  12. package/dist/lib/templates/go/index-module.eta +6 -0
  13. package/dist/lib/templates/go/middleware/auth.eta +299 -59
  14. package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
  15. package/dist/lib/templates/guards/auth.guard.eta +6 -0
  16. package/dist/lib/templates/guards/guards.module.eta +11 -19
  17. package/dist/lib/templates/guards/index.eta +2 -2
  18. package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
  19. package/dist/lib/templates/guards/scope.guard.eta +20 -2
  20. package/dist/lib/templates/python/auth.eta +217 -0
  21. package/dist/lib/templates/python/index-module.eta +11 -0
  22. package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
  23. package/dist/lib/templates/python/schemas/schema.eta +22 -1
  24. package/dist/lib/templates/rest/controller-rest.eta +16 -1
  25. package/dist/lib/templates/rest/index-module-rest.eta +6 -0
  26. package/dist/lib/types/auth.d.ts +41 -7
  27. package/dist/lib/types/auth.js +61 -2
  28. package/dist/lib/types/generator.d.ts +6 -0
  29. package/dist/lib/types/index.d.ts +1 -1
  30. package/dist/lib/types/index.js +3 -1
  31. package/dist/lib/utils/crud-deps.d.ts +13 -0
  32. package/dist/lib/utils/crud-deps.js +37 -1
  33. package/npm-shrinkwrap.json +2 -2
  34. package/oclif.manifest.json +1 -1
  35. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ /**
2
+ * AuthGuard - verifies <%= it.jwt.provider %> JWTs against the issuer's JWKS
3
+ *
4
+ * Auto-generated by Apso CLI from .apsorc auth configuration
5
+ * Generated at: <%= it.generatedAt %>
6
+ *
7
+ * This guard:
8
+ * - Reads the token from the Authorization: Bearer header
9
+ * - Verifies its signature with the issuer's published keys, plus issuer,
10
+ * expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm
11
+ * - Maps the token's claims into a normalized AuthContext on request.auth
12
+ *
13
+ * AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc
14
+ * values at runtime (for example a staging and a production tenant).
15
+ *
16
+ * DO NOT EDIT - Changes will be overwritten on next scaffold
17
+ */
18
+ import {
19
+ Injectable,
20
+ CanActivate,
21
+ ExecutionContext,
22
+ UnauthorizedException,
23
+ } from '@nestjs/common';
24
+ import { Reflector } from '@nestjs/core';
25
+ import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
26
+
27
+ export const IS_PUBLIC_KEY = 'isPublic';
28
+
29
+ /**
30
+ * Decorator to mark a route as public (skips authentication)
31
+ * Usage: @Public()
32
+ */
33
+ export const Public =
34
+ () => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
35
+ Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
36
+ };
37
+
38
+ /**
39
+ * Normalized auth context attached to authenticated requests.
40
+ * This is what scopeBy and RBAC consume.
41
+ */
42
+ export interface AuthContext {
43
+ userId?: string;
44
+ email?: string;
45
+ workspaceId?: string;
46
+ organizationId?: string;
47
+ roles: string[];
48
+ claims: JWTPayload;
49
+ }
50
+
51
+ const ISSUER = process.env.AUTH_JWT_ISSUER || <%~ JSON.stringify(it.jwt.issuer) %>;
52
+ const AUDIENCE = process.env.AUTH_JWT_AUDIENCE || <%~ JSON.stringify(it.jwt.audience || '') %>;
53
+ const JWKS_URI = process.env.AUTH_JWKS_URI || <%~ JSON.stringify(it.jwt.jwksUri) %>;
54
+ const ALGORITHMS = <%~ JSON.stringify(it.jwt.algorithms) %>;
55
+ const AUTHORIZED_PARTIES = (process.env.AUTH_JWT_AUTHORIZED_PARTIES || <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>)
56
+ .split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ const CLAIMS = <%~ JSON.stringify(it.jwt.claims) %> as Record<string, string | undefined>;
60
+
61
+ /** A claim by exact name (Auth0 namespaced URLs contain dots), else by dotted path. */
62
+ function claim(payload: JWTPayload, path?: string): unknown {
63
+ if (!path) return undefined;
64
+ if (path in payload) return (payload as any)[path];
65
+ return path.split('.').reduce((v: any, k) => (v == null ? undefined : v[k]), payload);
66
+ }
67
+
68
+ const str = (v: unknown): string | undefined =>
69
+ typeof v === 'string' || typeof v === 'number' ? String(v) : undefined;
70
+
71
+ function roles(v: unknown): string[] {
72
+ if (Array.isArray(v)) return v.map(String);
73
+ return typeof v === 'string' && v ? [v] : [];
74
+ }
75
+
76
+ @Injectable()
77
+ export class AuthGuard implements CanActivate {
78
+ private readonly jwks = createRemoteJWKSet(new URL(JWKS_URI));
79
+
80
+ constructor(private reflector: Reflector) {}
81
+
82
+ async canActivate(context: ExecutionContext): Promise<boolean> {
83
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
84
+ context.getHandler(),
85
+ context.getClass(),
86
+ ]);
87
+ if (isPublic) {
88
+ return true;
89
+ }
90
+
91
+ // The template's health check stays public for load balancer probes.
92
+ const req = context.switchToHttp().getRequest();
93
+ if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
94
+ return true;
95
+ }
96
+
97
+ const request = context.switchToHttp().getRequest();
98
+ const header: string | undefined = request.headers.authorization;
99
+ if (!header || !header.startsWith('Bearer ')) {
100
+ throw new UnauthorizedException('No bearer token provided');
101
+ }
102
+
103
+ let payload: JWTPayload;
104
+ try {
105
+ ({ payload } = await jwtVerify(header.substring(7), this.jwks, {
106
+ issuer: ISSUER,
107
+ algorithms: ALGORITHMS,
108
+ <% if (it.jwt.provider !== 'cognito') { %>
109
+ audience: AUDIENCE || undefined,
110
+ <% } %>
111
+ }));
112
+ } catch {
113
+ throw new UnauthorizedException('Invalid token');
114
+ }
115
+ <% if (it.jwt.provider === 'cognito') { %>
116
+ // Cognito ID tokens carry aud; access tokens carry client_id instead.
117
+ if (AUDIENCE) {
118
+ const aud = payload.aud ?? (payload as any).client_id;
119
+ if (!(Array.isArray(aud) ? aud.includes(AUDIENCE) : aud === AUDIENCE)) {
120
+ throw new UnauthorizedException('Invalid token');
121
+ }
122
+ }
123
+ <% } %>
124
+
125
+ const azp = (payload as any).azp;
126
+ if (AUTHORIZED_PARTIES.length > 0 && azp !== undefined && !AUTHORIZED_PARTIES.includes(azp)) {
127
+ throw new UnauthorizedException('Invalid token');
128
+ }
129
+
130
+ const userId = str(claim(payload, CLAIMS.userId));
131
+ if (!userId) {
132
+ throw new UnauthorizedException('Token has no user id');
133
+ }
134
+ const tenant =
135
+ str(claim(payload, CLAIMS.workspaceId)) ??
136
+ str(claim(payload, CLAIMS.organizationId));
137
+ const auth: AuthContext = {
138
+ userId,
139
+ email: str(claim(payload, CLAIMS.email)),
140
+ workspaceId: tenant,
141
+ organizationId: tenant,
142
+ roles: roles(claim(payload, CLAIMS.roles)),
143
+ claims: payload,
144
+ };
145
+
146
+ request.auth = auth;
147
+ request.user = { id: userId, ...auth };
148
+ // Read by ScopeGuard's scope lookup.
149
+ request.workspaceId = tenant;
150
+ request.organizationId = tenant;
151
+ return true;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Helper function to get AuthContext from request
157
+ * Use this in controllers/services to access the authenticated context
158
+ */
159
+ export function getAuthContext(request: any): AuthContext | undefined {
160
+ return request?.auth;
161
+ }
162
+
163
+ /**
164
+ * Helper function to require AuthContext (throws if not present)
165
+ */
166
+ export function requireAuthContext(request: any): AuthContext {
167
+ const ctx = getAuthContext(request);
168
+ if (!ctx) {
169
+ throw new UnauthorizedException('Authentication required');
170
+ }
171
+ return ctx;
172
+ }
@@ -28,7 +28,7 @@ export const SkipScopeCheck = () =>
28
28
  );
29
29
  };
30
30
 
31
- <% if (it.authConfig) { %>
31
+ <% if (it.hasAuth) { %>
32
32
  /**
33
33
  * Import Public decorator from auth.guard to avoid duplicate exports
34
34
  */
@@ -246,7 +246,11 @@ export class ScopeGuard implements CanActivate {
246
246
  for (const scope of scopeConfig.scopes) {
247
247
  const expectedValue = this.getScopeValue(request, scope.contextKey);
248
248
  if (!expectedValue) {
249
+ <% if (it.hasAuth) { %>
250
+ return false; // The caller has no tenant, so it owns no scoped rows
251
+ <% } else { %>
249
252
  continue; // No scope value in context, skip this check
253
+ <% } %>
250
254
  }
251
255
 
252
256
  let actualValue: any;
@@ -257,7 +261,8 @@ export class ScopeGuard implements CanActivate {
257
261
  actualValue = this.getNestedValue(entity, scope.path);
258
262
  }
259
263
 
260
- if (actualValue !== expectedValue) {
264
+ // Token claims are strings; a numeric FK column reads back as a number.
265
+ if (actualValue == null || String(actualValue) !== String(expectedValue)) {
261
266
  return false;
262
267
  }
263
268
  }
@@ -277,9 +282,17 @@ export class ScopeGuard implements CanActivate {
277
282
  for (const scope of scopeConfig.scopes) {
278
283
  if (scope.direct) {
279
284
  const scopeValue = this.getScopeValue(request, scope.contextKey);
285
+ <% if (it.hasAuth) { %>
286
+ if (!scopeValue) {
287
+ throw new ForbiddenException('No tenant in the auth context');
288
+ }
289
+ // Always the caller's tenant, never one from the body.
290
+ request.body[scope.field] = scopeValue;
291
+ <% } else { %>
280
292
  if (scopeValue && !request.body[scope.field]) {
281
293
  request.body[scope.field] = scopeValue;
282
294
  }
295
+ <% } %>
283
296
  }
284
297
  }
285
298
  }
@@ -292,6 +305,11 @@ export class ScopeGuard implements CanActivate {
292
305
  for (const scope of scopeConfig.scopes) {
293
306
  if (scope.direct) {
294
307
  const scopeValue = this.getScopeValue(request, scope.contextKey);
308
+ <% if (it.hasAuth) { %>
309
+ if (!scopeValue) {
310
+ throw new ForbiddenException('No tenant in the auth context');
311
+ }
312
+ <% } %>
295
313
  if (scopeValue) {
296
314
  // Add filter for @apso/crud
297
315
  request.query[`filter.${scope.field}`] = `$eq:${scopeValue}`;
@@ -0,0 +1,217 @@
1
+ <%~ includeFile('./header.eta', it) %>
2
+
3
+ """
4
+ Authentication for every route on the generated /api router.
5
+
6
+ <% if (it.jwt) { %>
7
+ Verifies <%= it.jwt.provider %> JWTs from the Authorization: Bearer header against the
8
+ issuer's published keys (JWKS), checks issuer, expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm,
9
+ and maps the token's claims into an AuthContext on request.state.auth.
10
+ AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc values.
11
+ <% } else { %>
12
+ Reads an API key from the <%= it.apiKey.apiKeyHeader %> header and looks up the <%= it.apiKey.apiKeyEntity %> row
13
+ whose <%= it.apiKey.keyField %> is the key's hex SHA-256.
14
+ <% } %>
15
+ """
16
+ <% if (it.apiKey) { %>
17
+ import hashlib
18
+ from datetime import date, datetime, timezone
19
+ <% } else { %>
20
+ import os
21
+ <% } %>
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, List, Optional
24
+
25
+ <% if (it.jwt) { %>
26
+ import jwt
27
+ from fastapi import HTTPException, Request, status
28
+ from jwt import PyJWKClient
29
+ from starlette.concurrency import run_in_threadpool
30
+ <% } else { %>
31
+ from fastapi import Depends, HTTPException, Request, status
32
+ from sqlalchemy import select
33
+ from sqlalchemy.ext.asyncio import AsyncSession
34
+
35
+ from app.database import get_db
36
+ from .models.<%= it.apiKey.pyModule %> import <%= it.apiKey.apiKeyEntity %>
37
+ <% } %>
38
+
39
+
40
+ @dataclass
41
+ class AuthContext:
42
+ """The normalized identity of the caller."""
43
+
44
+ user_id: Optional[str] = None
45
+ email: Optional[str] = None
46
+ service_id: Optional[str] = None
47
+ workspace_id: Optional[str] = None
48
+ organization_id: Optional[str] = None
49
+ roles: List[str] = field(default_factory=list)
50
+ claims: dict = field(default_factory=dict)
51
+
52
+
53
+ def get_auth_context(request: Request) -> Optional[AuthContext]:
54
+ """The authenticated caller, if any."""
55
+ return getattr(request.state, "auth", None)
56
+
57
+
58
+ def _unauthorized(detail: str) -> HTTPException:
59
+ return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
60
+ <% if (it.jwt) { %>
61
+
62
+
63
+ ISSUER = os.getenv("AUTH_JWT_ISSUER") or <%~ JSON.stringify(it.jwt.issuer) %>
64
+ AUDIENCE = os.getenv("AUTH_JWT_AUDIENCE") or <%~ JSON.stringify(it.jwt.audience || '') %>
65
+ JWKS_URI = os.getenv("AUTH_JWKS_URI") or <%~ JSON.stringify(it.jwt.jwksUri) %>
66
+ ALGORITHMS = <%~ JSON.stringify(it.jwt.algorithms) %>
67
+ # Origins allowed in the azp claim; empty allows any.
68
+ AUTHORIZED_PARTIES = [
69
+ p.strip()
70
+ for p in (os.getenv("AUTH_JWT_AUTHORIZED_PARTIES") or <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>).split(",")
71
+ if p.strip()
72
+ ]
73
+ CLAIMS = <%~ JSON.stringify(it.jwt.claims) %>
74
+
75
+ # Fetches and caches the issuer's keys on first use.
76
+ _jwks = PyJWKClient(JWKS_URI, cache_keys=True)
77
+
78
+
79
+ def _claim(payload: dict, path: Optional[str]) -> Any:
80
+ """A claim by exact name (Auth0 namespaced URLs contain dots), else by dotted path."""
81
+ if not path:
82
+ return None
83
+ if path in payload:
84
+ return payload[path]
85
+ value: Any = payload
86
+ for part in path.split("."):
87
+ if not isinstance(value, dict):
88
+ return None
89
+ value = value.get(part)
90
+ return value
91
+
92
+
93
+ def _str(value: Any) -> Optional[str]:
94
+ if isinstance(value, bool):
95
+ return None
96
+ if isinstance(value, (str, int, float)):
97
+ return str(value)
98
+ return None
99
+
100
+
101
+ def _roles(value: Any) -> List[str]:
102
+ if isinstance(value, list):
103
+ return [str(v) for v in value]
104
+ return [value] if isinstance(value, str) and value else []
105
+
106
+
107
+ async def require_auth(request: Request) -> AuthContext:
108
+ """FastAPI dependency: verify the bearer token and attach the AuthContext."""
109
+ header = request.headers.get("authorization", "")
110
+ if not header.startswith("Bearer "):
111
+ raise _unauthorized("No bearer token provided")
112
+ token = header[len("Bearer "):]
113
+ try:
114
+ signing_key = await run_in_threadpool(_jwks.get_signing_key_from_jwt, token)
115
+ except jwt.PyJWKClientConnectionError:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
118
+ detail="Token signing keys unavailable",
119
+ )
120
+ except jwt.PyJWTError:
121
+ raise _unauthorized("Invalid token")
122
+ try:
123
+ payload = jwt.decode(
124
+ token,
125
+ signing_key.key,
126
+ algorithms=ALGORITHMS,
127
+ issuer=ISSUER,
128
+ <% if (it.jwt.provider !== 'cognito') { %>
129
+ audience=AUDIENCE or None,
130
+ options={"require": ["exp", "iss"], "verify_aud": bool(AUDIENCE)},
131
+ <% } else { %>
132
+ options={"require": ["exp", "iss"], "verify_aud": False},
133
+ <% } %>
134
+ )
135
+ except jwt.PyJWTError:
136
+ raise _unauthorized("Invalid token")
137
+ <% if (it.jwt.provider === 'cognito') { %>
138
+ # Cognito ID tokens carry aud; access tokens carry client_id instead.
139
+ if AUDIENCE:
140
+ aud = payload.get("aud", payload.get("client_id"))
141
+ if not (AUDIENCE in aud if isinstance(aud, list) else aud == AUDIENCE):
142
+ raise _unauthorized("Invalid token")
143
+ <% } %>
144
+
145
+ azp = payload.get("azp")
146
+ if AUTHORIZED_PARTIES and azp is not None and azp not in AUTHORIZED_PARTIES:
147
+ raise _unauthorized("Invalid token")
148
+
149
+ user_id = _str(_claim(payload, CLAIMS.get("userId")))
150
+ if not user_id:
151
+ raise _unauthorized("Token has no user id")
152
+ tenant = _str(_claim(payload, CLAIMS.get("workspaceId"))) or _str(
153
+ _claim(payload, CLAIMS.get("organizationId"))
154
+ )
155
+ ctx = AuthContext(
156
+ user_id=user_id,
157
+ email=_str(_claim(payload, CLAIMS.get("email"))),
158
+ workspace_id=tenant,
159
+ organization_id=tenant,
160
+ roles=_roles(_claim(payload, CLAIMS.get("roles"))),
161
+ claims=payload,
162
+ )
163
+ request.state.auth = ctx
164
+ return ctx
165
+ <% } else { %>
166
+
167
+
168
+ def _expired(value: Any) -> bool:
169
+ if value is None:
170
+ return False
171
+ if isinstance(value, datetime):
172
+ if value.tzinfo is None:
173
+ value = value.replace(tzinfo=timezone.utc)
174
+ return value <= datetime.now(timezone.utc)
175
+ if isinstance(value, date):
176
+ return value <= date.today()
177
+ return False
178
+
179
+
180
+ async def require_auth(request: Request, db: AsyncSession = Depends(get_db)) -> AuthContext:
181
+ """FastAPI dependency: authenticate the API key and attach the AuthContext."""
182
+ key = request.headers.get(<%~ JSON.stringify(it.apiKey.apiKeyHeader) %>)
183
+ if not key:
184
+ raise _unauthorized("No API key provided")
185
+ digest = hashlib.sha256(key.encode()).hexdigest()
186
+ result = await db.execute(
187
+ select(<%= it.apiKey.apiKeyEntity %>).where(<%= it.apiKey.apiKeyEntity %>.<%= it.apiKey.keyField %> == digest)
188
+ )
189
+ record = result.scalar_one_or_none()
190
+ if record is None:
191
+ raise _unauthorized("Invalid API key")
192
+ <% if (it.apiKey.hasStatus) { %>
193
+ if getattr(record.status, "value", record.status) != "active":
194
+ raise _unauthorized("API key is not active")
195
+ <% } %>
196
+ <% if (it.apiKey.hasExpiresAt) { %>
197
+ if _expired(record.expiresAt):
198
+ raise _unauthorized("API key has expired")
199
+ <% } %>
200
+
201
+ tenant = getattr(record, <%~ JSON.stringify(it.apiKey.workspaceField) %>, None)
202
+ tenant = str(tenant) if tenant is not None else None
203
+ <% if (it.apiKey.roleField) { %>
204
+ role = getattr(record, <%~ JSON.stringify(it.apiKey.roleField) %>, None)
205
+ roles = [str(getattr(role, "value", role))] if role is not None else []
206
+ <% } else { %>
207
+ roles: List[str] = []
208
+ <% } %>
209
+ ctx = AuthContext(
210
+ service_id=str(record.id),
211
+ workspace_id=tenant,
212
+ organization_id=tenant,
213
+ roles=roles,
214
+ )
215
+ request.state.auth = ctx
216
+ return ctx
217
+ <% } %>
@@ -1,13 +1,24 @@
1
1
  <%~ includeFile('./header.eta', it) %>
2
2
 
3
+ <% if (it.hasAuth) { %>
4
+ from fastapi import APIRouter, Depends
5
+
6
+ from .auth import require_auth
7
+ <% } else { %>
3
8
  from fastapi import APIRouter
9
+ <% } %>
4
10
 
5
11
  <% it.entities.forEach((entity) => { %>
6
12
  from .routers.<%= entity.name.toLowerCase() %> import router as <%= entity.name.toLowerCase() %>_router
7
13
  <% }) %>
8
14
 
9
15
  # Create main router for autogenerated endpoints
16
+ <% if (it.hasAuth) { %>
17
+ # Every route on this router requires authentication (.apsorc auth).
18
+ autogen_router = APIRouter(prefix="/api", dependencies=[Depends(require_auth)])
19
+ <% } else { %>
10
20
  autogen_router = APIRouter(prefix="/api")
21
+ <% } %>
11
22
 
12
23
  # Include all entity routers
13
24
  <% it.entities.forEach((entity) => { %>
@@ -10,7 +10,7 @@ const pgEnumName = (it.entityName + '_' + it.field.name + '_enum').toLowerCase()
10
10
  enums.<%= enumTypeName %>,
11
11
  name="<%= pgEnumName %>",
12
12
  values_callable=lambda x: [e.value for e in x],
13
- create_type=False
13
+ create_type=True
14
14
  ),
15
15
  nullable=<%= it.field.nullable === false ? 'False' : 'True' %>
16
16
  )
@@ -5,12 +5,23 @@ from typing import Optional, List, Any
5
5
  from datetime import datetime, date
6
6
  from decimal import Decimal
7
7
  from uuid import UUID
8
- from pydantic import BaseModel, Field, ConfigDict
8
+ from pydantic import BaseModel, Field, ConfigDict, field_serializer
9
+ from sqlalchemy import inspect as sa_inspect
9
10
  <% if (it.importEnums) { -%>
10
11
  from ..models import enums
11
12
  <% } -%>
12
13
 
13
14
 
15
+
16
+ def _columns(value: Any) -> Any:
17
+ """A related ORM row as its column values; its own relations are not loaded."""
18
+ if isinstance(value, list):
19
+ return [_columns(v) for v in value]
20
+ if not hasattr(value, "__table__"):
21
+ return value
22
+ return {a.key: getattr(value, a.key) for a in sa_inspect(value).mapper.column_attrs}
23
+
24
+
14
25
  class <%= it.entityName %>Base(BaseModel):
15
26
  """Base schema with common fields for <%= it.entityName %>"""
16
27
  <% it.columns.filter(c => !['id', 'createdAt', 'updatedAt', 'created_at', 'updated_at'].includes(c.name)).forEach((field) => { -%>
@@ -76,6 +87,16 @@ class <%= it.entityName %>(BaseModel):
76
87
  <% it.associations.filter(a => a.type === 'ManyToOne' || a.type === 'OneToOne').forEach((assoc) => { -%>
77
88
  <%= assoc.relationshipName %>: Optional[Any] = None
78
89
  <% }); -%>
90
+ <% const relationFields = [
91
+ ...it.associations.filter(a => a.type === 'OneToMany' || a.type === 'ManyToMany').map(a => a.pluralizedRelationshipName),
92
+ ...it.associations.filter(a => a.type === 'ManyToOne' || a.type === 'OneToOne').map(a => a.relationshipName),
93
+ ]; -%>
94
+ <% if (relationFields.length > 0) { -%>
95
+
96
+ @field_serializer(<%~ [...new Set(relationFields)].map(n => JSON.stringify(n)).join(', ') %>)
97
+ def _serialize_relations(self, value: Any) -> Any:
98
+ return _columns(value)
99
+ <% } -%>
79
100
 
80
101
 
81
102
  class <%= it.entityName %>List(BaseModel):
@@ -1,7 +1,7 @@
1
1
  <%~ includeFile('./header.eta') %>
2
2
 
3
3
  import { Controller, Body } from "@nestjs/common";
4
- import { Crud, CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
4
+ import { Crud, <% if (it.crudAuth.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
5
5
  import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
6
6
  import {<%= it.entityName %>, <%= it.entityName %>Create, <%= it.entityName %>Update} from './dtos/<%= it.entityName%>.dto'
7
7
  import {<%= it.svcName %>} from './<%= it.entityName %>.service';
@@ -51,6 +51,21 @@ import {<%= it.svcName %>} from './<%= it.entityName %>.service';
51
51
  <% } %>
52
52
  }
53
53
  })
54
+ <% if (it.crudAuth.length) { %>
55
+ // Every query and write is limited to the caller's tenant (.apsorc scopeBy).
56
+ @CrudAuth({
57
+ filter: (req: any) => ({
58
+ <% it.crudAuth.forEach((s) => { %>
59
+ <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
60
+ <% }) %>
61
+ }),
62
+ persist: (req: any) => ({
63
+ <% it.crudAuth.forEach((s) => { %>
64
+ <%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
65
+ <% }) %>
66
+ }),
67
+ })
68
+ <% } %>
54
69
 
55
70
  @Controller('<%= it.pluralEntityName %>')
56
71
  @ApiTags('<%= it.pluralEntityName %>')
@@ -3,9 +3,15 @@
3
3
  <% it.entities.forEach((entity) => { %>
4
4
  import {<%= entity.name %>Module} from './<%= entity.name %>/<%= entity.name %>.module'
5
5
  <%}) %>
6
+ <% if (it.hasGuards) { %>
7
+ import { GuardsModule } from './guards/guards.module'
8
+ <% } %>
6
9
 
7
10
  export default [
8
11
  <% it.entities.forEach((entity) => { %>
9
12
  <%= entity.name %>Module,
10
13
  <%}) %>
14
+ <% if (it.hasGuards) { %>
15
+ GuardsModule,
16
+ <% } %>
11
17
  ]
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Supported authentication provider types
3
3
  */
4
- export type AuthProviderType = "better-auth" | "custom-db-session" | "auth0" | "clerk" | "cognito" | "api-key";
4
+ export type AuthProviderType = "better-auth" | "custom-db-session" | "auth0" | "clerk" | "cognito" | "jwt" | "api-key";
5
5
  /**
6
6
  * Base configuration common to all auth providers
7
7
  */
@@ -50,17 +50,25 @@ export interface JwtConfig {
50
50
  */
51
51
  issuer: string;
52
52
  /**
53
- * Expected audience for the JWT
53
+ * Expected audience for the JWT. When omitted the audience is not checked
54
+ * (Clerk session tokens and Cognito access tokens carry no `aud` claim).
55
+ * For Cognito an access token's `client_id` is checked against it instead.
54
56
  */
55
- audience: string;
57
+ audience?: string;
56
58
  /**
57
59
  * JWKS URI for key rotation. If not provided, uses issuer + /.well-known/jwks.json
58
60
  */
59
61
  jwksUri?: string;
60
62
  /**
61
- * Algorithms to accept. Default: ["RS256"]
63
+ * Algorithms to accept. Default: ["RS256"]. Better Auth's JWT plugin signs
64
+ * with EdDSA by default.
62
65
  */
63
66
  algorithms?: string[];
67
+ /**
68
+ * Origins allowed in the token's `azp` claim (Clerk recommends this). A
69
+ * token whose azp is not listed is rejected; one without azp is accepted.
70
+ */
71
+ authorizedParties?: string[];
64
72
  }
65
73
  /**
66
74
  * Claim mapping for JWT providers
@@ -88,10 +96,11 @@ export interface JwtClaimsMapping {
88
96
  email?: string;
89
97
  }
90
98
  /**
91
- * Configuration for JWT-based providers (Auth0, Cognito, Clerk)
99
+ * Configuration for JWT-based providers (Auth0, Clerk, Cognito, or any issuer
100
+ * that publishes a JWKS via the generic "jwt" provider)
92
101
  */
93
102
  export interface JwtAuthConfig extends BaseAuthConfig {
94
- provider: "auth0" | "clerk" | "cognito";
103
+ provider: "auth0" | "clerk" | "cognito" | "jwt";
95
104
  /**
96
105
  * JWT verification configuration
97
106
  */
@@ -119,7 +128,12 @@ export interface ApiKeyAuthConfig extends BaseAuthConfig {
119
128
  */
120
129
  apiKeyEntity?: string;
121
130
  /**
122
- * Field name on apiKeyEntity for workspace/organization ID
131
+ * Field on apiKeyEntity holding the hex SHA-256 of the key. Default: "keyHash"
132
+ */
133
+ keyField?: string;
134
+ /**
135
+ * Field name on apiKeyEntity for workspace/organization ID.
136
+ * Default: "organizationId"
123
137
  */
124
138
  workspaceField?: string;
125
139
  /**
@@ -201,3 +215,23 @@ export declare function isJwtAuth(auth: AuthConfig): auth is JwtAuthConfig;
201
215
  * @returns {boolean} True if the auth config is API key based
202
216
  */
203
217
  export declare function isApiKeyAuth(auth: AuthConfig): auth is ApiKeyAuthConfig;
218
+ /** A JWT auth config with every default filled in, as the templates use it. */
219
+ export interface ResolvedJwtAuth {
220
+ provider: JwtAuthConfig["provider"];
221
+ issuer: string;
222
+ audience?: string;
223
+ jwksUri: string;
224
+ algorithms: string[];
225
+ authorizedParties: string[];
226
+ claims: Required<Pick<JwtClaimsMapping, "userId">> & Omit<JwtClaimsMapping, "userId">;
227
+ }
228
+ /** An API key auth config with every default filled in. */
229
+ export type ResolvedApiKeyAuth = Required<Omit<ApiKeyAuthConfig, "roleField">> & Pick<ApiKeyAuthConfig, "roleField">;
230
+ /**
231
+ * Fill JWT defaults and reject configs that would generate an unsafe check.
232
+ * Only asymmetric algorithms are accepted: the key comes from the issuer's
233
+ * JWKS, so there is no shared secret to configure or leak.
234
+ */
235
+ export declare function resolveJwtAuth(auth: JwtAuthConfig): ResolvedJwtAuth;
236
+ /** Fill API key defaults. */
237
+ export declare function resolveApiKeyAuth(auth: ApiKeyAuthConfig): ResolvedApiKeyAuth;