feat(vNext.4): implement trusted-subject security chain for portal user key self-service

- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved

This implements the secure chain:
  Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)

Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services

Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
This commit is contained in:
phamnazage-jpg
2026-06-09 07:48:03 +08:00
parent dd6f332b53
commit 4e2ee087fd
25 changed files with 1861 additions and 177 deletions

View File

@@ -336,14 +336,10 @@ func NewAPIHandler(adminToken string, actions ActionSet, dsn ...string) http.Han
if len(dsn) > 0 {
dsnVal = dsn[0]
}
return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions, dsnVal)
return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions, dsnVal, "")
}
func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet, dsn ...string) http.Handler {
sqliteDSN := ""
if len(dsn) > 0 {
sqliteDSN = dsn[0]
}
func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet, sqliteDSN string, portalSessionSecret string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /version", handleVersion)
@@ -366,6 +362,19 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet, dsn ...
mux.HandleFunc("GET /api/portal/logical-groups/{groupID}/models", func(w http.ResponseWriter, r *http.Request) {
handleListPortalLogicalGroupModels(w, r, actions.ListPortalLogicalGroupModels)
})
// Portal user session endpoints
portalAuth := PortalAuthConfig{
SessionSecret: portalSessionSecret,
}
mux.HandleFunc("GET /api/portal/session", func(w http.ResponseWriter, r *http.Request) {
handlePortalSessionState(w, r, portalAuth)
})
mux.HandleFunc("POST /api/portal/session/login", func(w http.ResponseWriter, r *http.Request) {
handlePortalSessionLogin(w, r, portalAuth)
})
mux.HandleFunc("POST /api/portal/session/logout", func(w http.ResponseWriter, r *http.Request) {
handlePortalSessionLogout(w, r)
})
mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
})))
@@ -1296,6 +1305,14 @@ func writeJSON(w http.ResponseWriter, statusCode int, body any) {
_ = json.NewEncoder(w).Encode(body)
}
func nonEmptyString(value, fallback string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return fallback
}
func classifyError(err error) *httpError {
if err == nil {
return nil
@@ -1337,7 +1354,7 @@ func NewActionSet(sqliteDSN string) ActionSet {
return NewActionSetWithStickyRuntime(sqliteDSN, defaultStickyStoreRuntime())
}
func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRuntime) ActionSet {
func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRuntime, authCfg ...config.UserKeyAuthConfig) ActionSet {
routeLogWriter := newLazyRouteLogWriter(sqliteDSN)
resolveRoute := buildResolveRouteAction(sqliteDSN, stickyRuntime, routeLogWriter)
proxyRouteChatCompletions := buildProxyRouteChatCompletionsAction(sqliteDSN, resolveRoute, routeLogWriter)
@@ -1383,7 +1400,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
GetProviderAccountBindingCandidates: buildGetProviderAccountBindingCandidatesAction(sqliteDSN),
UserKeyHandler: buildUserKeyHandler(sqliteDSN),
UserKeyHandler: buildUserKeyHandler(sqliteDSN, authCfg...),
UpdateProviderAccountBinding: buildUpdateProviderAccountBindingAction(sqliteDSN),
EnableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusActive),
DisableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDisabled),
@@ -2741,6 +2758,19 @@ func handlePublicV1ChatCompletions(w http.ResponseWriter, r *http.Request, dsn s
writeHTTPError(w, &httpError{StatusCode: http.StatusForbidden, Code: "quota_exhausted", Message: "API key quota exhausted"})
return
}
if key.ExpiresAt != "" {
expiresAt, parseErr := time.Parse(time.RFC3339, key.ExpiresAt)
if parseErr != nil {
metrics.RecordUserKeyChatRequest("key_metadata_error")
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "key_metadata_error", Message: "invalid key expiry metadata"})
return
}
if !expiresAt.After(time.Now().UTC()) {
metrics.RecordUserKeyChatRequest("key_expired")
writeHTTPError(w, &httpError{StatusCode: http.StatusForbidden, Code: "key_expired", Message: "API key has expired"})
return
}
}
// 4. Parse request body (OpenAI-compatible)
body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes))
@@ -2768,6 +2798,20 @@ func handlePublicV1ChatCompletions(w http.ResponseWriter, r *http.Request, dsn s
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "model is required"})
return
}
if len(key.AllowedModels) > 0 {
modelAllowed := false
for _, allowedModel := range key.AllowedModels {
if strings.TrimSpace(allowedModel) == model {
modelAllowed = true
break
}
}
if !modelAllowed {
metrics.RecordUserKeyChatRequest("model_not_allowed")
writeHTTPError(w, &httpError{StatusCode: http.StatusForbidden, Code: "model_not_allowed", Message: "requested model is not allowed for this API key"})
return
}
}
// 5. Map to proxy request
proxyReq := ProxyRouteChatCompletionsRequest{
@@ -2804,7 +2848,28 @@ func handlePublicV1ChatCompletions(w http.ResponseWriter, r *http.Request, dsn s
}
if upstreamResp == nil {
// Fallback: construct a minimal response from proxy info
upstreamResp = map[string]any{}
}
if !result.Forward.OK {
statusCode := result.Forward.UpstreamStatus
if statusCode <= 0 {
statusCode = http.StatusBadGateway
}
upstreamResp["upstream_http_code"] = statusCode
if _, hasError := upstreamResp["error"]; !hasError {
upstreamResp["error"] = map[string]any{
"code": nonEmptyString(result.Forward.ErrorClass, "upstream_error"),
"message": nonEmptyString(result.Forward.ErrorMessage, fmt.Sprintf("upstream request failed with status %d", statusCode)),
}
}
metrics.RecordUserKeyChatRequest(nonEmptyString(result.Forward.ErrorClass, "upstream_error"))
writeJSON(w, statusCode, upstreamResp)
return
}
// Fallback: construct a minimal success response from proxy info
if len(upstreamResp) == 0 {
upstreamResp = map[string]any{
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixMilli()),
"object": "chat.completion",
@@ -2820,10 +2885,8 @@ func handlePublicV1ChatCompletions(w http.ResponseWriter, r *http.Request, dsn s
}},
}
}
// Ensure upstream HTTP code is reflected
if !result.Forward.OK && result.Forward.UpstreamStatus > 0 {
upstreamResp["upstream_http_code"] = result.Forward.UpstreamStatus
if err := store.UserKeys().TouchLastUsed(r.Context(), key.KeyID); err != nil {
log.Printf("gateway: touch last_used_at for key %s failed: %v", key.KeyID, err)
}
// Wrap in OpenAI standard envelope if upstream didn't return one