@apso/cli 0.43.4 → 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.
- package/README.md +7 -11
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/generate.js +21 -1
- package/dist/commands/mcp/serve.js +30 -9
- package/dist/commands/migrate/generate.d.ts +3 -1
- package/dist/commands/migrate/generate.js +42 -8
- package/dist/commands/migrate/plan.d.ts +12 -0
- package/dist/commands/migrate/plan.js +88 -0
- package/dist/commands/migrate/sandbox.d.ts +11 -0
- package/dist/commands/migrate/sandbox.js +132 -0
- package/dist/commands/migrate/verify.d.ts +5 -2
- package/dist/commands/migrate/verify.js +44 -9
- package/dist/commands/migrate.d.ts +16 -3
- package/dist/commands/migrate.js +171 -96
- package/dist/lib/ddl/catalog.d.ts +21 -0
- package/dist/lib/ddl/catalog.js +58 -0
- package/dist/lib/ddl/change.d.ts +19 -0
- package/dist/lib/ddl/change.js +393 -0
- package/dist/lib/ddl/index.d.ts +80 -0
- package/dist/lib/ddl/index.js +309 -0
- package/dist/lib/ddl/migration-file.d.ts +42 -0
- package/dist/lib/ddl/migration-file.js +222 -0
- package/dist/lib/generators/base.d.ts +1 -0
- package/dist/lib/generators/go.d.ts +2 -1
- package/dist/lib/generators/go.js +37 -6
- package/dist/lib/generators/python.d.ts +4 -2
- package/dist/lib/generators/python.js +31 -8
- package/dist/lib/generators/typescript.d.ts +2 -1
- package/dist/lib/generators/typescript.js +43 -6
- package/dist/lib/guards.d.ts +11 -1
- package/dist/lib/guards.js +20 -1
- package/dist/lib/plan/baseline.d.ts +22 -0
- package/dist/lib/plan/baseline.js +100 -0
- package/dist/lib/plan/index.d.ts +52 -0
- package/dist/lib/plan/index.js +272 -0
- package/dist/lib/plan/prompts.d.ts +18 -0
- package/dist/lib/plan/prompts.js +77 -0
- package/dist/lib/templates/go/index-module.eta +6 -0
- package/dist/lib/templates/go/middleware/auth.eta +299 -59
- package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
- package/dist/lib/templates/guards/auth.guard.eta +6 -0
- package/dist/lib/templates/guards/guards.module.eta +11 -19
- package/dist/lib/templates/guards/index.eta +2 -2
- package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
- package/dist/lib/templates/guards/scope.guard.eta +20 -2
- package/dist/lib/templates/python/auth.eta +217 -0
- package/dist/lib/templates/python/index-module.eta +11 -0
- package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
- package/dist/lib/templates/python/schemas/schema.eta +22 -1
- package/dist/lib/templates/rest/controller-rest.eta +16 -1
- package/dist/lib/templates/rest/index-module-rest.eta +6 -0
- package/dist/lib/types/auth.d.ts +41 -7
- package/dist/lib/types/auth.js +61 -2
- package/dist/lib/types/generator.d.ts +6 -0
- package/dist/lib/types/index.d.ts +1 -1
- package/dist/lib/types/index.js +3 -1
- package/dist/lib/utils/crud-deps.d.ts +13 -0
- package/dist/lib/utils/crud-deps.js +37 -1
- package/dist/lib/verify/index.d.ts +27 -9
- package/dist/lib/verify/index.js +249 -21
- package/dist/lib/verify/review.d.ts +14 -0
- package/dist/lib/verify/review.js +98 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +176 -23
- package/package.json +1 -1
|
@@ -1,100 +1,340 @@
|
|
|
1
|
-
<%~ includeFile('
|
|
1
|
+
<%~ includeFile('../header.eta', it) %>
|
|
2
2
|
|
|
3
3
|
package middleware
|
|
4
4
|
|
|
5
|
+
// AuthMiddleware authenticates every route registered on the /api group.
|
|
6
|
+
// Routes registered outside that group (such as /health) stay public.
|
|
7
|
+
<% if (it.jwt) { %>
|
|
8
|
+
//
|
|
9
|
+
// It verifies <%= it.jwt.provider %> JWTs from the Authorization: Bearer header against the
|
|
10
|
+
// issuer's published keys (JWKS), checks issuer, expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm,
|
|
11
|
+
// and maps the token's claims into an AuthContext.
|
|
12
|
+
// AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc values.
|
|
13
|
+
<% } else { %>
|
|
14
|
+
//
|
|
15
|
+
// It reads an API key from the <%= it.apiKey.apiKeyHeader %> header and looks up the
|
|
16
|
+
// <%= it.apiKey.apiKeyEntity %> row whose <%= it.apiKey.keyField %> is the key's hex SHA-256.
|
|
17
|
+
<% } %>
|
|
18
|
+
|
|
5
19
|
import (
|
|
20
|
+
<% if (it.jwt) { %>
|
|
21
|
+
"context"
|
|
22
|
+
"encoding/json"
|
|
23
|
+
<% } else { %>
|
|
24
|
+
"crypto/sha256"
|
|
25
|
+
"encoding/hex"
|
|
26
|
+
"fmt"
|
|
27
|
+
<% } %>
|
|
6
28
|
"net/http"
|
|
29
|
+
<% if (it.jwt) { %>
|
|
30
|
+
"os"
|
|
31
|
+
"strconv"
|
|
7
32
|
"strings"
|
|
33
|
+
"sync"
|
|
34
|
+
<% } else if (it.apiKey.hasExpiresAt) { %>
|
|
35
|
+
"time"
|
|
36
|
+
<% } %>
|
|
8
37
|
|
|
38
|
+
<% if (it.jwt) { %>
|
|
39
|
+
"github.com/MicahParks/keyfunc/v3"
|
|
40
|
+
<% } %>
|
|
9
41
|
"github.com/gin-gonic/gin"
|
|
42
|
+
<% if (it.jwt) { %>
|
|
43
|
+
"github.com/golang-jwt/jwt/v5"
|
|
44
|
+
<% } %>
|
|
45
|
+
"gorm.io/gorm"
|
|
46
|
+
<% if (it.apiKey) { %>
|
|
47
|
+
|
|
48
|
+
"app/autogen/models"
|
|
49
|
+
<% } %>
|
|
10
50
|
)
|
|
11
51
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
// AuthContext is the normalized identity of the caller.
|
|
53
|
+
type AuthContext struct {
|
|
54
|
+
UserID string `json:"userId,omitempty"`
|
|
55
|
+
Email string `json:"email,omitempty"`
|
|
56
|
+
ServiceID string `json:"serviceId,omitempty"`
|
|
57
|
+
WorkspaceID string `json:"workspaceId,omitempty"`
|
|
58
|
+
OrganizationID string `json:"organizationId,omitempty"`
|
|
59
|
+
Roles []string `json:"roles"`
|
|
60
|
+
Claims map[string]interface{} `json:"claims,omitempty"`
|
|
61
|
+
}
|
|
20
62
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
63
|
+
// AuthContextKey is the gin context key holding *AuthContext.
|
|
64
|
+
const AuthContextKey = "auth"
|
|
65
|
+
|
|
66
|
+
// GetAuthContext returns the authenticated caller, if any.
|
|
67
|
+
func GetAuthContext(c *gin.Context) (*AuthContext, bool) {
|
|
68
|
+
v, ok := c.Get(AuthContextKey)
|
|
69
|
+
if !ok {
|
|
70
|
+
return nil, false
|
|
71
|
+
}
|
|
72
|
+
a, ok := v.(*AuthContext)
|
|
73
|
+
return a, ok
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func unauthorized(c *gin.Context, msg string) {
|
|
77
|
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": msg})
|
|
78
|
+
}
|
|
79
|
+
<% if (it.jwt) { %>
|
|
80
|
+
|
|
81
|
+
func splitList(s string) []string {
|
|
82
|
+
out := []string{}
|
|
83
|
+
for _, p := range strings.Split(s, ",") {
|
|
84
|
+
if p = strings.TrimSpace(p); p != "" {
|
|
85
|
+
out = append(out, p)
|
|
25
86
|
}
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
26
90
|
|
|
27
|
-
|
|
91
|
+
func env(name, fallback string) string {
|
|
92
|
+
if v := os.Getenv(name); v != "" {
|
|
93
|
+
return v
|
|
94
|
+
}
|
|
95
|
+
return fallback
|
|
96
|
+
}
|
|
28
97
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
98
|
+
var (
|
|
99
|
+
issuer = env("AUTH_JWT_ISSUER", <%~ JSON.stringify(it.jwt.issuer) %>)
|
|
100
|
+
audience = env("AUTH_JWT_AUDIENCE", <%~ JSON.stringify(it.jwt.audience || '') %>)
|
|
101
|
+
jwksURI = env("AUTH_JWKS_URI", <%~ JSON.stringify(it.jwt.jwksUri) %>)
|
|
102
|
+
algorithms = []string{<%~ it.jwt.algorithms.map((a) => JSON.stringify(a)).join(', ') %>}
|
|
103
|
+
// Origins allowed in the azp claim; empty allows any.
|
|
104
|
+
authorizedParties = splitList(env("AUTH_JWT_AUTHORIZED_PARTIES", <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>))
|
|
105
|
+
|
|
106
|
+
jwksMu sync.Mutex
|
|
107
|
+
jwks keyfunc.Keyfunc
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// keys fetches the issuer's JWKS on first use, retrying on later requests if
|
|
111
|
+
// the issuer was unreachable. keyfunc refreshes the set in the background.
|
|
112
|
+
func keys() (keyfunc.Keyfunc, error) {
|
|
113
|
+
jwksMu.Lock()
|
|
114
|
+
defer jwksMu.Unlock()
|
|
115
|
+
if jwks != nil {
|
|
116
|
+
return jwks, nil
|
|
117
|
+
}
|
|
118
|
+
k, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksURI})
|
|
119
|
+
if err != nil {
|
|
120
|
+
return nil, err
|
|
121
|
+
}
|
|
122
|
+
jwks = k
|
|
123
|
+
return k, nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// claim reads a claim by exact name (Auth0 namespaced URLs contain dots),
|
|
127
|
+
// else by dotted path into nested objects.
|
|
128
|
+
func claim(m map[string]interface{}, path string) interface{} {
|
|
129
|
+
if path == "" {
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
if v, ok := m[path]; ok {
|
|
133
|
+
return v
|
|
134
|
+
}
|
|
135
|
+
var cur interface{} = m
|
|
136
|
+
for _, part := range strings.Split(path, ".") {
|
|
137
|
+
obj, ok := cur.(map[string]interface{})
|
|
138
|
+
if !ok {
|
|
139
|
+
return nil
|
|
34
140
|
}
|
|
141
|
+
cur = obj[part]
|
|
142
|
+
}
|
|
143
|
+
return cur
|
|
144
|
+
}
|
|
35
145
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
146
|
+
func str(v interface{}) string {
|
|
147
|
+
switch t := v.(type) {
|
|
148
|
+
case string:
|
|
149
|
+
return t
|
|
150
|
+
case float64:
|
|
151
|
+
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
152
|
+
case json.Number:
|
|
153
|
+
return t.String()
|
|
154
|
+
}
|
|
155
|
+
return ""
|
|
156
|
+
}
|
|
39
157
|
|
|
40
|
-
|
|
158
|
+
func contains(list []string, s string) bool {
|
|
159
|
+
for _, v := range list {
|
|
160
|
+
if v == s {
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func roles(v interface{}) []string {
|
|
168
|
+
out := []string{}
|
|
169
|
+
switch t := v.(type) {
|
|
170
|
+
case []interface{}:
|
|
171
|
+
for _, r := range t {
|
|
172
|
+
if s := str(r); s != "" {
|
|
173
|
+
out = append(out, s)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
case string:
|
|
177
|
+
if t != "" {
|
|
178
|
+
out = append(out, t)
|
|
179
|
+
}
|
|
41
180
|
}
|
|
181
|
+
return out
|
|
42
182
|
}
|
|
43
183
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
func RoleMiddleware(allowedRoles ...string) gin.HandlerFunc {
|
|
184
|
+
// AuthMiddleware verifies the caller's JWT. The database is unused for JWT auth.
|
|
185
|
+
func AuthMiddleware(_ *gorm.DB) gin.HandlerFunc {
|
|
47
186
|
return func(c *gin.Context) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
187
|
+
header := c.GetHeader("Authorization")
|
|
188
|
+
if !strings.HasPrefix(header, "Bearer ") {
|
|
189
|
+
unauthorized(c, "No bearer token provided")
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
k, err := keys()
|
|
193
|
+
if err != nil {
|
|
194
|
+
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "Token signing keys unavailable"})
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
opts := []jwt.ParserOption{
|
|
198
|
+
jwt.WithIssuer(issuer),
|
|
199
|
+
jwt.WithValidMethods(algorithms),
|
|
200
|
+
jwt.WithExpirationRequired(),
|
|
201
|
+
}
|
|
202
|
+
<% if (it.jwt.provider !== 'cognito') { %>
|
|
203
|
+
if audience != "" {
|
|
204
|
+
opts = append(opts, jwt.WithAudience(audience))
|
|
205
|
+
}
|
|
206
|
+
<% } %>
|
|
207
|
+
mc := jwt.MapClaims{}
|
|
208
|
+
token, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), mc, k.Keyfunc, opts...)
|
|
209
|
+
if err != nil || !token.Valid {
|
|
210
|
+
unauthorized(c, "Invalid token")
|
|
52
211
|
return
|
|
53
212
|
}
|
|
213
|
+
claims := map[string]interface{}(mc)
|
|
214
|
+
<% if (it.jwt.provider === 'cognito') { %>
|
|
215
|
+
// Cognito ID tokens carry aud; access tokens carry client_id instead.
|
|
216
|
+
if audience != "" {
|
|
217
|
+
aud := claims["aud"]
|
|
218
|
+
if aud == nil {
|
|
219
|
+
aud = claims["client_id"]
|
|
220
|
+
}
|
|
221
|
+
if !containsAudience(aud, audience) {
|
|
222
|
+
unauthorized(c, "Invalid token")
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
<% } %>
|
|
54
227
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Invalid roles format"})
|
|
228
|
+
if azp, ok := claims["azp"].(string); ok && len(authorizedParties) > 0 && !contains(authorizedParties, azp) {
|
|
229
|
+
unauthorized(c, "Invalid token")
|
|
58
230
|
return
|
|
59
231
|
}
|
|
60
232
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
233
|
+
userID := str(claim(claims, <%~ JSON.stringify(it.jwt.claims.userId) %>))
|
|
234
|
+
if userID == "" {
|
|
235
|
+
unauthorized(c, "Token has no user id")
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
tenant := str(claim(claims, <%~ JSON.stringify(it.jwt.claims.workspaceId || '') %>))
|
|
239
|
+
if tenant == "" {
|
|
240
|
+
tenant = str(claim(claims, <%~ JSON.stringify(it.jwt.claims.organizationId || '') %>))
|
|
241
|
+
}
|
|
242
|
+
c.Set(AuthContextKey, &AuthContext{
|
|
243
|
+
UserID: userID,
|
|
244
|
+
Email: str(claim(claims, <%~ JSON.stringify(it.jwt.claims.email || '') %>)),
|
|
245
|
+
WorkspaceID: tenant,
|
|
246
|
+
OrganizationID: tenant,
|
|
247
|
+
Roles: roles(claim(claims, <%~ JSON.stringify(it.jwt.claims.roles || '') %>)),
|
|
248
|
+
Claims: claims,
|
|
249
|
+
})
|
|
250
|
+
c.Next()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
<% if (it.jwt.provider === 'cognito') { %>
|
|
254
|
+
|
|
255
|
+
func containsAudience(aud interface{}, want string) bool {
|
|
256
|
+
switch t := aud.(type) {
|
|
257
|
+
case string:
|
|
258
|
+
return t == want
|
|
259
|
+
case []interface{}:
|
|
260
|
+
for _, a := range t {
|
|
261
|
+
if str(a) == want {
|
|
262
|
+
return true
|
|
68
263
|
}
|
|
69
264
|
}
|
|
265
|
+
}
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
<% } %>
|
|
269
|
+
<% } else { %>
|
|
70
270
|
|
|
71
|
-
|
|
271
|
+
<% if (it.apiKey.hasExpiresAt) { %>
|
|
272
|
+
// before reports whether an expiresAt value read from the database is still
|
|
273
|
+
// in the future. A value it cannot read counts as expired.
|
|
274
|
+
func before(v interface{}, now time.Time) bool {
|
|
275
|
+
switch t := v.(type) {
|
|
276
|
+
case time.Time:
|
|
277
|
+
return now.Before(t)
|
|
278
|
+
case *time.Time:
|
|
279
|
+
return t == nil || now.Before(*t)
|
|
280
|
+
case string:
|
|
281
|
+
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02"} {
|
|
282
|
+
if p, err := time.Parse(layout, t); err == nil {
|
|
283
|
+
return now.Before(p)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
case []byte:
|
|
287
|
+
return before(string(t), now)
|
|
72
288
|
}
|
|
289
|
+
return false
|
|
73
290
|
}
|
|
74
291
|
|
|
75
|
-
// Role constants
|
|
76
|
-
const (
|
|
77
|
-
<% it.auth.roles.forEach((role) => { %>
|
|
78
|
-
Role<%= role.charAt(0).toUpperCase() + role.slice(1) %> = "<%= role %>"
|
|
79
|
-
<% }) %>
|
|
80
|
-
)
|
|
81
292
|
<% } %>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// TenantMiddleware extracts and validates tenant from requests
|
|
85
|
-
func TenantMiddleware() gin.HandlerFunc {
|
|
293
|
+
// AuthMiddleware authenticates the caller's API key against <%= it.apiKey.apiKeyEntity %>.
|
|
294
|
+
func AuthMiddleware(db *gorm.DB) gin.HandlerFunc {
|
|
86
295
|
return func(c *gin.Context) {
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
296
|
+
key := c.GetHeader(<%~ JSON.stringify(it.apiKey.apiKeyHeader) %>)
|
|
297
|
+
if key == "" {
|
|
298
|
+
unauthorized(c, "No API key provided")
|
|
299
|
+
return
|
|
90
300
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
301
|
+
sum := sha256.Sum256([]byte(key))
|
|
302
|
+
row := map[string]interface{}{}
|
|
303
|
+
err := db.Model(&models.<%= it.apiKey.goModel %>{}).
|
|
304
|
+
Where(<%~ JSON.stringify(it.apiKey.keyColumn + ' = ?') %>, hex.EncodeToString(sum[:])).
|
|
305
|
+
Take(&row).Error
|
|
306
|
+
if err != nil {
|
|
307
|
+
unauthorized(c, "Invalid API key")
|
|
94
308
|
return
|
|
95
309
|
}
|
|
96
|
-
|
|
97
|
-
|
|
310
|
+
<% if (it.apiKey.hasStatus) { %>
|
|
311
|
+
if fmt.Sprint(row["status"]) != "active" {
|
|
312
|
+
unauthorized(c, "API key is not active")
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
<% } %>
|
|
316
|
+
<% if (it.apiKey.hasExpiresAt) { %>
|
|
317
|
+
if v := row[<%~ JSON.stringify(it.apiKey.expiresColumn) %>]; v != nil && !before(v, time.Now()) {
|
|
318
|
+
unauthorized(c, "API key has expired")
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
<% } %>
|
|
322
|
+
tenant := ""
|
|
323
|
+
if v := row[<%~ JSON.stringify(it.apiKey.workspaceColumn) %>]; v != nil {
|
|
324
|
+
tenant = fmt.Sprint(v)
|
|
325
|
+
}
|
|
326
|
+
roles := []string{}
|
|
327
|
+
<% if (it.apiKey.roleField) { %>
|
|
328
|
+
if v := row[<%~ JSON.stringify(it.apiKey.roleColumn) %>]; v != nil {
|
|
329
|
+
roles = append(roles, fmt.Sprint(v))
|
|
330
|
+
}
|
|
331
|
+
<% } %>
|
|
332
|
+
c.Set(AuthContextKey, &AuthContext{
|
|
333
|
+
ServiceID: fmt.Sprint(row["id"]),
|
|
334
|
+
WorkspaceID: tenant,
|
|
335
|
+
OrganizationID: tenant,
|
|
336
|
+
Roles: roles,
|
|
337
|
+
})
|
|
98
338
|
c.Next()
|
|
99
339
|
}
|
|
100
340
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthGuard - application API keys stored as SHA-256 hashes on <%= it.apiKey.apiKeyEntity %>
|
|
3
|
+
*
|
|
4
|
+
* Auto-generated by Apso CLI from .apsorc auth configuration
|
|
5
|
+
* Generated at: <%= it.generatedAt %>
|
|
6
|
+
*
|
|
7
|
+
* This guard:
|
|
8
|
+
* - Reads the key from the <%= it.apiKey.apiKeyHeader %> header
|
|
9
|
+
* - Looks up the <%= it.apiKey.apiKeyEntity %> row whose <%= it.apiKey.keyField %> is the key's hex SHA-256
|
|
10
|
+
<% if (it.apiKey.hasStatus) { %>
|
|
11
|
+
* - Rejects keys whose status is not "active"
|
|
12
|
+
<% } %>
|
|
13
|
+
<% if (it.apiKey.hasExpiresAt) { %>
|
|
14
|
+
* - Rejects keys past expiresAt
|
|
15
|
+
<% } %>
|
|
16
|
+
* - Attaches a normalized AuthContext to request.auth
|
|
17
|
+
*
|
|
18
|
+
* DO NOT EDIT - Changes will be overwritten on next scaffold
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
Injectable,
|
|
22
|
+
CanActivate,
|
|
23
|
+
ExecutionContext,
|
|
24
|
+
UnauthorizedException,
|
|
25
|
+
} from '@nestjs/common';
|
|
26
|
+
import { Reflector } from '@nestjs/core';
|
|
27
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
28
|
+
import { Repository } from 'typeorm';
|
|
29
|
+
import { createHash } from 'crypto';
|
|
30
|
+
|
|
31
|
+
import { <%= it.apiKey.apiKeyEntity %> } from '../<%= it.apiKey.apiKeyEntity %>/<%= it.apiKey.apiKeyEntity %>.entity';
|
|
32
|
+
|
|
33
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decorator to mark a route as public (skips authentication)
|
|
37
|
+
* Usage: @Public()
|
|
38
|
+
*/
|
|
39
|
+
export const Public =
|
|
40
|
+
() => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
41
|
+
Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalized auth context attached to authenticated requests.
|
|
46
|
+
* This is what scopeBy and RBAC consume.
|
|
47
|
+
*/
|
|
48
|
+
export interface AuthContext {
|
|
49
|
+
userId?: string;
|
|
50
|
+
serviceId: string;
|
|
51
|
+
workspaceId?: string;
|
|
52
|
+
organizationId?: string;
|
|
53
|
+
roles: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Injectable()
|
|
57
|
+
export class AuthGuard implements CanActivate {
|
|
58
|
+
constructor(
|
|
59
|
+
private reflector: Reflector,
|
|
60
|
+
@InjectRepository(<%= it.apiKey.apiKeyEntity %>)
|
|
61
|
+
private apiKeyRepository: Repository<<%= it.apiKey.apiKeyEntity %>>,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
65
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
66
|
+
context.getHandler(),
|
|
67
|
+
context.getClass(),
|
|
68
|
+
]);
|
|
69
|
+
if (isPublic) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The template's health check stays public for load balancer probes.
|
|
74
|
+
const req = context.switchToHttp().getRequest();
|
|
75
|
+
if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const request = context.switchToHttp().getRequest();
|
|
80
|
+
const key = request.headers['<%= it.apiKey.apiKeyHeader %>'];
|
|
81
|
+
if (typeof key !== 'string' || !key) {
|
|
82
|
+
throw new UnauthorizedException('No API key provided');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hash = createHash('sha256').update(key).digest('hex');
|
|
86
|
+
const record: any = await this.apiKeyRepository.findOne({
|
|
87
|
+
where: { <%= it.apiKey.keyField %>: hash } as any,
|
|
88
|
+
});
|
|
89
|
+
if (!record) {
|
|
90
|
+
throw new UnauthorizedException('Invalid API key');
|
|
91
|
+
}
|
|
92
|
+
<% if (it.apiKey.hasStatus) { %>
|
|
93
|
+
if (record.status !== 'active') {
|
|
94
|
+
throw new UnauthorizedException('API key is not active');
|
|
95
|
+
}
|
|
96
|
+
<% } %>
|
|
97
|
+
<% if (it.apiKey.hasExpiresAt) { %>
|
|
98
|
+
if (record.expiresAt && new Date(record.expiresAt) <= new Date()) {
|
|
99
|
+
throw new UnauthorizedException('API key has expired');
|
|
100
|
+
}
|
|
101
|
+
<% } %>
|
|
102
|
+
|
|
103
|
+
const tenant =
|
|
104
|
+
record.<%= it.apiKey.workspaceField %> != null
|
|
105
|
+
? String(record.<%= it.apiKey.workspaceField %>)
|
|
106
|
+
: undefined;
|
|
107
|
+
<% if (it.apiKey.roleField) { %>
|
|
108
|
+
const role = record.<%= it.apiKey.roleField %>;
|
|
109
|
+
const roles = Array.isArray(role) ? role.map(String) : role ? [String(role)] : [];
|
|
110
|
+
<% } else { %>
|
|
111
|
+
const roles: string[] = [];
|
|
112
|
+
<% } %>
|
|
113
|
+
const auth: AuthContext = {
|
|
114
|
+
serviceId: String(record.id),
|
|
115
|
+
workspaceId: tenant,
|
|
116
|
+
organizationId: tenant,
|
|
117
|
+
roles,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
request.auth = auth;
|
|
121
|
+
request.user = { id: auth.serviceId, ...auth };
|
|
122
|
+
// Read by ScopeGuard's scope lookup.
|
|
123
|
+
request.workspaceId = tenant;
|
|
124
|
+
request.organizationId = tenant;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Helper function to get AuthContext from request
|
|
131
|
+
* Use this in controllers/services to access the authenticated context
|
|
132
|
+
*/
|
|
133
|
+
export function getAuthContext(request: any): AuthContext | undefined {
|
|
134
|
+
return request?.auth;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Helper function to require AuthContext (throws if not present)
|
|
139
|
+
*/
|
|
140
|
+
export function requireAuthContext(request: any): AuthContext {
|
|
141
|
+
const ctx = getAuthContext(request);
|
|
142
|
+
if (!ctx) {
|
|
143
|
+
throw new UnauthorizedException('Authentication required');
|
|
144
|
+
}
|
|
145
|
+
return ctx;
|
|
146
|
+
}
|
|
@@ -94,6 +94,12 @@ export class AuthGuard implements CanActivate {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// The template's health check stays public for load balancer probes.
|
|
98
|
+
const req = context.switchToHttp().getRequest();
|
|
99
|
+
if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
const request = context.switchToHttp().getRequest();
|
|
98
104
|
const token = this.extractToken(request);
|
|
99
105
|
|
|
@@ -7,7 +7,7 @@ import { APP_GUARD } from '@nestjs/core';
|
|
|
7
7
|
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
8
8
|
import { ScopeGuard } from './scope.guard';
|
|
9
9
|
<% } %>
|
|
10
|
-
<% if (it.
|
|
10
|
+
<% if (it.hasAuth) { %>
|
|
11
11
|
import { AuthGuard } from './auth.guard';
|
|
12
12
|
<% } %>
|
|
13
13
|
|
|
@@ -20,13 +20,11 @@ import { <%= entity.name %> } from '../<%= entity.name %>/<%= entity.name %>.ent
|
|
|
20
20
|
<% } %>
|
|
21
21
|
|
|
22
22
|
<% /* Import entities for AuthGuard (from sibling autogen folders) */ %>
|
|
23
|
-
<% if (it.
|
|
23
|
+
<% if (it.authEntities.length > 0) { %>
|
|
24
24
|
// Entities for AuthGuard
|
|
25
|
-
|
|
26
|
-
import { <%=
|
|
27
|
-
<%
|
|
28
|
-
import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.accountUserEntity %>/<%= it.authConfig.accountUserEntity %>.entity';
|
|
29
|
-
<% } %>
|
|
25
|
+
<% it.authEntities.forEach((entity) => { %>
|
|
26
|
+
import { <%= entity %> } from '../<%= entity %>/<%= entity %>.entity';
|
|
27
|
+
<% }) %>
|
|
30
28
|
<% } %>
|
|
31
29
|
|
|
32
30
|
/**
|
|
@@ -43,12 +41,10 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
|
|
|
43
41
|
* 3. Services with no auth model generate no guards and rely on the API Gateway
|
|
44
42
|
* API key at the edge.
|
|
45
43
|
*
|
|
46
|
-
<% if (it.
|
|
44
|
+
<% if (it.hasAuth) { %>
|
|
47
45
|
* AuthGuard:
|
|
48
|
-
* -
|
|
49
|
-
* - Loads user and organization context
|
|
46
|
+
* - Authenticates every request (<%= it.authProvider %>)
|
|
50
47
|
* - Attaches normalized AuthContext to request.auth
|
|
51
|
-
* - Provider: <%= it.authConfig.provider %>
|
|
52
48
|
*
|
|
53
49
|
<% } %>
|
|
54
50
|
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
@@ -72,11 +68,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
|
|
|
72
68
|
<% /* Collect all unique entities */ %>
|
|
73
69
|
<% const allEntities = new Set(); %>
|
|
74
70
|
<% if (it.scopedEntities) { it.scopedEntities.forEach(e => allEntities.add(e.name)); } %>
|
|
75
|
-
<%
|
|
76
|
-
<% allEntities.add(it.authConfig.sessionEntity); %>
|
|
77
|
-
<% allEntities.add(it.authConfig.userEntity); %>
|
|
78
|
-
<% if (it.authConfig.accountUserEntity) { allEntities.add(it.authConfig.accountUserEntity); } %>
|
|
79
|
-
<% } %>
|
|
71
|
+
<% it.authEntities.forEach(e => allEntities.add(e)); %>
|
|
80
72
|
<% const entityArray = Array.from(allEntities); %>
|
|
81
73
|
<% entityArray.forEach((entity, index) => { %>
|
|
82
74
|
<%= entity %><%= index < entityArray.length - 1 ? ',' : '' %>
|
|
@@ -84,7 +76,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
|
|
|
84
76
|
]),
|
|
85
77
|
],
|
|
86
78
|
providers: [
|
|
87
|
-
<% if (it.
|
|
79
|
+
<% if (it.hasAuth) { %>
|
|
88
80
|
AuthGuard,
|
|
89
81
|
<% } %>
|
|
90
82
|
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
@@ -95,7 +87,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
|
|
|
95
87
|
// config generate no guards at all and are protected at the edge by the
|
|
96
88
|
// API Gateway API key — so this does not conflict with that setup. Use the
|
|
97
89
|
// @Public() decorator to opt an individual route out of authentication.
|
|
98
|
-
<% if (it.
|
|
90
|
+
<% if (it.hasAuth) { %>
|
|
99
91
|
{
|
|
100
92
|
provide: APP_GUARD,
|
|
101
93
|
useClass: AuthGuard,
|
|
@@ -109,7 +101,7 @@ import { <%= it.authConfig.accountUserEntity %> } from '../<%= it.authConfig.acc
|
|
|
109
101
|
<% } %>
|
|
110
102
|
],
|
|
111
103
|
exports: [
|
|
112
|
-
<% if (it.
|
|
104
|
+
<% if (it.hasAuth) { %>
|
|
113
105
|
AuthGuard,
|
|
114
106
|
<% } %>
|
|
115
107
|
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<%~ includeFile('../header.eta') %>
|
|
2
2
|
|
|
3
|
-
<% if (it.
|
|
3
|
+
<% if (it.hasAuth) { %>
|
|
4
4
|
export * from './auth.guard';
|
|
5
5
|
<% } %>
|
|
6
6
|
<% if (it.scopedEntities && it.scopedEntities.length > 0) { %>
|
|
7
|
-
<% if (it.
|
|
7
|
+
<% if (it.hasAuth) { %>
|
|
8
8
|
// When auth is configured, only export scope-specific items from scope.guard
|
|
9
9
|
// to avoid duplicate exports (Public, IS_PUBLIC_KEY come from auth.guard)
|
|
10
10
|
export {
|