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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user