From f30aaeb2d43d29d1afd7e9f2fe60c7bfe914d399 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Tue, 14 Jul 2026 17:06:41 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20SSF=20?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BC=9A=E8=AF=9D=E6=92=A4=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 16 + README.md | 2 + apps/api/docs/swagger.json | 74 ++++ apps/api/docs/swagger.yaml | 51 +++ apps/api/go.mod | 1 + apps/api/go.sum | 2 + apps/api/internal/auth/auth.go | 2 + apps/api/internal/auth/oidc.go | 89 +++- apps/api/internal/auth/oidc_test.go | 54 +++ apps/api/internal/config/config.go | 219 +++++++--- apps/api/internal/config/config_test.go | 31 ++ apps/api/internal/httpapi/security_events.go | 22 + apps/api/internal/httpapi/server.go | 82 +++- apps/api/internal/oidcsession/service.go | 14 + apps/api/internal/securityevents/handler.go | 185 ++++++++ .../internal/securityevents/handler_test.go | 269 ++++++++++++ apps/api/internal/securityevents/metrics.go | 168 +++++++ .../internal/securityevents/metrics_test.go | 50 +++ apps/api/internal/securityevents/service.go | 211 +++++++++ .../internal/securityevents/service_test.go | 86 ++++ apps/api/internal/securityevents/verifier.go | 412 ++++++++++++++++++ apps/api/internal/store/security_events.go | 341 +++++++++++++++ .../store/security_events_integration_test.go | 132 ++++++ .../internal/store/security_events_test.go | 10 + .../migrations/0062_oidc_security_events.sql | 70 +++ docker-compose.yml | 16 + docker/nginx.conf | 23 + docs/security/ssf-session-revocation.md | 56 +++ 28 files changed, 2597 insertions(+), 91 deletions(-) create mode 100644 apps/api/internal/httpapi/security_events.go create mode 100644 apps/api/internal/securityevents/handler.go create mode 100644 apps/api/internal/securityevents/handler_test.go create mode 100644 apps/api/internal/securityevents/metrics.go create mode 100644 apps/api/internal/securityevents/metrics_test.go create mode 100644 apps/api/internal/securityevents/service.go create mode 100644 apps/api/internal/securityevents/service_test.go create mode 100644 apps/api/internal/securityevents/verifier.go create mode 100644 apps/api/internal/store/security_events.go create mode 100644 apps/api/internal/store/security_events_integration_test.go create mode 100644 apps/api/internal/store/security_events_test.go create mode 100644 apps/api/migrations/0062_oidc_security_events.sql create mode 100644 docs/security/ssf-session-revocation.md diff --git a/.env.example b/.env.example index 1905d2a..25fd408 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,22 @@ OIDC_ACCEPT_LEGACY_HS256=true OIDC_INTROSPECTION_ENABLED=false OIDC_INTROSPECTION_CLIENT_ID= OIDC_INTROSPECTION_CLIENT_SECRET= +# Optional SSF/CAEP real-time revocation receiver. Enabling it also requires +# the RFC 7662 credentials above so bootstrap and fail-closed fallback work. +OIDC_SECURITY_EVENTS_ENABLED=false +OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER=https://auth.51easyai.com/ssf +OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE= +OIDC_SECURITY_EVENTS_BEARER_SECRET= +# During zero-downtime rotation, set next before removing current. +OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT= +OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT=https://api.51easyai.com/api/v1/security-events/ssf +OIDC_SECURITY_EVENTS_STREAM_ID= +OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL= +OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID= +OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET= +OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 # Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to # one existing active Gateway tenant; tokens never create Gateway tenants. OIDC_JIT_PROVISIONING_ENABLED=false diff --git a/README.md b/README.md index 5f98881..d4561c0 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ OIDC_SESSION_ENCRYPTION_KEY= Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 +可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 + `pnpm dev` 会先创建数据库并执行 migration,然后并行启动: - `api:dev`:通过 `scripts/go-watch.mjs` 运行 Go API,监听 `.go`、`go.mod`、`go.sum` 变化并自动重启后端进程;watcher 会按进程组终止旧的 `go run` 和其子进程,避免热更新时残留进程占用 API 端口。 diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index bab1b40..5515870 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -5506,6 +5506,80 @@ } } }, + "/api/v1/security-events/ssf": { + "post": { + "description": "Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response.", + "consumes": [ + "application/secevent+jwt" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Security Events" + ], + "summary": "Receive an SSF Security Event Token", + "parameters": [ + { + "type": "string", + "description": "Bearer stream-specific-secret", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Compact signed Security Event Token", + "name": "set", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/song/generations": { "post": { "security": [ diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index ac0cb65..1db16ed 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -6073,6 +6073,57 @@ paths: summary: 创建或执行 AI 任务 tags: - tasks + /api/v1/security-events/ssf: + post: + consumes: + - application/secevent+jwt + description: Optional endpoint. It validates a stream-specific Bearer before + parsing and verifying an ES256 SET. A committed event and a duplicate jti + both return an empty 202 response. + parameters: + - description: Bearer stream-specific-secret + in: header + name: Authorization + required: true + type: string + - description: Compact signed Security Event Token + in: body + name: set + required: true + schema: + type: string + produces: + - application/json + responses: + "202": + description: Accepted + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + type: string + type: object + summary: Receive an SSF Security Event Token + tags: + - Security Events /api/v1/song/generations: post: consumes: diff --git a/apps/api/go.mod b/apps/api/go.mod index 69c6e99..df5bee9 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -9,6 +9,7 @@ require ( github.com/dop251/goja v0.0.0-20260311135729-065cd970411c github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/riverqueue/river v0.24.0 github.com/riverqueue/river/riverdriver/riverpgxv5 v0.24.0 diff --git a/apps/api/go.sum b/apps/api/go.sum index 7db61cb..792b0e9 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -19,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index b93a0b4..087b4b5 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -50,6 +50,8 @@ type User struct { APIKeyPrefix string `json:"apiKeyPrefix,omitempty"` APIKeyScopes []string `json:"apiKeyScopes,omitempty"` TokenExpiresAt time.Time `json:"-"` + TokenIssuedAt time.Time `json:"-"` + Issuer string `json:"-"` } type contextKey string diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index b767221..df62e60 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" + "crypto/tls" "encoding/base64" "encoding/json" "errors" @@ -24,16 +25,31 @@ const maxOIDCResponseBytes = 1 << 20 const defaultOIDCHTTPTimeout = 10 * time.Second type OIDCConfig struct { - Issuer string - Audience string - TenantID string - RolePrefix string - RequiredScopes []string - JWKSCacheTTL time.Duration - IntrospectionEnabled bool - IntrospectionClientID string - IntrospectionClientSecret string - HTTPClient *http.Client + Issuer string + Audience string + TenantID string + RolePrefix string + RequiredScopes []string + JWKSCacheTTL time.Duration + IntrospectionEnabled bool + IntrospectionClientID string + IntrospectionClientSecret string + HTTPClient *http.Client + SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) + IntrospectionObserver func(string) + JWKSRefreshFailureObserver func() +} + +type OIDCSecurityEventIdentity struct { + Issuer string + TenantID string + Subject string + IssuedAt time.Time +} + +type OIDCSecurityEventEvaluation struct { + Revoked bool + RequireIntrospection bool } type OIDCVerifier struct { @@ -75,7 +91,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { return nil, errors.New("issuer, audience, tenant and role prefix are required") } - if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { + if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { return nil, errors.New("introspection client credentials are required when introspection is enabled") } if config.JWKSCacheTTL <= 0 { @@ -83,9 +99,12 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { } client := config.HTTPClient if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + transport.DisableCompression = true client = &http.Client{Timeout: defaultOIDCHTTPTimeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse - }} + }, Transport: transport} } return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil } @@ -125,6 +144,10 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { if _, ok := numericDateClaim(claims["nbf"]); !ok { return nil, oidcUnauthorized("nbf is missing", nil) } + issuedAt, hasIssuedAt := numericDateClaim(claims["iat"]) + if v.config.SecurityEventEvaluator != nil && !hasIssuedAt { + return nil, oidcUnauthorized("iat is required when security events are enabled", nil) + } scopes := scopeClaims(claims) if !containsAll(scopes, v.config.RequiredScopes) { return nil, oidcUnauthorized("required scope is missing", nil) @@ -136,7 +159,26 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { if len(roles) == 0 { return nil, oidcUnauthorized("mapped role is missing", nil) } - if v.config.IntrospectionEnabled { + if v.config.SecurityEventEvaluator != nil { + evaluation, evaluateErr := v.config.SecurityEventEvaluator(ctx, OIDCSecurityEventIdentity{ + Issuer: v.config.Issuer, TenantID: v.config.TenantID, Subject: stringClaim(claims, "sub"), IssuedAt: issuedAt, + }) + if evaluateErr != nil { + return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用") + } + if evaluation.Revoked { + return nil, oidcUnauthorized("token was issued before the revocation watermark", nil) + } + if evaluation.RequireIntrospection { + active, introspectionErr := v.introspect(ctx, raw) + if introspectionErr != nil { + return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_INTROSPECTION_UNAVAILABLE", "认证中心内省暂时不可用") + } + if !active { + return nil, oidcUnauthorized("token is inactive", nil) + } + } + } else if v.config.IntrospectionEnabled { active, err := v.introspect(ctx, raw) if err != nil || !active { return nil, oidcUnauthorized("token is inactive", err) @@ -148,7 +190,7 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { } return &User{ ID: stringClaim(claims, "sub"), Username: username, Roles: roles, - TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, + TenantID: v.config.TenantID, Source: "oidc", TokenExpiresAt: expiresAt, TokenIssuedAt: issuedAt, Issuer: v.config.Issuer, }, nil } @@ -161,6 +203,9 @@ func oidcUnauthorized(reason string, cause error) error { func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) { if err := v.refresh(ctx, false); err != nil { + if v.config.JWKSRefreshFailureObserver != nil { + v.config.JWKSRefreshFailureObserver() + } return nil, err } v.mu.Lock() @@ -170,6 +215,9 @@ func (v *OIDCVerifier) key(ctx context.Context, kid string) (any, error) { return key, nil } if err := v.refresh(ctx, true); err != nil { + if v.config.JWKSRefreshFailureObserver != nil { + v.config.JWKSRefreshFailureObserver() + } return nil, err } v.mu.Lock() @@ -195,7 +243,7 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error { if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil { return errors.New("OIDC discovery metadata is invalid") } - if v.config.IntrospectionEnabled && validatePublicURL(discovery.IntrospectionEndpoint) != nil { + if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil { return errors.New("OIDC introspection metadata is invalid") } var set jsonWebKeySet @@ -222,6 +270,12 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error { } func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) { + outcome := "failed" + defer func() { + if v.config.IntrospectionObserver != nil { + v.config.IntrospectionObserver(outcome) + } + }() v.mu.Lock() endpoint := v.introspectionEndpoint v.mu.Unlock() @@ -252,6 +306,11 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) if err := decoder.Decode(&result); err != nil { return false, errors.New("OIDC introspection response is invalid") } + if result.Active { + outcome = "active" + } else { + outcome = "inactive" + } return result.Active, nil } diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index ad625bf..16762a1 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -8,6 +8,7 @@ import ( "crypto/rsa" "encoding/base64" "encoding/json" + "errors" "math/big" "net/http" "net/http/httptest" @@ -153,6 +154,59 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing } } +func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + active, introspectionAvailable, introspectionCalls := true, true, 0 + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "jwks_uri": issuer + "/jwks", "introspection_endpoint": issuer + "/introspect"}) + case "/jwks": + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ecJWK("ec-key", &key.PublicKey)}}) + case "/introspect": + introspectionCalls++ + if !introspectionAvailable { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"active": active}) + } + })) + defer server.Close() + issuer = server.URL + evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true} + verifier, err := NewOIDCVerifier(OIDCConfig{ + Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", + RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), + IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret", + SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) { + if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() { + t.Fatal("security event evaluator received incomplete identity") + } + return evaluation, nil + }, + }) + if err != nil { + t.Fatal(err) + } + raw := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, nil) + if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 { + t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls) + } + evaluation = OIDCSecurityEventEvaluation{Revoked: true} + if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 { + t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls) + } + evaluation = OIDCSecurityEventEvaluation{RequireIntrospection: true} + introspectionAvailable = false + _, err = verifier.Verify(context.Background(), raw) + var requestError *RequestAuthError + if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable { + t.Fatalf("introspection outage error=%T %v", err, err) + } +} + func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string { t.Helper() now := time.Now() diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 6cd3a36..3819271 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -8,6 +8,8 @@ import ( "os" "strconv" "strings" + + "github.com/google/uuid" ) const ( @@ -16,50 +18,63 @@ const ( ) type Config struct { - AppEnv string - HTTPAddr string - DatabaseURL string - IdentityMode string - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - ServerMainInternalKey string - ServerMainInternalSecret string - OIDCEnabled bool - OIDCIssuer string - OIDCAudience string - OIDCTenantID string - OIDCRolePrefix string - OIDCRequiredScopes []string - OIDCJWKSCacheTTLSeconds int - OIDCAcceptLegacyHS256 bool - OIDCIntrospectionEnabled bool - OIDCIntrospectionClientID string - OIDCIntrospectionClientSecret string - OIDCJITProvisioningEnabled bool - OIDCGatewayTenantKey string - OIDCBrowserSessionEnabled bool - OIDCSessionCookieSecure bool - OIDCClientID string - OIDCRedirectURI string - OIDCPostLogoutRedirectURI string - OIDCSessionEncryptionKey string - OIDCSessionIdleTTLSeconds int - OIDCSessionAbsoluteTTLSeconds int - OIDCSessionRefreshBeforeSeconds int - PublicBaseURL string - WebBaseURL string - LocalGeneratedStorageDir string - LocalUploadedStorageDir string - LocalTempAssetTTLHours int - TaskProgressCallbackEnabled bool - TaskProgressCallbackURL string - TaskProgressCallbackTimeoutMS string - TaskProgressCallbackMaxAttempts string - CORSAllowedOrigin string - GlobalHTTPProxy string - GlobalHTTPProxySource string - LogLevel slog.Level + AppEnv string + HTTPAddr string + DatabaseURL string + IdentityMode string + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + OIDCEnabled bool + OIDCIssuer string + OIDCAudience string + OIDCTenantID string + OIDCRolePrefix string + OIDCRequiredScopes []string + OIDCJWKSCacheTTLSeconds int + OIDCAcceptLegacyHS256 bool + OIDCIntrospectionEnabled bool + OIDCIntrospectionClientID string + OIDCIntrospectionClientSecret string + OIDCSecurityEventsEnabled bool + OIDCSecurityEventsTransmitterIssuer string + OIDCSecurityEventsReceiverAudience string + OIDCSecurityEventsBearerSecret string + OIDCSecurityEventsBearerSecretNext string + OIDCSecurityEventsPublicEndpoint string + OIDCSecurityEventsStreamID string + OIDCSecurityEventsManagementTokenURL string + OIDCSecurityEventsManagementClientID string + OIDCSecurityEventsManagementClientSecret string + OIDCSecurityEventsHeartbeatIntervalSeconds int + OIDCSecurityEventsStaleAfterSeconds int + OIDCSecurityEventsClockSkewSeconds int + OIDCJITProvisioningEnabled bool + OIDCGatewayTenantKey string + OIDCBrowserSessionEnabled bool + OIDCSessionCookieSecure bool + OIDCClientID string + OIDCRedirectURI string + OIDCPostLogoutRedirectURI string + OIDCSessionEncryptionKey string + OIDCSessionIdleTTLSeconds int + OIDCSessionAbsoluteTTLSeconds int + OIDCSessionRefreshBeforeSeconds int + PublicBaseURL string + WebBaseURL string + LocalGeneratedStorageDir string + LocalUploadedStorageDir string + LocalTempAssetTTLHours int + TaskProgressCallbackEnabled bool + TaskProgressCallbackURL string + TaskProgressCallbackTimeoutMS string + TaskProgressCallbackMaxAttempts string + CORSAllowedOrigin string + GlobalHTTPProxy string + GlobalHTTPProxySource string + LogLevel slog.Level } func Load() Config { @@ -75,23 +90,36 @@ func Load() Config { env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), - ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), - ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), - ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), - OIDCEnabled: env("OIDC_ENABLED", "false") == "true", - OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), - OIDCAudience: env("OIDC_AUDIENCE", ""), - OIDCTenantID: env("OIDC_TENANT_ID", ""), - OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), - OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), - OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), - OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", - OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", - OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""), - OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""), - OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true", - OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""), - OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true", + ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), + ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), + OIDCEnabled: env("OIDC_ENABLED", "false") == "true", + OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), + OIDCAudience: env("OIDC_AUDIENCE", ""), + OIDCTenantID: env("OIDC_TENANT_ID", ""), + OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), + OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), + OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), + OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", + OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", + OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""), + OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""), + OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true", + OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"), + OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""), + OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""), + OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""), + OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""), + OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""), + OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""), + OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""), + OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""), + OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), + OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), + OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), + OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true", + OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""), + OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true", OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE", strconv.FormatBool(!isLocalEnvironment(appEnv)), ) == "true", @@ -157,9 +185,59 @@ func (c Config) Validate() error { } } } + if c.OIDCSecurityEventsEnabled { + if !c.OIDCEnabled { + return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled") + } + if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) || + !validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) || + !validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) { + return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs") + } + if _, err := uuid.Parse(c.OIDCTenantID); err != nil { + return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled") + } + applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:") + if applicationID == c.OIDCSecurityEventsReceiverAudience { + return errors.New("OIDC security event receiver audience and stream ID are required") + } + if _, err := uuid.Parse(applicationID); err != nil { + return errors.New("OIDC security event receiver audience must contain an application UUID") + } + if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil { + return errors.New("OIDC security event stream ID must be a UUID") + } + if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) || + c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) { + return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters") + } + if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" { + return errors.New("OIDC security event management machine client credentials are required") + } + if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" { + return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled") + } + if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || + c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || + c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { + return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") + } + } return nil } +func validBearerSecret(value string) bool { + if len(value) < 16 || len(value) > 4096 { + return false + } + for _, character := range []byte(value) { + if character < 0x21 || character > 0x7e { + return false + } + } + return true +} + func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) { raw := strings.TrimSpace(c.OIDCSessionEncryptionKey) for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} { @@ -182,6 +260,25 @@ func validPublicRedirectURL(raw string) bool { return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") } +func validServiceURL(raw, appEnv string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return false + } + if parsed.Scheme == "https" { + return true + } + return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") +} + +func validSecurityEventReceiverURL(raw, appEnv string) bool { + if !validServiceURL(raw, appEnv) { + return false + } + parsed, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == "" +} + func isLocalEnvironment(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "development", "dev", "local", "test": diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 016192c..6312c7e 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -136,3 +136,34 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) { t.Fatalf("Validate() error = %v, want offline_access rejection", err) } } + +func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) { + if err := (Config{}).Validate(); err != nil { + t.Fatalf("disabled security events changed existing config: %v", err) + } + cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") { + t.Fatalf("partial security event config error = %v", err) + } + cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf" + cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf" + cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token" + cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003" + cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002" + cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001" + cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16" + cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf" + cfg.OIDCSecurityEventsManagementClientSecret = "management-secret" + cfg.OIDCIntrospectionClientID = "gateway-introspection" + cfg.OIDCIntrospectionClientSecret = "introspection-secret" + cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60 + cfg.OIDCSecurityEventsStaleAfterSeconds = 180 + cfg.OIDCSecurityEventsClockSkewSeconds = 60 + if err := cfg.Validate(); err != nil { + t.Fatalf("valid security event config: %v", err) + } + cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path" + if err := cfg.Validate(); err == nil { + t.Fatal("non-canonical security event receiver path was accepted") + } +} diff --git a/apps/api/internal/httpapi/security_events.go b/apps/api/internal/httpapi/security_events.go new file mode 100644 index 0000000..bbe48a8 --- /dev/null +++ b/apps/api/internal/httpapi/security_events.go @@ -0,0 +1,22 @@ +package httpapi + +import "net/http" + +// receiveSecurityEvent accepts an RFC 8417 Security Event Token over RFC 8935 Push. +// +// @Summary Receive an SSF Security Event Token +// @Description Optional endpoint. It validates a stream-specific Bearer before parsing and verifying an ES256 SET. A committed event and a duplicate jti both return an empty 202 response. +// @Tags Security Events +// @Accept application/secevent+jwt +// @Produce json +// @Param Authorization header string true "Bearer stream-specific-secret" +// @Param set body string true "Compact signed Security Event Token" +// @Success 202 +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 503 {object} map[string]string +// @Router /api/v1/security-events/ssf [post] +func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) { + s.securityEventReceiver.ServeHTTP(w, r) +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 85c6cd4..2ab5fc1 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -13,21 +13,23 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type Server struct { - ctx context.Context - cfg config.Config - store *store.Store - oidcUserResolver oidcUserResolver - auth *auth.Authenticator - oidcClient oidcPublicClient - oidcSessions oidcSessionManager - oidcSessionCipher *oidcsession.Cipher - runner *runner.Service - logger *slog.Logger - geminiUploadSessions sync.Map + ctx context.Context + cfg config.Config + store *store.Store + oidcUserResolver oidcUserResolver + auth *auth.Authenticator + oidcClient oidcPublicClient + oidcSessions oidcSessionManager + oidcSessionCipher *oidcsession.Cipher + runner *runner.Service + logger *slog.Logger + geminiUploadSessions sync.Map + securityEventReceiver http.Handler } type oidcPublicClient interface { @@ -63,14 +65,58 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256 server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret + securityEventMetrics := &ssfreceiver.Metrics{} + var securityEventService *ssfreceiver.Service + if cfg.OIDCSecurityEventsEnabled { + ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{ + TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer, + Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer, + TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID, + ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + }) + if err != nil { + panic("invalid OIDC security event verifier configuration: " + err.Error()) + } + ssfVerifier.SetMetrics(securityEventMetrics) + securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{ + Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience, + StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL, + ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret, + HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, + StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, + }) + if err != nil { + panic("invalid OIDC security event heartbeat configuration: " + err.Error()) + } + securityEventService.SetMetrics(securityEventMetrics) + if err := securityEventService.Start(ctx); err != nil { + panic("OIDC security event state initialization failed") + } + receiver, err := ssfreceiver.NewHandler( + ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, + cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext, + ) + if err != nil { + panic("invalid OIDC security event receiver configuration: " + err.Error()) + } + receiver.SetMetrics(securityEventMetrics) + server.securityEventReceiver = receiver + } if cfg.OIDCEnabled { + var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) + if securityEventService != nil { + evaluator = securityEventService.Evaluate + } verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, - IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, - IntrospectionClientID: cfg.OIDCIntrospectionClientID, - IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, + JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, + IntrospectionClientID: cfg.OIDCIntrospectionClientID, + IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, + SecurityEventEvaluator: evaluator, + IntrospectionObserver: securityEventMetrics.ObserveIntrospection, + JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, }) if err != nil { panic("invalid OIDC configuration: " + err.Error()) @@ -118,6 +164,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux := http.NewServeMux() mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /readyz", server.ready) + mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled)) mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset) mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset) mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset) @@ -128,6 +175,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin) mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession) mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession) + if server.securityEventReceiver != nil { + mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent) + } mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders))) mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels))) @@ -284,6 +334,8 @@ func oidcSessionRequestError(err error) error { return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_REFRESH_UNAVAILABLE", "认证中心暂时不可用,请稍后重试") case errors.Is(err, oidcsession.ErrSessionStoreUnavailable): return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SESSION_STORE_UNAVAILABLE", "登录会话存储暂时不可用") + case errors.Is(err, oidcsession.ErrSecurityStateUnavailable): + return auth.NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用") case errors.Is(err, oidcsession.ErrGatewayUserDisabled): return auth.NewRequestAuthError(http.StatusForbidden, "GATEWAY_USER_DISABLED", "该 Gateway 账号已停用,请联系管理员") default: diff --git a/apps/api/internal/oidcsession/service.go b/apps/api/internal/oidcsession/service.go index ebb0d84..bc33f14 100644 --- a/apps/api/internal/oidcsession/service.go +++ b/apps/api/internal/oidcsession/service.go @@ -20,6 +20,7 @@ var ( ErrSessionRefreshUnavailable = errors.New("OIDC session refresh is unavailable") ErrSessionStoreUnavailable = errors.New("OIDC session store is unavailable") ErrGatewayUserDisabled = errors.New("gateway user is disabled") + ErrSecurityStateUnavailable = errors.New("OIDC security event state is unavailable") ) type Repository interface { @@ -179,11 +180,17 @@ func (s *Service) refresh(ctx context.Context, hash []byte, record store.OIDCSes } return user, nil } + if errors.Is(verifyErr, ErrSecurityStateUnavailable) { + return nil, verifyErr + } } return nil, ErrSessionRefreshUnavailable } user, err := s.verifySessionUser(ctx, record, refreshed.AccessToken) if err != nil { + if errors.Is(err, ErrSecurityStateUnavailable) { + return nil, err + } _ = s.repository.DeleteOIDCSessionByID(ctx, record.ID) return nil, ErrSessionExpired } @@ -241,12 +248,19 @@ func (s *Service) waitForRefresh(ctx context.Context, hash []byte, original stor } return user, nil } + if errors.Is(err, ErrSecurityStateUnavailable) { + return nil, err + } } return nil, ErrSessionRefreshUnavailable } func (s *Service) verifySessionUser(ctx context.Context, record store.OIDCSession, accessToken string) (*auth.User, error) { user, err := s.verifier.Verify(ctx, accessToken) + var requestError *auth.RequestAuthError + if errors.As(err, &requestError) && requestError.Status == 503 { + return nil, ErrSecurityStateUnavailable + } if err != nil || user == nil || user.Source != "oidc" || user.ID != record.ExternalUserID { return nil, ErrSessionInvalid } diff --git a/apps/api/internal/securityevents/handler.go b/apps/api/internal/securityevents/handler.go new file mode 100644 index 0000000..8c9f7b0 --- /dev/null +++ b/apps/api/internal/securityevents/handler.go @@ -0,0 +1,185 @@ +package securityevents + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strings" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type Repository interface { + ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) + ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) + ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) + RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) +} + +type Handler struct { + verifier *Verifier + repository Repository + issuer string + audience string + streamID string + bearerSecret string + nextSecret string + clock func() time.Time + metrics *Metrics +} + +func (h *Handler) SetMetrics(metrics *Metrics) { h.metrics = metrics } + +func NewHandler(verifier *Verifier, repository Repository, issuer, audience, streamID, bearerSecret, nextSecret string) (*Handler, error) { + if verifier == nil || repository == nil || issuer == "" || audience == "" || streamID == "" || len(bearerSecret) < 16 { + return nil, errors.New("SSF receiver dependencies and bearer secret are required") + } + return &Handler{verifier: verifier, repository: repository, issuer: issuer, audience: audience, streamID: streamID, bearerSecret: bearerSecret, nextSecret: nextSecret, clock: time.Now}, nil +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + started := h.clock() + outcome := "rejected" + var sessionsDeleted int64 + defer func() { + if h.metrics != nil { + h.metrics.ObserveReceipt(outcome, h.clock().Sub(started), sessionsDeleted) + } + }() + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !h.validAuthorization(r.Header.Get("Authorization")) { + w.Header().Set("WWW-Authenticate", `Bearer realm="ssf-receiver"`) + writeSETError(w, http.StatusUnauthorized, "authentication_failed") + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/secevent+jwt" || r.Header.Get("Content-Encoding") != "" { + writeSETError(w, http.StatusBadRequest, "invalid_request") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + payload, err := io.ReadAll(r.Body) + if err != nil || len(payload) == 0 { + writeSETError(w, http.StatusBadRequest, "invalid_request") + return + } + event, err := h.verifier.Verify(r.Context(), string(payload)) + if err != nil { + var protocol *protocolError + if errors.As(err, &protocol) { + writeSETError(w, http.StatusBadRequest, protocol.Code) + } else { + writeSETError(w, http.StatusBadRequest, "invalid_request") + } + return + } + switch event.EventType { + case SessionRevokedEventType: + var result store.ApplySecurityEventResult + result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{ + Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID, + SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp, + InitiatingEntity: event.InitiatingEntity, + }) + if err == nil { + sessionsDeleted = result.SessionsDeleted + if result.Duplicate { + outcome = "duplicate" + } else { + outcome = "accepted" + } + } + case VerificationEventType: + var confirmed bool + if event.State == "" { + confirmed, err = h.repository.RecordSecurityEventReceipt(r.Context(), h.issuer, h.audience, event.JTI, VerificationEventType, h.streamID) + } else { + stateHash := sha256.Sum256([]byte(event.State)) + confirmed, err = h.repository.ConfirmSecurityEventVerification(r.Context(), h.issuer, h.audience, h.streamID, event.JTI, stateHash[:], h.clock().UTC()) + } + if err != nil && strings.Contains(err.Error(), "invalid verification state") { + writeSETError(w, http.StatusBadRequest, "invalid_request") + return + } + if err == nil { + if confirmed { + outcome = "accepted" + if h.metrics != nil { + h.metrics.ObserveVerificationAccepted() + } + } else { + outcome = "duplicate" + } + } + case StreamUpdatedEventType: + var inserted bool + inserted, err = h.repository.ApplySecurityEventStreamUpdated( + r.Context(), h.issuer, h.audience, event.JTI, h.streamID, event.StreamStatus, event.Reason, h.clock().UTC(), + ) + if err == nil { + if inserted { + outcome = "accepted" + } else { + outcome = "duplicate" + } + } + } + if err != nil { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusAccepted) +} + +func (h *Handler) validAuthorization(value string) bool { + if constantBearerCompare(value, h.bearerSecret) { + return true + } + return h.nextSecret != "" && constantBearerCompare(value, h.nextSecret) +} + +func constantBearerCompare(header, secret string) bool { + providedHash := sha256.Sum256([]byte(header)) + expectedHash := sha256.Sum256([]byte("Bearer " + secret)) + return subtle.ConstantTimeCompare(providedHash[:], expectedHash[:]) == 1 +} + +func writeSETError(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Language", "en") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "err": code, + "description": setErrorDescription(code), + }) +} + +func setErrorDescription(code string) string { + switch code { + case "invalid_request": + return "The SET request is malformed or contains invalid claims." + case "invalid_key": + return "The SET signing key or signature is invalid." + case "invalid_issuer": + return "The SET issuer is not accepted by this receiver." + case "invalid_audience": + return "The SET audience does not identify this receiver." + case "authentication_failed": + return "Receiver authentication failed." + case "access_denied": + return "The SET is not permitted for this receiver." + default: + return "The SET request could not be processed." + } +} diff --git a/apps/api/internal/securityevents/handler_test.go b/apps/api/internal/securityevents/handler_test.go new file mode 100644 index 0000000..b8dd894 --- /dev/null +++ b/apps/api/internal/securityevents/handler_test.go @@ -0,0 +1,269 @@ +package securityevents + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type fakeRepository struct { + input *store.ApplySessionRevokedInput + dedup map[string]struct{} + confirmed bool +} + +func (f *fakeRepository) ApplySessionRevoked(_ context.Context, input store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) { + if f.dedup == nil { + f.dedup = make(map[string]struct{}) + } + if _, exists := f.dedup[input.JTI]; exists { + return store.ApplySecurityEventResult{Duplicate: true}, nil + } + f.dedup[input.JTI] = struct{}{} + f.input = &input + return store.ApplySecurityEventResult{WatermarkMoved: true, SessionsDeleted: 2}, nil +} + +func (f *fakeRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) { + f.confirmed = true + return true, nil +} + +func (*fakeRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) { + return true, nil +} + +func (*fakeRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) { + return true, nil +} + +func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + var transmitter *httptest.Server + transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/ssf-configuration/ssf": + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json"}) + case "/ssf/jwks.json": + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{ + "kty": "EC", "kid": "current", "use": "sig", "alg": "ES256", "crv": "P-256", + "x": coordinate(privateKey.X), "y": coordinate(privateKey.Y), + }}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer transmitter.Close() + tenantID, subjectID, applicationID, streamID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() + audience := "urn:easyai:ssf:receiver:" + applicationID + verifier, err := NewVerifier(VerifierConfig{ + TransmitterIssuer: transmitter.URL + "/ssf", Audience: audience, + SubjectIssuer: "https://auth.example/issuer/tenant-a", TenantID: tenantID, StreamID: streamID, + ClockSkew: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + repository := &fakeRepository{} + handler, err := NewHandler(verifier, repository, transmitter.URL+"/ssf", audience, streamID, "receiver-bearer-secret", "next-receiver-secret") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + jti := uuid.NewString() + set := signSET(t, privateKey, map[string]any{ + "iss": transmitter.URL + "/ssf", "aud": audience, "iat": now.Unix(), "jti": jti, "txn": uuid.NewString(), + "sub_id": map[string]any{ + "format": "complex", + "user": map[string]any{"format": "iss_sub", "iss": "https://auth.example/issuer/tenant-a", "sub": subjectID}, + "tenant": map[string]any{"format": "opaque", "id": tenantID}, + }, + "events": map[string]any{SessionRevokedEventType: map[string]any{"event_timestamp": now.Unix(), "initiating_entity": "admin"}}, + }) + for _, secret := range []string{"receiver-bearer-secret", "next-receiver-secret"} { + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", strings.NewReader(set)) + request.Header.Set("Authorization", "Bearer "+secret) + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusAccepted || response.Body.Len() != 0 { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + } + if repository.input == nil || repository.input.Subject != subjectID || repository.input.TenantID != tenantID || + repository.input.SubjectIssuer != "https://auth.example/issuer/tenant-a" { + t.Fatalf("applied event = %#v", repository.input) + } +} + +func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + verifier := &Verifier{config: VerifierConfig{ + TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app", + SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute, + }, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient} + handler, _ := NewHandler(verifier, &fakeRepository{}, "https://auth.example/ssf", verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "") + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader("token")) + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("missing bearer status=%d", response.Code) + } + assertSETError(t, response, "authentication_failed") + + set := signSET(t, privateKey, map[string]any{ + "iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), + "jti": uuid.NewString(), "sub": "forbidden", "exp": time.Now().Add(time.Hour).Unix(), "events": map[string]any{}, + }) + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set)) + request.Header.Set("Authorization", "Bearer receiver-bearer-secret") + request.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("wrong media status=%d", response.Code) + } + assertSETError(t, response, "invalid_request") + + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set)) + request.Header.Set("Authorization", "Bearer receiver-bearer-secret") + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request") { + t.Fatalf("forbidden claims status=%d body=%s", response.Code, response.Body.String()) + } + + future := time.Now().Add(10 * time.Minute) + futureSET := signSET(t, privateKey, map[string]any{ + "iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), + "jti": uuid.NewString(), "txn": uuid.NewString(), + "sub_id": map[string]any{ + "format": "complex", + "user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": uuid.NewString()}, + "tenant": map[string]any{"format": "opaque", "id": verifier.config.TenantID}, + }, + "events": map[string]any{SessionRevokedEventType: map[string]any{ + "event_timestamp": future.Unix(), "initiating_entity": "admin", + }}, + }) + response = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(futureSET)) + request.Header.Set("Authorization", "Bearer receiver-bearer-secret") + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("future event timestamp status=%d", response.Code) + } +} + +func TestReceiverReturnsRFC8935IssuerAudienceAndKeyErrors(t *testing.T) { + trustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + untrustedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + verifier := &Verifier{config: VerifierConfig{ + TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app", + SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute, + }, keys: map[string]*ecdsa.PublicKey{"current": &trustedKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient} + handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "") + + baseClaims := jwt.MapClaims{ + "iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), + "jti": uuid.NewString(), "events": map[string]any{}, + } + tests := []struct { + name string + key *ecdsa.PrivateKey + mutate func(jwt.MapClaims) + code string + }{ + {name: "issuer", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["iss"] = "https://wrong.example/ssf" }, code: "invalid_issuer"}, + {name: "audience", key: trustedKey, mutate: func(claims jwt.MapClaims) { claims["aud"] = "urn:wrong" }, code: "invalid_audience"}, + {name: "signature", key: untrustedKey, mutate: func(jwt.MapClaims) {}, code: "invalid_key"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + claims := jwt.MapClaims{} + for key, value := range baseClaims { + claims[key] = value + } + test.mutate(claims) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(signSET(t, test.key, claims))) + request.Header.Set("Authorization", "Bearer receiver-bearer-secret") + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + assertSETError(t, response, test.code) + }) + } +} + +func TestReceiverAcceptsStreamUpdatedEvent(t *testing.T) { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + verifier := &Verifier{config: VerifierConfig{ + TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:app", + SubjectIssuer: "https://auth.example/issuer/tenant", TenantID: uuid.NewString(), StreamID: uuid.NewString(), ClockSkew: time.Minute, + }, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient} + handler, _ := NewHandler(verifier, &fakeRepository{}, verifier.config.TransmitterIssuer, verifier.config.Audience, verifier.config.StreamID, "receiver-bearer-secret", "") + set := signSET(t, privateKey, jwt.MapClaims{ + "iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": time.Now().Unix(), "jti": uuid.NewString(), + "sub_id": map[string]any{"format": "opaque", "id": verifier.config.StreamID}, + "events": map[string]any{StreamUpdatedEventType: map[string]any{"status": "paused", "reason": "maintenance"}}, + }) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/events", strings.NewReader(set)) + request.Header.Set("Authorization", "Bearer receiver-bearer-secret") + request.Header.Set("Content-Type", "application/secevent+jwt") + handler.ServeHTTP(response, request) + if response.Code != http.StatusAccepted || response.Body.Len() != 0 { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} + +func assertSETError(t *testing.T, response *httptest.ResponseRecorder, code string) { + t.Helper() + if response.Header().Get("Content-Language") != "en" { + t.Fatalf("Content-Language=%q", response.Header().Get("Content-Language")) + } + var payload map[string]string + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("invalid error body: %v", err) + } + if payload["err"] != code || payload["description"] == "" { + t.Fatalf("error payload=%#v", payload) + } +} + +func signSET(t *testing.T, key *ecdsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["typ"], token.Header["kid"] = "secevent+jwt", "current" + raw, err := token.SignedString(key) + if err != nil { + t.Fatal(err) + } + return raw +} + +func coordinate(value *big.Int) string { + payload := make([]byte, 32) + value.FillBytes(payload) + return base64.RawURLEncoding.EncodeToString(payload) +} diff --git a/apps/api/internal/securityevents/metrics.go b/apps/api/internal/securityevents/metrics.go new file mode 100644 index 0000000..39a5fc3 --- /dev/null +++ b/apps/api/internal/securityevents/metrics.go @@ -0,0 +1,168 @@ +package securityevents + +import ( + "context" + "fmt" + "net/http" + "sync/atomic" + "time" +) + +type MetricsSnapshotProvider interface { + SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) +} + +type Metrics struct { + accepted atomic.Uint64 + rejected atomic.Uint64 + duplicate atomic.Uint64 + sessionsDeleted atomic.Uint64 + watermarkRejected atomic.Uint64 + verificationAccepted atomic.Uint64 + heartbeatAccepted atomic.Uint64 + heartbeatFailed atomic.Uint64 + introspectionActive atomic.Uint64 + introspectionInactive atomic.Uint64 + introspectionFailed atomic.Uint64 + jwksSSFFailed atomic.Uint64 + jwksOIDCFailed atomic.Uint64 + processingCount atomic.Uint64 + processingNanos atomic.Uint64 + processingBuckets [6]atomic.Uint64 +} + +var processingDurationBounds = [...]time.Duration{ + 10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, + 500 * time.Millisecond, time.Second, 3 * time.Second, +} + +func (m *Metrics) ObserveReceipt(outcome string, duration time.Duration, sessionsDeleted int64) { + switch outcome { + case "accepted": + m.accepted.Add(1) + case "duplicate": + m.duplicate.Add(1) + default: + m.rejected.Add(1) + } + if sessionsDeleted > 0 { + m.sessionsDeleted.Add(uint64(sessionsDeleted)) + } + if duration < 0 { + duration = 0 + } + m.processingCount.Add(1) + m.processingNanos.Add(uint64(duration)) + for index, bound := range processingDurationBounds { + if duration <= bound { + m.processingBuckets[index].Add(1) + } + } +} + +func (m *Metrics) ObserveWatermarkRejection() { m.watermarkRejected.Add(1) } + +func (m *Metrics) ObserveVerificationAccepted() { m.verificationAccepted.Add(1) } + +func (m *Metrics) ObserveHeartbeat(outcome string) { + if outcome == "accepted" { + m.heartbeatAccepted.Add(1) + return + } + m.heartbeatFailed.Add(1) +} + +func (m *Metrics) ObserveIntrospection(outcome string) { + switch outcome { + case "active": + m.introspectionActive.Add(1) + case "inactive": + m.introspectionInactive.Add(1) + default: + m.introspectionFailed.Add(1) + } +} + +func (m *Metrics) ObserveJWKSRefreshFailure(source string) { + if source == "ssf" { + m.jwksSSFFailed.Add(1) + return + } + m.jwksOIDCFailed.Add(1) +} + +func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience string, enabled bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mode := "disabled" + var lastVerificationAt *time.Time + if enabled { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + var err error + mode, lastVerificationAt, err = provider.SecurityEventMetrics(ctx, issuer, audience) + if err != nil { + http.Error(w, "metrics unavailable", http.StatusServiceUnavailable) + return + } + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + outcomeCounters(w, "easyai_gateway_ssf_receipts_total", "Received SETs by bounded outcome.", []outcomeValue{ + {"accepted", m.accepted.Load()}, {"rejected", m.rejected.Load()}, {"duplicate", m.duplicate.Load()}, + }) + plainCounter(w, "easyai_gateway_ssf_sessions_deleted_total", "OIDC browser sessions deleted by accepted session-revoked events.", m.sessionsDeleted.Load()) + plainCounter(w, "easyai_gateway_ssf_watermark_rejections_total", "OIDC access tokens rejected by a local revocation watermark.", m.watermarkRejected.Load()) + plainCounter(w, "easyai_gateway_ssf_verifications_total", "Valid verification SETs accepted by the receiver.", m.verificationAccepted.Load()) + outcomeCounters(w, "easyai_gateway_ssf_heartbeat_requests_total", "Verification requests by bounded outcome.", []outcomeValue{ + {"accepted", m.heartbeatAccepted.Load()}, {"failed", m.heartbeatFailed.Load()}, + }) + outcomeCounters(w, "easyai_gateway_oidc_introspection_total", "OIDC introspection requests by bounded outcome.", []outcomeValue{ + {"active", m.introspectionActive.Load()}, {"inactive", m.introspectionInactive.Load()}, {"failed", m.introspectionFailed.Load()}, + }) + outcomeCounters(w, "easyai_gateway_jwks_refresh_failures_total", "JWKS refresh failures by bounded source.", []outcomeValue{ + {"ssf", m.jwksSSFFailed.Load()}, {"oidc", m.jwksOIDCFailed.Load()}, + }) + fmt.Fprintln(w, "# HELP easyai_gateway_ssf_processing_duration_seconds SSF receiver transaction processing time.") + fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_processing_duration_seconds histogram") + for index, bound := range processingDurationBounds { + fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"%.2f\"} %d\n", bound.Seconds(), m.processingBuckets[index].Load()) + } + fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_bucket{le=\"+Inf\"} %d\n", m.processingCount.Load()) + fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_sum %.6f\n", float64(m.processingNanos.Load())/float64(time.Second)) + fmt.Fprintf(w, "easyai_gateway_ssf_processing_duration_seconds_count %d\n", m.processingCount.Load()) + verificationAge := float64(0) + if lastVerificationAt != nil { + verificationAge = time.Since(*lastVerificationAt).Seconds() + if verificationAge < 0 { + verificationAge = 0 + } + } + fmt.Fprintln(w, "# HELP easyai_gateway_ssf_verification_age_seconds Seconds since the last matched verification SET.") + fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_verification_age_seconds gauge") + fmt.Fprintf(w, "easyai_gateway_ssf_verification_age_seconds %.6f\n", verificationAge) + fmt.Fprintln(w, "# HELP easyai_gateway_ssf_mode Current SSF receiver mode as a one-hot gauge.") + fmt.Fprintln(w, "# TYPE easyai_gateway_ssf_mode gauge") + for _, modeName := range []string{"disabled", "bootstrap", "push_healthy", "introspection_fallback"} { + value := 0 + if mode == modeName { + value = 1 + } + fmt.Fprintf(w, "easyai_gateway_ssf_mode{mode=\"%s\"} %d\n", modeName, value) + } + }) +} + +type outcomeValue struct { + name string + value uint64 +} + +func outcomeCounters(w http.ResponseWriter, name, help string, values []outcomeValue) { + fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name) + for _, value := range values { + fmt.Fprintf(w, "%s{outcome=\"%s\"} %d\n", name, value.name, value.value) + } +} + +func plainCounter(w http.ResponseWriter, name, help string, value uint64) { + fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, value) +} diff --git a/apps/api/internal/securityevents/metrics_test.go b/apps/api/internal/securityevents/metrics_test.go new file mode 100644 index 0000000..1ba6d30 --- /dev/null +++ b/apps/api/internal/securityevents/metrics_test.go @@ -0,0 +1,50 @@ +package securityevents + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type metricsSnapshot struct { + mode string + last time.Time +} + +func (m metricsSnapshot) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) { + return m.mode, &m.last, nil +} + +func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) { + metrics := &Metrics{} + metrics.ObserveReceipt("accepted", 20*time.Millisecond, 2) + metrics.ObserveReceipt("duplicate", 10*time.Millisecond, 0) + metrics.ObserveWatermarkRejection() + metrics.ObserveHeartbeat("accepted") + metrics.ObserveIntrospection("failed") + metrics.ObserveJWKSRefreshFailure("ssf") + + recorder := httptest.NewRecorder() + metrics.Handler(metricsSnapshot{mode: "push_healthy", last: time.Now().Add(-time.Minute)}, "issuer", "audience", true). + ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("metrics status=%d", recorder.Code) + } + for _, expected := range []string{ + `easyai_gateway_ssf_receipts_total{outcome="accepted"} 1`, + `easyai_gateway_ssf_receipts_total{outcome="duplicate"} 1`, + `easyai_gateway_ssf_sessions_deleted_total 2`, + `easyai_gateway_ssf_watermark_rejections_total 1`, + `easyai_gateway_ssf_processing_duration_seconds_bucket{le="0.05"} 2`, + `easyai_gateway_ssf_mode{mode="push_healthy"} 1`, + `easyai_gateway_oidc_introspection_total{outcome="failed"} 1`, + `easyai_gateway_jwks_refresh_failures_total{outcome="ssf"} 1`, + } { + if !strings.Contains(recorder.Body.String(), expected) { + t.Fatalf("missing metric %q in:\n%s", expected, recorder.Body.String()) + } + } +} diff --git a/apps/api/internal/securityevents/service.go b/apps/api/internal/securityevents/service.go new file mode 100644 index 0000000..6814161 --- /dev/null +++ b/apps/api/internal/securityevents/service.go @@ -0,0 +1,211 @@ +package securityevents + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type StateRepository interface { + EnsureSecurityEventStreamState(context.Context, string, string, string) error + BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error + RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error + AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error + EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) +} + +type ServiceConfig struct { + Issuer string + Audience string + StreamID string + ManagementTokenURL string + ManagementClientID string + ManagementSecret string + HeartbeatInterval time.Duration + StaleAfter time.Duration + HTTPClient *http.Client +} + +type Service struct { + repository StateRepository + config ServiceConfig + client *http.Client + clock func() time.Time + tokenMu sync.Mutex + token string + tokenUntil time.Time + metrics *Metrics +} + +func (s *Service) SetMetrics(metrics *Metrics) { s.metrics = metrics } + +func NewService(repository StateRepository, config ServiceConfig) (*Service, error) { + if repository == nil || config.Issuer == "" || config.Audience == "" || config.StreamID == "" || + config.ManagementTokenURL == "" || config.ManagementClientID == "" || config.ManagementSecret == "" { + return nil, errors.New("SSF heartbeat configuration is incomplete") + } + if config.HeartbeatInterval <= 0 || config.StaleAfter < 2*config.HeartbeatInterval { + return nil, errors.New("SSF heartbeat timing is invalid") + } + client := config.HTTPClient + if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + transport.DisableCompression = true + client = &http.Client{Timeout: 10 * time.Second, + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + } + return &Service{repository: repository, config: config, client: client, clock: time.Now}, nil +} + +func (s *Service) Start(ctx context.Context) error { + if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil { + return err + } + go s.run(ctx) + return nil +} + +func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) { + result, err := s.repository.EvaluateOIDCSecurityEvent( + ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject, + identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter, + ) + if err != nil { + return auth.OIDCSecurityEventEvaluation{}, err + } + if result.Revoked && s.metrics != nil { + s.metrics.ObserveWatermarkRejection() + } + return auth.OIDCSecurityEventEvaluation{Revoked: result.Revoked, RequireIntrospection: result.RequireIntrospection}, nil +} + +func (s *Service) run(ctx context.Context) { + s.sendVerification(ctx) + ticker := time.NewTicker(s.config.HeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sendVerification(ctx) + } + } +} + +func (s *Service) sendVerification(ctx context.Context) { + outcome := "failed" + defer func() { + if s.metrics != nil { + s.metrics.ObserveHeartbeat(outcome) + } + }() + if err := s.repository.AdvanceSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.clock().UTC(), s.config.StaleAfter); err != nil { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_advance_failed") + } + state, err := randomVerificationState() + if err != nil { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_generation_failed") + return + } + hash := sha256.Sum256([]byte(state)) + now := s.clock().UTC() + if err := s.repository.BeginSecurityEventVerification(ctx, s.config.Issuer, s.config.Audience, hash[:], now); err != nil { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "state_persistence_failed") + return + } + token, err := s.managementToken(ctx) + if err != nil { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "management_token_failed") + return + } + payload, _ := json.Marshal(map[string]string{"stream_id": s.config.StreamID, "state": state}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.config.Issuer, "/")+"/v1/verify", bytes.NewReader(payload)) + if err != nil { + return + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("Content-Type", "application/json") + response, err := s.client.Do(request) + if err != nil { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, "verification_request_failed") + return + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + if response.StatusCode != http.StatusNoContent { + _ = s.repository.RecordSecurityEventHeartbeatFailure(ctx, s.config.Issuer, s.config.Audience, fmt.Sprintf("verification_http_%d", response.StatusCode)) + return + } + outcome = "accepted" +} + +func (s *Service) managementToken(ctx context.Context) (string, error) { + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + if s.token != "" && s.clock().Before(s.tokenUntil) { + return s.token, nil + } + form := url.Values{"grant_type": {"client_credentials"}, "scope": {"ssf.stream.read ssf.stream.verify"}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.ManagementTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.SetBasicAuth(s.config.ManagementClientID, s.config.ManagementSecret) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := s.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return "", fmt.Errorf("management token endpoint returned HTTP %d", response.StatusCode) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1)) + if err != nil || len(payload) > 64*1024 { + return "", errors.New("management token response is too large") + } + var result struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + } + if json.Unmarshal(payload, &result) != nil || result.AccessToken == "" || !strings.EqualFold(result.TokenType, "Bearer") { + return "", errors.New("management token response is invalid") + } + if result.ExpiresIn <= 0 { + result.ExpiresIn = 60 + } + cacheFor := time.Duration(result.ExpiresIn) * time.Second + if cacheFor > 30*time.Second { + cacheFor -= 30 * time.Second + } + s.token, s.tokenUntil = result.AccessToken, s.clock().Add(cacheFor) + return s.token, nil +} + +func randomVerificationState() (string, error) { + payload := make([]byte, 32) + if _, err := rand.Read(payload); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload), nil +} diff --git a/apps/api/internal/securityevents/service_test.go b/apps/api/internal/securityevents/service_test.go new file mode 100644 index 0000000..d7512c5 --- /dev/null +++ b/apps/api/internal/securityevents/service_test.go @@ -0,0 +1,86 @@ +package securityevents + +import ( + "context" + "crypto/sha256" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type fakeStateRepository struct { + mutex sync.Mutex + stateHash []byte + verification chan struct{} + evaluation store.SecurityEventEvaluation +} + +func (*fakeStateRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error { + return nil +} +func (f *fakeStateRepository) BeginSecurityEventVerification(_ context.Context, _, _ string, hash []byte, _ time.Time) error { + f.mutex.Lock() + f.stateHash = append([]byte(nil), hash...) + f.mutex.Unlock() + return nil +} +func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error { + return nil +} +func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error { + return nil +} +func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { + return f.evaluation, nil +} + +func TestServiceRequestsVerificationWithMachineTokenAndPersistsStateFirst(t *testing.T) { + repository := &fakeStateRepository{verification: make(chan struct{}, 1)} + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + clientID, secret, ok := r.BasicAuth() + if !ok || clientID != "gateway-ssf" || secret != "management-secret" { + t.Fatal("machine client authentication is missing") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"management-token","token_type":"Bearer","expires_in":300}`)) + case "/ssf/v1/verify": + if r.Header.Get("Authorization") != "Bearer management-token" { + t.Fatal("verification bearer is missing") + } + repository.mutex.Lock() + persisted := len(repository.stateHash) == sha256.Size + repository.mutex.Unlock() + if !persisted { + t.Fatal("verification state was not persisted before request") + } + w.WriteHeader(http.StatusNoContent) + repository.verification <- struct{}{} + } + })) + defer server.Close() + service, err := NewService(repository, ServiceConfig{ + Issuer: server.URL + "/ssf", Audience: "urn:easyai:ssf:receiver:app", StreamID: "00000000-0000-4000-8000-000000000001", + ManagementTokenURL: server.URL + "/token", ManagementClientID: "gateway-ssf", ManagementSecret: "management-secret", + HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := service.Start(ctx); err != nil { + t.Fatal(err) + } + select { + case <-repository.verification: + case <-time.After(2 * time.Second): + t.Fatal("verification was not requested") + } +} diff --git a/apps/api/internal/securityevents/verifier.go b/apps/api/internal/securityevents/verifier.go new file mode 100644 index 0000000..415c5ac --- /dev/null +++ b/apps/api/internal/securityevents/verifier.go @@ -0,0 +1,412 @@ +package securityevents + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification" + StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated" +) + +type Event struct { + Issuer string + Audience string + JTI string + TransactionID string + IssuedAt time.Time + EventType string + EventTimestamp time.Time + SubjectIssuer string + Subject string + TenantID string + InitiatingEntity string + StreamID string + State string + StreamStatus string + Reason string +} + +type VerifierConfig struct { + TransmitterIssuer string + Audience string + SubjectIssuer string + TenantID string + StreamID string + ClockSkew time.Duration + HTTPClient *http.Client +} + +type Verifier struct { + config VerifierConfig + client *http.Client + mu sync.Mutex + keys map[string]*ecdsa.PublicKey + expiresAt time.Time + metrics *Metrics + clock func() time.Time +} + +func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics } + +type transmitterMetadata struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` +} + +type jwkSet struct { + Keys []struct { + KID string `json:"kid"` + KTY string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` +} + +type protocolError struct { + Code string +} + +func (e *protocolError) Error() string { return e.Code } + +func NewVerifier(config VerifierConfig) (*Verifier, error) { + config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/") + config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/") + if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" { + return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required") + } + if _, err := uuid.Parse(config.StreamID); err != nil { + return nil, errors.New("SSF stream id must be a UUID") + } + if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute { + return nil, errors.New("SSF clock skew is invalid") + } + client := config.HTTPClient + if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + transport.DisableCompression = true + client = &http.Client{Timeout: 10 * time.Second, + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + } + return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil +} + +func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) { + parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"})) + unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) + if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" { + return Event{}, &protocolError{Code: "invalid_request"} + } + unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims) + if !ok { + return Event{}, &protocolError{Code: "invalid_request"} + } + if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer { + return Event{}, &protocolError{Code: "invalid_issuer"} + } + if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) { + return Event{}, &protocolError{Code: "invalid_audience"} + } + kid, _ := unverified.Header["kid"].(string) + if kid == "" { + return Event{}, &protocolError{Code: "invalid_key"} + } + key, err := v.key(ctx, kid) + if err != nil { + return Event{}, &protocolError{Code: "invalid_key"} + } + token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil }, + jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer), + jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew)) + if err != nil || !token.Valid { + if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return Event{}, &protocolError{Code: "invalid_key"} + } + return Event{}, &protocolError{Code: "invalid_request"} + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return Event{}, &protocolError{Code: "invalid_request"} + } + if _, present := claims["sub"]; present { + return Event{}, &protocolError{Code: "invalid_request"} + } + if _, present := claims["exp"]; present { + return Event{}, &protocolError{Code: "invalid_request"} + } + jti := stringClaim(claims, "jti") + if _, err := uuid.Parse(jti); err != nil { + return Event{}, &protocolError{Code: "invalid_request"} + } + issuedAt, err := claims.GetIssuedAt() + if err != nil || issuedAt == nil { + return Event{}, &protocolError{Code: "invalid_request"} + } + events, ok := claims["events"].(map[string]any) + if !ok || len(events) != 1 { + return Event{}, &protocolError{Code: "invalid_request"} + } + event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time} + if payload, present := events[SessionRevokedEventType]; present { + value, ok := payload.(map[string]any) + if !ok || event.TransactionID == "" { + return Event{}, &protocolError{Code: "invalid_request"} + } + if _, err := uuid.Parse(event.TransactionID); err != nil { + return Event{}, &protocolError{Code: "invalid_request"} + } + event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"]) + if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) || + !validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) { + return Event{}, &protocolError{Code: "invalid_request"} + } + return event, nil + } + if payload, present := events[VerificationEventType]; present { + value, ok := payload.(map[string]any) + if !ok || !v.readStreamSubject(claims["sub_id"], &event) { + return Event{}, &protocolError{Code: "invalid_request"} + } + event.EventType = VerificationEventType + if rawState, present := value["state"]; present { + var valid bool + event.State, valid = rawState.(string) + if !valid || len(event.State) > 1024 { + return Event{}, &protocolError{Code: "invalid_request"} + } + } + if len(event.State) > 1024 { + return Event{}, &protocolError{Code: "invalid_request"} + } + return event, nil + } + if payload, present := events[StreamUpdatedEventType]; present { + value, ok := payload.(map[string]any) + if !ok || !v.readStreamSubject(claims["sub_id"], &event) { + return Event{}, &protocolError{Code: "invalid_request"} + } + event.EventType = StreamUpdatedEventType + event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"]) + if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" { + return Event{}, &protocolError{Code: "invalid_request"} + } + if len(event.Reason) > 1024 { + return Event{}, &protocolError{Code: "invalid_request"} + } + return event, nil + } + return Event{}, &protocolError{Code: "invalid_request"} +} + +func claimHasAudience(raw any, expected string) bool { + switch value := raw.(type) { + case string: + return value == expected + case []string: + for _, audience := range value { + if audience == expected { + return true + } + } + case []any: + for _, item := range value { + if audience, ok := item.(string); ok && audience == expected { + return true + } + } + } + return false +} + +func (v *Verifier) now() time.Time { + if v.clock != nil { + return v.clock().UTC() + } + return time.Now().UTC() +} + +func (v *Verifier) readSessionSubject(raw any, event *Event) bool { + subject, ok := raw.(map[string]any) + if !ok || stringValue(subject["format"]) != "complex" { + return false + } + user, userOK := subject["user"].(map[string]any) + tenant, tenantOK := subject["tenant"].(map[string]any) + if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" { + return false + } + event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"]) + if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID { + return false + } + _, err := uuid.Parse(event.Subject) + return err == nil +} + +func (v *Verifier) readStreamSubject(raw any, event *Event) bool { + subject, ok := raw.(map[string]any) + if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID { + return false + } + event.StreamID = v.config.StreamID + return true +} + +func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) { + v.mu.Lock() + if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) { + v.mu.Unlock() + return key, nil + } + v.mu.Unlock() + if err := v.refresh(ctx); err != nil { + if v.metrics != nil { + v.metrics.ObserveJWKSRefreshFailure("ssf") + } + return nil, err + } + v.mu.Lock() + defer v.mu.Unlock() + key := v.keys[kid] + if key == nil { + return nil, errors.New("SSF signing key is unavailable") + } + return key, nil +} + +func (v *Verifier) refresh(ctx context.Context) error { + v.mu.Lock() + defer v.mu.Unlock() + configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer) + if err != nil { + return err + } + var metadata transmitterMetadata + if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil { + return err + } + if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) { + return errors.New("SSF metadata is not bound to configured issuer") + } + var set jwkSet + if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil { + return err + } + keys := make(map[string]*ecdsa.PublicKey) + for _, item := range set.Keys { + if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" { + continue + } + x, xErr := decodeCoordinate(item.X) + y, yErr := decodeCoordinate(item.Y) + if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) { + keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} + } + } + if len(keys) == 0 { + return errors.New("SSF JWKS contains no ES256 keys") + } + v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute) + return nil +} + +func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + response, err := v.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil || len(payload) > 1<<20 { + return errors.New("SSF endpoint response is too large") + } + if err := json.Unmarshal(payload, target); err != nil { + return errors.New("SSF endpoint returned invalid JSON") + } + return nil +} + +func ssfConfigurationURL(issuer string) (string, error) { + parsed, err := url.Parse(issuer) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errors.New("SSF issuer is invalid") + } + path := strings.TrimSuffix(parsed.EscapedPath(), "/") + parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, "" + parsed.RawQuery, parsed.Fragment = "", "" + return parsed.String(), nil +} + +func sameOrigin(candidate, issuer string) bool { + candidateURL, candidateErr := url.Parse(candidate) + issuerURL, issuerErr := url.Parse(issuer) + return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == "" +} + +func absoluteHTTPURL(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == "" +} + +func decodeCoordinate(raw string) (*big.Int, error) { + value, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil || len(value) != 32 { + return nil, errors.New("invalid EC coordinate") + } + return new(big.Int).SetBytes(value), nil +} + +func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) } + +func stringValue(value any) string { + result, _ := value.(string) + return result +} + +func unixClaim(value any) time.Time { + switch typed := value.(type) { + case float64: + return time.Unix(int64(typed), 0).UTC() + case json.Number: + integer, err := typed.Int64() + if err == nil { + return time.Unix(integer, 0).UTC() + } + } + return time.Time{} +} + +func validInitiator(value string) bool { + return value == "admin" || value == "user" || value == "policy" || value == "system" +} diff --git a/apps/api/internal/store/security_events.go b/apps/api/internal/store/security_events.go new file mode 100644 index 0000000..5ae18ed --- /dev/null +++ b/apps/api/internal/store/security_events.go @@ -0,0 +1,341 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +type ApplySessionRevokedInput struct { + Issuer string + Audience string + JTI string + TransactionID string + SubjectIssuer string + TenantID string + Subject string + EventTimestamp time.Time + InitiatingEntity string +} + +type ApplySecurityEventResult struct { + Duplicate bool + WatermarkMoved bool + SessionsDeleted int64 + AuditID string +} + +type SecurityEventEvaluation struct { + Revoked bool + RequireIntrospection bool + Mode string +} + +func (s *Store) ApplySessionRevoked(ctx context.Context, input ApplySessionRevokedInput) (ApplySecurityEventResult, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return ApplySecurityEventResult{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + subjectHash := shortSecurityEventHash(input.Subject) + tag, err := tx.Exec(ctx, ` +INSERT INTO gateway_security_event_receipts ( + issuer, jti, audience, event_type, transaction_id, tenant_id, subject_hash, event_timestamp +) +VALUES ($1, $2::uuid, $3, $4, NULLIF($5, ''), $6, $7, $8) +ON CONFLICT (issuer, jti) DO NOTHING`, + input.Issuer, input.JTI, input.Audience, + "https://schemas.openid.net/secevent/caep/event-type/session-revoked", + input.TransactionID, input.TenantID, subjectHash, input.EventTimestamp, + ) + if err != nil { + return ApplySecurityEventResult{}, err + } + if tag.RowsAffected() == 0 { + if err := tx.Commit(ctx); err != nil { + return ApplySecurityEventResult{}, err + } + return ApplySecurityEventResult{Duplicate: true}, nil + } + + var revokedAt time.Time + err = tx.QueryRow(ctx, ` +INSERT INTO gateway_oidc_revocation_watermarks (issuer, tenant_id, subject, revoked_at, source_jti) +VALUES ($1, $2, $3, $4, $5::uuid) +ON CONFLICT (issuer, tenant_id, subject) DO UPDATE +SET revoked_at = EXCLUDED.revoked_at, source_jti = EXCLUDED.source_jti, updated_at = now() +WHERE EXCLUDED.revoked_at > gateway_oidc_revocation_watermarks.revoked_at +RETURNING revoked_at`, input.SubjectIssuer, input.TenantID, input.Subject, input.EventTimestamp, input.JTI).Scan(&revokedAt) + watermarkMoved := err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return ApplySecurityEventResult{}, err + } + var sessionsDeleted int64 + if watermarkMoved { + tag, err = tx.Exec(ctx, ` +DELETE FROM gateway_oidc_sessions session +USING gateway_users gateway_user +WHERE session.gateway_user_id = gateway_user.id + AND gateway_user.source = 'oidc' + AND gateway_user.external_user_id = $1 + AND gateway_user.tenant_id = $2`, input.Subject, input.TenantID) + if err != nil { + return ApplySecurityEventResult{}, err + } + sessionsDeleted = tag.RowsAffected() + } + jtiHash := shortSecurityEventHash(input.JTI) + audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{ + Category: "identity", Action: "identity.oidc_session.revoked", ActorSource: "ssf", + TargetType: "oidc_subject", TargetID: subjectHash, + AfterState: map[string]any{"watermarkMoved": watermarkMoved, "sessionsDeleted": sessionsDeleted}, + Metadata: map[string]any{ + "issuer": input.SubjectIssuer, "transmitterIssuer": input.Issuer, "tenantId": input.TenantID, "jtiHash": jtiHash, + "initiatingEntity": input.InitiatingEntity, + }, + }) + if err != nil { + return ApplySecurityEventResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return ApplySecurityEventResult{}, err + } + return ApplySecurityEventResult{WatermarkMoved: watermarkMoved, SessionsDeleted: sessionsDeleted, AuditID: audit.ID}, nil +} + +func (s *Store) EnsureSecurityEventStreamState(ctx context.Context, issuer, audience, streamID string) error { + tag, err := s.pool.Exec(ctx, ` +INSERT INTO gateway_security_event_stream_state(issuer,audience,stream_id,mode) +VALUES($1,$2,$3::uuid,'bootstrap') +ON CONFLICT(issuer,audience) DO UPDATE +SET stream_id=EXCLUDED.stream_id,updated_at=now() +WHERE gateway_security_event_stream_state.stream_id=EXCLUDED.stream_id`, issuer, audience, streamID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("security event stream id cannot be changed") + } + return nil +} + +func (s *Store) BeginSecurityEventVerification(ctx context.Context, issuer, audience string, stateHash []byte, now time.Time) error { + if len(stateHash) != sha256.Size { + return errors.New("verification state hash must be SHA-256") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET pending_state_hash=$3,pending_state_created_at=$4,updated_at=now() +WHERE issuer=$1 AND audience=$2`, issuer, audience, stateHash, now) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("security event stream state is missing") + } + if _, err := tx.Exec(ctx, ` +INSERT INTO gateway_security_event_verification_challenges(issuer,audience,state_hash,created_at,expires_at) +VALUES($1,$2,$3,$4,$4 + interval '180 seconds') +ON CONFLICT(issuer,audience,state_hash) DO NOTHING`, issuer, audience, stateHash, now); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges WHERE expires_at < $1`, now); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Store) ConfirmSecurityEventVerification(ctx context.Context, issuer, audience, streamID, jti string, stateHash []byte, now time.Time) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, ` +INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash) +VALUES($1,$2::uuid,$3,$4,$5) +ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience, + "https://schemas.openid.net/secevent/ssf/event-type/verification", shortSecurityEventHash(streamID)) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, tx.Commit(ctx) + } + tag, err = tx.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET last_verification_at=$5, + bootstrap_until=CASE WHEN last_verification_at IS NULL THEN $5 + interval '360 seconds' ELSE bootstrap_until END, + consecutive_verifications=CASE + WHEN stream_status <> 'enabled' THEN 0 + WHEN mode='introspection_fallback' THEN consecutive_verifications+1 + ELSE 1 + END, + mode=CASE + WHEN stream_status <> 'enabled' THEN mode + WHEN mode='introspection_fallback' AND consecutive_verifications+1 >= 2 + AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN 'push_healthy' + WHEN mode='introspection_fallback' THEN 'introspection_fallback' + WHEN mode='push_healthy' THEN 'push_healthy' + ELSE 'bootstrap' + END, + fallback_since=CASE + WHEN stream_status='enabled' AND mode='introspection_fallback' AND consecutive_verifications+1 >= 2 + AND (bootstrap_until IS NULL OR $5 >= bootstrap_until) THEN NULL + ELSE fallback_since + END, + pending_state_hash=NULL,pending_state_created_at=NULL, + last_error_category=CASE WHEN stream_status='enabled' THEN NULL ELSE 'stream_' || stream_status END, + updated_at=now() +WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid + AND EXISTS(SELECT 1 FROM gateway_security_event_verification_challenges challenge + WHERE challenge.issuer=$1 AND challenge.audience=$2 AND challenge.state_hash=$4 AND challenge.expires_at >= $5)`, + issuer, audience, streamID, stateHash, now) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, errors.New("invalid verification state") + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges + WHERE issuer=$1 AND audience=$2 AND state_hash=$3`, issuer, audience, stateHash); err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + +func (s *Store) RecordSecurityEventReceipt(ctx context.Context, issuer, audience, jti, eventType, subject string) (bool, error) { + tag, err := s.pool.Exec(ctx, ` +INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash) +VALUES($1,$2::uuid,$3,$4,$5) +ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience, eventType, shortSecurityEventHash(subject)) + return tag.RowsAffected() == 1, err +} + +// ApplySecurityEventStreamUpdated records a transmitter-originated stream +// status change and immediately moves OIDC traffic to introspection. Push is +// trusted again only after the normal consecutive-verification recovery path. +func (s *Store) ApplySecurityEventStreamUpdated(ctx context.Context, issuer, audience, jti, streamID, status, reason string, now time.Time) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, ` +INSERT INTO gateway_security_event_receipts(issuer,jti,audience,event_type,subject_hash) +VALUES($1,$2::uuid,$3,$4,$5) +ON CONFLICT(issuer,jti) DO NOTHING`, issuer, jti, audience, + "https://schemas.openid.net/secevent/ssf/event-type/stream-updated", shortSecurityEventHash(streamID)) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, tx.Commit(ctx) + } + tag, err = tx.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET stream_status=$5,mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$4), + consecutive_verifications=0,last_error_category=$6,updated_at=now() +WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid`, issuer, audience, streamID, now, status, "stream_"+status) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, errors.New("security event stream state is missing") + } + if _, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{ + Category: "identity", Action: "identity.ssf_stream.updated", ActorSource: "ssf", + TargetType: "ssf_stream", TargetID: shortSecurityEventHash(streamID), + AfterState: map[string]any{"status": status}, + Metadata: map[string]any{"issuer": issuer, "audience": audience, "reasonCategory": shortSecurityEventHash(reason)}, + }); err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + +func (s *Store) RecordSecurityEventHeartbeatFailure(ctx context.Context, issuer, audience, category string) error { + _, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state + SET last_error_category=$3,updated_at=now() WHERE issuer=$1 AND audience=$2`, issuer, audience, category) + return err +} + +func (s *Store) AdvanceSecurityEventStreamState(ctx context.Context, issuer, audience string, now time.Time, staleAfter time.Duration) error { + if staleAfter <= 0 { + return errors.New("security event stale threshold must be positive") + } + _, err := s.pool.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3), + consecutive_verifications=0,last_error_category='verification_stale',updated_at=now() +WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback' + AND COALESCE(last_verification_at,created_at) < $3 - make_interval(secs => $4::double precision)`, + issuer, audience, now, staleAfter.Seconds()) + return err +} + +func (s *Store) EvaluateOIDCSecurityEvent(ctx context.Context, transmitterIssuer, audience, subjectIssuer, tenantID, subject string, issuedAt, now time.Time, staleAfter time.Duration) (SecurityEventEvaluation, error) { + var mode, streamStatus string + var lastVerification, bootstrapUntil *time.Time + var createdAt time.Time + var revokedAt *time.Time + err := s.pool.QueryRow(ctx, ` +SELECT state.mode,state.stream_status,state.last_verification_at,state.bootstrap_until,state.created_at,watermark.revoked_at +FROM gateway_security_event_stream_state state +LEFT JOIN gateway_oidc_revocation_watermarks watermark + ON watermark.issuer=$3 AND watermark.tenant_id=$4 AND watermark.subject=$5 +WHERE state.issuer=$1 AND state.audience=$2`, transmitterIssuer, audience, subjectIssuer, tenantID, subject).Scan( + &mode, &streamStatus, &lastVerification, &bootstrapUntil, &createdAt, &revokedAt, + ) + if err != nil { + return SecurityEventEvaluation{}, err + } + evaluation := SecurityEventEvaluation{Mode: mode, Revoked: revokedAt != nil && !issuedAt.After(*revokedAt)} + stale := (lastVerification != nil && now.Sub(*lastVerification) > staleAfter) || + (lastVerification == nil && (mode != "bootstrap" || now.Sub(createdAt) > staleAfter)) + if stale { + evaluation.Mode, evaluation.RequireIntrospection = "introspection_fallback", true + _, err := s.pool.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3),consecutive_verifications=0, + last_error_category='verification_stale',updated_at=now() +WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback'`, transmitterIssuer, audience, now) + return evaluation, err + } + if mode == "bootstrap" { + if streamStatus != "enabled" || bootstrapUntil == nil || now.Before(*bootstrapUntil) { + evaluation.RequireIntrospection = true + return evaluation, nil + } + evaluation.Mode = "push_healthy" + _, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET mode='push_healthy',updated_at=now() +WHERE issuer=$1 AND audience=$2 AND mode='bootstrap'`, transmitterIssuer, audience) + return evaluation, err + } + evaluation.RequireIntrospection = mode == "introspection_fallback" + return evaluation, nil +} + +func (s *Store) SecurityEventMetrics(ctx context.Context, issuer, audience string) (string, *time.Time, error) { + var mode string + var lastVerificationAt *time.Time + err := s.pool.QueryRow(ctx, ` +SELECT mode,last_verification_at +FROM gateway_security_event_stream_state +WHERE issuer=$1 AND audience=$2`, issuer, audience).Scan(&mode, &lastVerificationAt) + return mode, lastVerificationAt, err +} + +func shortSecurityEventHash(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:])[:16] +} diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go new file mode 100644 index 0000000..f2d9952 --- /dev/null +++ b/apps/api/internal/store/security_events_integration_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "context" + "crypto/sha256" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run security event PostgreSQL integration tests") + } + ctx := context.Background() + probe, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + var databaseName string + if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil { + probe.Close() + t.Fatal(err) + } + probe.Close() + if !strings.Contains(strings.ToLower(databaseName), "test") { + t.Fatalf("refusing to migrate non-test database %q", databaseName) + } + applyOIDCJITTestMigrations(t, ctx, databaseURL) + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + issuer := "https://auth.test.example/ssf" + subjectIssuer := "https://auth.test/realms/tenant" + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + streamID, tenantID, subject := uuid.NewString(), uuid.NewString(), uuid.NewString() + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_receipts WHERE issuer=$1`, issuer) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_oidc_revocation_watermarks WHERE issuer=$1`, subjectIssuer) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, issuer) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_audit_logs WHERE actor_source='ssf' AND target_id=ANY($1)`, []string{shortSecurityEventHash(subject), shortSecurityEventHash(streamID)}) + }) + if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, streamID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" { + t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err) + } + + confirmAt := func(label string, at time.Time) { + t.Helper() + stateHash := sha256.Sum256([]byte(label)) + if err := db.BeginSecurityEventVerification(ctx, issuer, audience, stateHash[:], at); err != nil { + t.Fatal(err) + } + matched, err := db.ConfirmSecurityEventVerification(ctx, issuer, audience, streamID, uuid.NewString(), stateHash[:], at) + if err != nil || !matched { + t.Fatalf("confirm matched=%v error=%v", matched, err) + } + } + inserted, err := db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, uuid.NewString(), streamID, "enabled", "onboarding", now) + if err != nil || !inserted { + t.Fatalf("enable stream inserted=%v error=%v", inserted, err) + } + confirmAt("first-verification-state", now) + confirmAt("second-verification-state", now.Add(time.Minute)) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second) + if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { + t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err) + } + confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second)) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second) + if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" { + t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err) + } + streamUpdateJTI := uuid.NewString() + inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now.Add(362*time.Second)) + if err != nil || !inserted { + t.Fatalf("stream update inserted=%v error=%v", inserted, err) + } + inserted, err = db.ApplySecurityEventStreamUpdated(ctx, issuer, audience, streamUpdateJTI, streamID, "paused", "maintenance", now) + if err != nil || inserted { + t.Fatalf("duplicate stream update inserted=%v error=%v", inserted, err) + } + confirmAt("paused-verification-one", now.Add(363*time.Second)) + confirmAt("paused-verification-two", now.Add(364*time.Second)) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second) + if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { + t.Fatalf("stream update fallback=%#v error=%v", evaluation, err) + } + _, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET stream_status='enabled',mode='push_healthy',fallback_since=NULL, + consecutive_verifications=0,last_verification_at=$3 WHERE issuer=$1 AND audience=$2`, issuer, audience, now) + + revokedAt := now.Add(-10 * time.Second) + input := ApplySessionRevokedInput{Issuer: issuer, Audience: audience, JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer, TenantID: tenantID, Subject: subject, EventTimestamp: revokedAt, InitiatingEntity: "admin"} + result, err := db.ApplySessionRevoked(ctx, input) + if err != nil || !result.WatermarkMoved || result.AuditID == "" { + t.Fatalf("apply result=%#v error=%v", result, err) + } + duplicate, err := db.ApplySessionRevoked(ctx, input) + if err != nil || !duplicate.Duplicate { + t.Fatalf("duplicate result=%#v error=%v", duplicate, err) + } + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second) + if !evaluation.Revoked { + t.Fatal("token at watermark was accepted") + } + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second) + if evaluation.Revoked { + t.Fatal("watermark crossed OIDC issuer boundary") + } + evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + if evaluation.Revoked { + t.Fatal("token after watermark was rejected") + } + + _, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3 + WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second)) + evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) + if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { + t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err) + } +} diff --git a/apps/api/internal/store/security_events_test.go b/apps/api/internal/store/security_events_test.go new file mode 100644 index 0000000..c550c9f --- /dev/null +++ b/apps/api/internal/store/security_events_test.go @@ -0,0 +1,10 @@ +package store + +import "testing" + +func TestSecurityEventHashesAreStableAndSanitized(t *testing.T) { + first := shortSecurityEventHash("sensitive-subject") + if first != shortSecurityEventHash("sensitive-subject") || len(first) != 16 || first == "sensitive-subject" { + t.Fatalf("unsafe or unstable hash: %q", first) + } +} diff --git a/apps/api/migrations/0062_oidc_security_events.sql b/apps/api/migrations/0062_oidc_security_events.sql new file mode 100644 index 0000000..698d862 --- /dev/null +++ b/apps/api/migrations/0062_oidc_security_events.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS gateway_security_event_receipts ( + issuer text NOT NULL, + jti uuid NOT NULL, + audience text NOT NULL, + event_type text NOT NULL, + transaction_id text, + tenant_id text, + subject_hash text, + event_timestamp timestamptz, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (issuer, jti) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_receipts_received + ON gateway_security_event_receipts(received_at DESC); + +CREATE TABLE IF NOT EXISTS gateway_oidc_revocation_watermarks ( + issuer text NOT NULL, + tenant_id text NOT NULL, + subject text NOT NULL, + revoked_at timestamptz NOT NULL, + source_jti uuid NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (issuer, tenant_id, subject) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_oidc_revocation_watermarks_lookup + ON gateway_oidc_revocation_watermarks(issuer, tenant_id, subject, revoked_at); + +CREATE TABLE IF NOT EXISTS gateway_security_event_stream_state ( + issuer text NOT NULL, + audience text NOT NULL, + stream_id uuid NOT NULL, + stream_status text NOT NULL DEFAULT 'unknown', + mode text NOT NULL DEFAULT 'bootstrap', + last_verification_at timestamptz, + bootstrap_until timestamptz, + consecutive_verifications integer NOT NULL DEFAULT 0, + pending_state_hash bytea, + pending_state_created_at timestamptz, + fallback_since timestamptz, + last_error_category text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (issuer, audience), + UNIQUE (stream_id), + CONSTRAINT gateway_security_event_stream_state_mode + CHECK (mode IN ('bootstrap','push_healthy','introspection_fallback')), + CONSTRAINT gateway_security_event_stream_state_status + CHECK (stream_status IN ('unknown','enabled','paused','disabled')), + CONSTRAINT gateway_security_event_stream_state_pending_hash + CHECK (pending_state_hash IS NULL OR octet_length(pending_state_hash) = 32) +); + +CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges ( + issuer text NOT NULL, + audience text NOT NULL, + state_hash bytea NOT NULL, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (issuer, audience, state_hash), + FOREIGN KEY (issuer, audience) + REFERENCES gateway_security_event_stream_state(issuer, audience) ON DELETE CASCADE, + CONSTRAINT gateway_security_event_verification_challenge_hash + CHECK (octet_length(state_hash) = 32) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry + ON gateway_security_event_verification_challenges(expires_at); diff --git a/docker-compose.yml b/docker-compose.yml index 67df0f4..eda6f9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,22 @@ x-api-environment: &api-environment OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access} OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300} OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true} + OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false} + OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-} + OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-} + OIDC_SECURITY_EVENTS_ENABLED: ${OIDC_SECURITY_EVENTS_ENABLED:-false} + OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER: ${OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER:-} + OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE: ${OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE:-} + OIDC_SECURITY_EVENTS_BEARER_SECRET: ${OIDC_SECURITY_EVENTS_BEARER_SECRET:-} + OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT: ${OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT:-} + OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT: ${OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT:-} + OIDC_SECURITY_EVENTS_STREAM_ID: ${OIDC_SECURITY_EVENTS_STREAM_ID:-} + OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL: ${OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL:-} + OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID:-} + OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET:-} + OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} + OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} + OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false} OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-} OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} diff --git a/docker/nginx.conf b/docker/nginx.conf index 6b892c3..510bd73 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -10,6 +10,29 @@ server { return 308 /gateway-api/; } + # RFC 8935 receiver: exact path, no redirect, short bounded upstream timeout. + location = /api/v1/security-events/ssf { + client_max_body_size 64k; + client_body_buffer_size 64k; + client_body_timeout 5s; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_connect_timeout 3s; + proxy_read_timeout 10s; + proxy_send_timeout 10s; + proxy_redirect off; + proxy_pass http://api:8088; + } + + # Metrics stay on the API's monitoring network and are not exposed by Web. + location = /gateway-api/metrics { + return 404; + } + location /gateway-api/ { proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md new file mode 100644 index 0000000..f2cc589 --- /dev/null +++ b/docs/security/ssf-session-revocation.md @@ -0,0 +1,56 @@ +# SSF/CAEP 实时会话撤销运行手册 + +Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。默认关闭;关闭时不会注册接收路由或启动状态机,也不会改变 OIDC、BFF、API Key 或 Legacy JWT 行为。 + +## 启用前提 + +- Auth Center 已创建属于当前 Application/租户的 paused Stream,Audience 为 `urn:easyai:ssf:receiver:{application-public-id}`。 +- Gateway OIDC `iss`、`tid` 与 Stream 绑定完全一致;Stream ID 为公开 UUID,不使用认证内核 ID。 +- 接收 Bearer、Management Client Secret、RFC 7662 Client Secret 仅从 Git 忽略的 `.env`、Kubernetes Secret 或 Secret Manager 注入。 +- `OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT` 必须是外部可访问的精确 HTTPS 地址;本地测试环境才允许 localhost HTTP。 +- Management Client 至少有 `ssf.stream.read ssf.stream.verify`,不能使用浏览器 PKCE Client。 + +完整配置键见仓库根目录 `.env.example`。启用时 `OIDC_SECURITY_EVENTS_ENABLED=true`,且 RFC 7662 Client ID/Secret 必须同时存在,否则启动失败。 + +## 启用顺序 + +1. 运行数据库迁移,部署仍保持功能关闭。 +2. 在 Secret Store 创建接收 Bearer、Management Client Secret 和内省 Client Secret;不要把值放进日志或工单。 +3. 启用 Gateway。启动模式为 `bootstrap`,所有 OIDC Token 都先内省。 +4. 在 Auth Center 对 paused Stream 发起 Verification,确认 Gateway 返回空 Body 的 `202`。 +5. 启用 Stream。首次 Verification 成功后仍内省 360 秒,覆盖启用前已签发的最长 300 秒 Token。 +6. 观察 `easyai_gateway_ssf_mode{mode="push_healthy"}` 变为 1,再执行测试用户撤销。 + +健康时 Gateway 每 60 秒请求一次 Verification,不按业务 QPS 请求 Auth Center。180 秒没有匹配的有效 Verification 会进入 `introspection_fallback`;只有最近收到的 Stream 状态为 `enabled` 时,连续两次 Verification 成功才会恢复,`paused`/`disabled` 状态下心跳不会错误恢复推送信任。降级期间内省不可用会对 OIDC 返回可审计的 503,API Key 继续工作。 + +## 零停机 Bearer 轮换 + +1. 在 Gateway 写入 `OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT`,同时接受 current/next。 +2. 更新 Auth Center 的 Stream `credential_ref` 指向 next。 +3. 发起 Verification,并确认投递和状态机健康。 +4. 等待至少 180 秒后,将 next 提升为 current 并清空旧值。 + +Gateway 使用常量时间比较两个值。响应、日志、审计和指标都不得包含 Authorization Header 或原始 SET。 + +## 监控与告警 + +`GET /metrics` 输出以下低基数指标;生产入口应仅允许监控网络访问该路径: + +- `easyai_gateway_ssf_receipts_total{outcome}`:accepted/rejected/duplicate。 +- `easyai_gateway_ssf_sessions_deleted_total`:被撤销删除的 BFF Session。 +- `easyai_gateway_ssf_watermark_rejections_total`:本地水位拒绝的 Token。 +- `easyai_gateway_ssf_processing_duration_seconds`:Receiver 数据库事务处理直方图,用于 P99 告警。 +- `easyai_gateway_ssf_verification_age_seconds` 和 `easyai_gateway_ssf_mode{mode}`。 +- `easyai_gateway_ssf_heartbeat_requests_total{outcome}`。 +- `easyai_gateway_oidc_introspection_total{outcome}`。 +- `easyai_gateway_jwks_refresh_failures_total{source}`:SSF/OIDC JWKS 刷新失败。 + +至少配置以下告警:Verification age 超过 120 秒、fallback 超过 5 分钟、内省 failed 增长、SET rejected 增长、撤销端到端 P99 超过 3 秒。日志只能携带脱敏 Trace ID、Audit ID 和错误类别。 + +## 回滚 + +先在 Auth Center 暂停 Stream,再保持 Gateway 启用但持续使用 RFC 7662,确认没有遗漏撤销后才关闭接收功能。保留迁移表和 Secret,不做破坏性数据库回滚。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。 + +## 验收证据 + +真实链路验收只能使用明确标记为测试用途的 Application,且必须在自动化测试通过后执行。报告需包含脱敏 Trace、Claims 结构、截图、Audit ID、投递延迟、fallback 与恢复证据。涉及人工、真实设备或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`,Mock、契约和安全测试不得跳过。 From 9efeb16fd14995e5ed997298b4ea20f00d71dd43 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Wed, 15 Jul 2026 17:25:00 +0800 Subject: [PATCH 02/20] =?UTF-8?q?feat(ssf):=20=E5=AE=9E=E7=8E=B0=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BA=8B=E4=BB=B6=E4=B8=80=E9=94=AE=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E4=B8=8E=E5=87=AD=E6=8D=AE=E6=89=98=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。 增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。 --- .env.example | 23 +- .gitignore | 1 + README.md | 2 +- apps/api/internal/auth/oidc.go | 14 +- apps/api/internal/auth/oidc_test.go | 17 +- apps/api/internal/config/config.go | 134 +-- apps/api/internal/config/config_test.go | 67 +- .../security_event_connection_handlers.go | 234 +++++ ...security_event_connection_handlers_test.go | 51 + apps/api/internal/httpapi/security_events.go | 4 + apps/api/internal/httpapi/server.go | 78 +- .../securityevents/connection_manager.go | 940 ++++++++++++++++++ .../securityevents/connection_manager_test.go | 293 ++++++ .../securityevents/kubernetes_secret_store.go | 186 ++++ .../kubernetes_secret_store_test.go | 95 ++ apps/api/internal/securityevents/metrics.go | 27 + .../internal/securityevents/secret_store.go | 112 +++ .../securityevents/secret_store_test.go | 40 + apps/api/internal/securityevents/service.go | 15 +- .../store/security_event_connections.go | 175 ++++ .../0063_security_event_connections.sql | 40 + apps/web/src/App.tsx | 60 +- apps/web/src/api.test.ts | 24 + apps/web/src/api.ts | 35 + apps/web/src/app-state.ts | 2 + apps/web/src/pages/AdminPage.tsx | 11 + .../src/pages/admin/SystemSettingsPanel.tsx | 132 ++- .../security-events-secret-rbac.yaml | 34 + docker-compose.yml | 12 +- docs/security/ssf-session-revocation.md | 85 +- packages/contracts/src/index.ts | 28 + 31 files changed, 2741 insertions(+), 230 deletions(-) create mode 100644 apps/api/internal/httpapi/security_event_connection_handlers.go create mode 100644 apps/api/internal/httpapi/security_event_connection_handlers_test.go create mode 100644 apps/api/internal/securityevents/connection_manager.go create mode 100644 apps/api/internal/securityevents/connection_manager_test.go create mode 100644 apps/api/internal/securityevents/kubernetes_secret_store.go create mode 100644 apps/api/internal/securityevents/kubernetes_secret_store_test.go create mode 100644 apps/api/internal/securityevents/secret_store.go create mode 100644 apps/api/internal/securityevents/secret_store_test.go create mode 100644 apps/api/internal/store/security_event_connections.go create mode 100644 apps/api/migrations/0063_security_event_connections.sql create mode 100644 deploy/kubernetes/security-events-secret-rbac.yaml diff --git a/.env.example b/.env.example index 25fd408..5fe82c3 100644 --- a/.env.example +++ b/.env.example @@ -35,19 +35,16 @@ OIDC_ACCEPT_LEGACY_HS256=true OIDC_INTROSPECTION_ENABLED=false OIDC_INTROSPECTION_CLIENT_ID= OIDC_INTROSPECTION_CLIENT_SECRET= -# Optional SSF/CAEP real-time revocation receiver. Enabling it also requires -# the RFC 7662 credentials above so bootstrap and fail-closed fallback work. -OIDC_SECURITY_EVENTS_ENABLED=false -OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER=https://auth.51easyai.com/ssf -OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE= -OIDC_SECURITY_EVENTS_BEARER_SECRET= -# During zero-downtime rotation, set next before removing current. -OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT= -OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT=https://api.51easyai.com/api/v1/security-events/ssf -OIDC_SECURITY_EVENTS_STREAM_ID= -OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL= -OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID= -OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET= +# SSF/CAEP is activated by the durable connection created in System Settings; +# there is no enable flag and no Push Bearer in environment variables. +AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088 +OIDC_SECURITY_EVENTS_SECRET_STORE=file +OIDC_SECURITY_EVENTS_SECRET_DIR=.local-secrets/ssf +# Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC. +# OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes +# OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai +# OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events +# OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER=https://kubernetes.default.svc OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 diff --git a/.gitignore b/.gitignore index 6ff9836..5121d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ .DS_Store .env .env.local +.local-secrets/ .gateway-local-password *.log diff --git a/README.md b/README.md index d4561c0..5e33e10 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ OIDC_SESSION_ENCRYPTION_KEY= Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 -可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 +可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。功能由“系统设置”中的持久连接启用,不使用环境变量开关;Gateway 后端自动生成和托管 Push Bearer,并复用现有 RFC 7662 机器 Client。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 `pnpm dev` 会先创建数据库并执行 migration,然后并行启动: diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index df62e60..39690d4 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -48,6 +48,7 @@ type OIDCSecurityEventIdentity struct { } type OIDCSecurityEventEvaluation struct { + Enabled bool Revoked bool RequireIntrospection bool } @@ -91,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { return nil, errors.New("issuer, audience, tenant and role prefix are required") } - if (config.IntrospectionEnabled || config.SecurityEventEvaluator != nil) && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { + if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { return nil, errors.New("introspection client credentials are required when introspection is enabled") } if config.JWKSCacheTTL <= 0 { @@ -145,9 +146,6 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { return nil, oidcUnauthorized("nbf is missing", nil) } issuedAt, hasIssuedAt := numericDateClaim(claims["iat"]) - if v.config.SecurityEventEvaluator != nil && !hasIssuedAt { - return nil, oidcUnauthorized("iat is required when security events are enabled", nil) - } scopes := scopeClaims(claims) if !containsAll(scopes, v.config.RequiredScopes) { return nil, oidcUnauthorized("required scope is missing", nil) @@ -166,6 +164,9 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { if evaluateErr != nil { return nil, NewRequestAuthError(http.StatusServiceUnavailable, "OIDC_SECURITY_EVENT_STATE_UNAVAILABLE", "认证撤销状态暂时不可用") } + if evaluation.Enabled && !hasIssuedAt { + return nil, oidcUnauthorized("iat is required when security events are enabled", nil) + } if evaluation.Revoked { return nil, oidcUnauthorized("token was issued before the revocation watermark", nil) } @@ -177,6 +178,11 @@ func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { if !active { return nil, oidcUnauthorized("token is inactive", nil) } + } else if !evaluation.Enabled && v.config.IntrospectionEnabled { + active, introspectionErr := v.introspect(ctx, raw) + if introspectionErr != nil || !active { + return nil, oidcUnauthorized("token is inactive", introspectionErr) + } } } else if v.config.IntrospectionEnabled { active, err := v.introspect(ctx, raw) diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 16762a1..36ed7fd 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -179,9 +179,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes verifier, err := NewOIDCVerifier(OIDCConfig{ Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), + IntrospectionEnabled: true, IntrospectionClientID: "gateway-introspection", IntrospectionClientSecret: "introspection-secret", SecurityEventEvaluator: func(_ context.Context, identity OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) { - if identity.Subject != "platform-subject" || identity.IssuedAt.IsZero() { + if identity.Subject != "platform-subject" { t.Fatal("security event evaluator received incomplete identity") } return evaluation, nil @@ -194,6 +195,10 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 { t.Fatalf("fallback token error=%v calls=%d", err, introspectionCalls) } + evaluation = OIDCSecurityEventEvaluation{Enabled: true} + if _, err := verifier.Verify(context.Background(), raw); err != nil || introspectionCalls != 1 { + t.Fatalf("healthy push token error=%v calls=%d", err, introspectionCalls) + } evaluation = OIDCSecurityEventEvaluation{Revoked: true} if _, err := verifier.Verify(context.Background(), raw); err == nil || introspectionCalls != 1 { t.Fatalf("revoked token error=%v calls=%d", err, introspectionCalls) @@ -205,6 +210,16 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes if !errors.As(err, &requestError) || requestError.Status != http.StatusServiceUnavailable { t.Fatalf("introspection outage error=%T %v", err, err) } + withoutIssuedAt := signedOIDCToken(t, issuer, "ec-key", jwt.SigningMethodES256, key, func(claims jwt.MapClaims) { delete(claims, "iat") }) + evaluation = OIDCSecurityEventEvaluation{} + introspectionAvailable = true + if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err != nil { + t.Fatalf("unconfigured security events unexpectedly required iat: %v", err) + } + evaluation = OIDCSecurityEventEvaluation{Enabled: true} + if _, err := verifier.Verify(context.Background(), withoutIssuedAt); err == nil { + t.Fatal("configured security events accepted a token without iat") + } } func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string { diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 3819271..310291d 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -8,8 +8,6 @@ import ( "os" "strconv" "strings" - - "github.com/google/uuid" ) const ( @@ -38,16 +36,13 @@ type Config struct { OIDCIntrospectionEnabled bool OIDCIntrospectionClientID string OIDCIntrospectionClientSecret string - OIDCSecurityEventsEnabled bool - OIDCSecurityEventsTransmitterIssuer string - OIDCSecurityEventsReceiverAudience string - OIDCSecurityEventsBearerSecret string - OIDCSecurityEventsBearerSecretNext string - OIDCSecurityEventsPublicEndpoint string - OIDCSecurityEventsStreamID string - OIDCSecurityEventsManagementTokenURL string - OIDCSecurityEventsManagementClientID string - OIDCSecurityEventsManagementClientSecret string + OIDCSecurityEventsSecretStore string + OIDCSecurityEventsSecretDir string + OIDCSecurityEventsKubernetesNamespace string + OIDCSecurityEventsKubernetesSecretName string + OIDCSecurityEventsKubernetesAPIServer string + OIDCSecurityEventsKubernetesTokenFile string + OIDCSecurityEventsKubernetesCAFile string OIDCSecurityEventsHeartbeatIntervalSeconds int OIDCSecurityEventsStaleAfterSeconds int OIDCSecurityEventsClockSkewSeconds int @@ -80,6 +75,9 @@ type Config struct { func Load() Config { globalProxy := LoadGlobalHTTPProxyStatus() appEnv := env("APP_ENV", "development") + oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/") + introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "") + introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "") return Config{ AppEnv: appEnv, HTTPAddr: env("HTTP_ADDR", ":8088"), @@ -94,7 +92,7 @@ func Load() Config { ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), OIDCEnabled: env("OIDC_ENABLED", "false") == "true", - OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"), + OIDCIssuer: oidcIssuer, OIDCAudience: env("OIDC_AUDIENCE", ""), OIDCTenantID: env("OIDC_TENANT_ID", ""), OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), @@ -102,18 +100,15 @@ func Load() Config { OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", - OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""), - OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""), - OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true", - OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"), - OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""), - OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""), - OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""), - OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""), - OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""), - OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""), - OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""), - OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""), + OIDCIntrospectionClientID: introspectionClientID, + OIDCIntrospectionClientSecret: introspectionClientSecret, + OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"), + OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"), + OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), + OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"), + OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), + OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), + OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), @@ -149,6 +144,26 @@ func Load() Config { } func (c Config) Validate() error { + switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) { + case "": + case "file": + if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" { + return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore") + } + case "kubernetes": + if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" { + return errors.New("Kubernetes security event SecretStore requires namespace and Secret name") + } + default: + return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes") + } + if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 { + if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || + c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || + c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { + return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") + } + } if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" { return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true") } @@ -185,59 +200,9 @@ func (c Config) Validate() error { } } } - if c.OIDCSecurityEventsEnabled { - if !c.OIDCEnabled { - return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled") - } - if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) || - !validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) || - !validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) { - return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs") - } - if _, err := uuid.Parse(c.OIDCTenantID); err != nil { - return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled") - } - applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:") - if applicationID == c.OIDCSecurityEventsReceiverAudience { - return errors.New("OIDC security event receiver audience and stream ID are required") - } - if _, err := uuid.Parse(applicationID); err != nil { - return errors.New("OIDC security event receiver audience must contain an application UUID") - } - if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil { - return errors.New("OIDC security event stream ID must be a UUID") - } - if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) || - c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) { - return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters") - } - if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" { - return errors.New("OIDC security event management machine client credentials are required") - } - if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" { - return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled") - } - if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || - c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || - c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { - return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") - } - } return nil } -func validBearerSecret(value string) bool { - if len(value) < 16 || len(value) > 4096 { - return false - } - for _, character := range []byte(value) { - if character < 0x21 || character > 0x7e { - return false - } - } - return true -} - func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) { raw := strings.TrimSpace(c.OIDCSessionEncryptionKey) for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} { @@ -260,25 +225,6 @@ func validPublicRedirectURL(raw string) bool { return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") } -func validServiceURL(raw, appEnv string) bool { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { - return false - } - if parsed.Scheme == "https" { - return true - } - return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") -} - -func validSecurityEventReceiverURL(raw, appEnv string) bool { - if !validServiceURL(raw, appEnv) { - return false - } - parsed, err := url.Parse(strings.TrimSpace(raw)) - return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == "" -} - func isLocalEnvironment(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "development", "dev", "local", "test": diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 6312c7e..989d054 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -137,33 +137,46 @@ func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) { } } -func TestSecurityEventsAreOptionalButFailFastWhenPartiallyConfigured(t *testing.T) { +func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) { + t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true") + t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf") + t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded") + t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file") + t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf") + + cfg := Load() + if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" { + t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir) + } if err := (Config{}).Validate(); err != nil { - t.Fatalf("disabled security events changed existing config: %v", err) - } - cfg := Config{AppEnv: "test", OIDCEnabled: true, OIDCSecurityEventsEnabled: true} - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "issuer") { - t.Fatalf("partial security event config error = %v", err) - } - cfg.OIDCSecurityEventsTransmitterIssuer = "http://localhost:8080/ssf" - cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/api/v1/security-events/ssf" - cfg.OIDCSecurityEventsManagementTokenURL = "http://localhost:8080/oauth/token" - cfg.OIDCTenantID = "00000000-0000-4000-8000-000000000003" - cfg.OIDCSecurityEventsReceiverAudience = "urn:easyai:ssf:receiver:00000000-0000-4000-8000-000000000002" - cfg.OIDCSecurityEventsStreamID = "00000000-0000-4000-8000-000000000001" - cfg.OIDCSecurityEventsBearerSecret = "receiver-secret-at-least-16" - cfg.OIDCSecurityEventsManagementClientID = "gateway-ssf" - cfg.OIDCSecurityEventsManagementClientSecret = "management-secret" - cfg.OIDCIntrospectionClientID = "gateway-introspection" - cfg.OIDCIntrospectionClientSecret = "introspection-secret" - cfg.OIDCSecurityEventsHeartbeatIntervalSeconds = 60 - cfg.OIDCSecurityEventsStaleAfterSeconds = 180 - cfg.OIDCSecurityEventsClockSkewSeconds = 60 - if err := cfg.Validate(); err != nil { - t.Fatalf("valid security event config: %v", err) - } - cfg.OIDCSecurityEventsPublicEndpoint = "http://localhost:8088/wrong-path" - if err := cfg.Validate(); err == nil { - t.Fatal("non-canonical security event receiver path was accepted") + t.Fatalf("an absent security event connection changed existing config: %v", err) + } +} + +func TestValidateKubernetesSecurityEventSecretStore(t *testing.T) { + config := Config{OIDCSecurityEventsSecretStore: "kubernetes", OIDCSecurityEventsKubernetesSecretName: "easyai-gateway-security-events"} + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") { + t.Fatalf("Validate() error=%v, want missing namespace", err) + } + config.OIDCSecurityEventsKubernetesNamespace = "easyai" + if err := config.Validate(); err != nil { + t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err) + } +} + +func TestValidateSecurityEventTiming(t *testing.T) { + config := Config{OIDCSecurityEventsHeartbeatIntervalSeconds: 60, OIDCSecurityEventsStaleAfterSeconds: 60, OIDCSecurityEventsClockSkewSeconds: 60} + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") { + t.Fatalf("Validate() error=%v, want invalid stale threshold", err) + } +} + +func TestLoadSecurityEventsReusesOnlyTheRFC7662MachineClient(t *testing.T) { + t.Setenv("OIDC_INTROSPECTION_CLIENT_ID", "gateway-service") + t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "service-secret") + + cfg := Load() + if cfg.OIDCIntrospectionClientID != "gateway-service" || cfg.OIDCIntrospectionClientSecret != "service-secret" { + t.Fatal("RFC 7662 machine credentials were not preserved") } } diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go new file mode 100644 index 0000000..bbd179a --- /dev/null +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -0,0 +1,234 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type securityEventConnectionRequest struct { + TransmitterIssuer string `json:"transmitter_issuer"` +} + +type securityEventConnectionResponse struct { + Connected bool `json:"connected"` + Connection ssfreceiver.ConnectionView `json:"connection"` +} + +func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if s.securityEventManager == nil { + writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) + return + } + connection, err := s.securityEventManager.Get(r.Context()) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) + return + } + if err != nil { + writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable") + return + } + w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version)) + writeJSON(w, http.StatusOK, map[string]any{"connected": true, "connection": connection, "prerequisites": s.securityEventPrerequisites()}) +} + +func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + if s.securityEventManager == nil { + writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing") + return + } + idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r) + if !ok { + return + } + var request securityEventConnectionRequest + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || strings.TrimSpace(request.TransmitterIssuer) == "" { + writeError(w, http.StatusBadRequest, "invalid security event connection request", "invalid_request") + return + } + request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/") + requestHash := securityEventOperationHash("connect", request.TransmitterIssuer) + if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) { + return + } + if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { + return + } + connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey) + if err != nil { + writeSecurityEventConnectionError(w, err) + return + } + s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, connection) +} + +func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify") + if !ok { + return + } + connection, err := s.securityEventManager.Verify(r.Context()) + if err != nil { + writeSecurityEventConnectionError(w, err) + return + } + s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, connection) +} + +func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate") + if !ok { + return + } + connection, err := s.securityEventManager.RotateCredential(r.Context()) + if err != nil { + writeSecurityEventConnectionError(w, err) + return + } + s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, connection) +} + +func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect") + if !ok { + return + } + connection, err := s.securityEventManager.Disconnect(r.Context()) + if err != nil { + writeSecurityEventConnectionError(w, err) + return + } + s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, connection) +} + +func (s *Server) securityEventPrerequisites() map[string]any { + return map[string]any{ + "oidcConfigured": s.cfg.OIDCEnabled && s.cfg.OIDCIssuer != "" && s.cfg.OIDCTenantID != "", + "introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "", + "publicBaseUrlConfigured": s.cfg.PublicBaseURL != "", + "managementClientId": s.cfg.OIDCIntrospectionClientID, + } +} + +func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (string, bool) { + value := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if value == "" || len(value) > 255 { + writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "idempotency_key_required") + return "", false + } + return value, true +} + +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation string) (string, string, bool) { + if s.securityEventManager == nil { + writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") + return "", "", false + } + idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r) + if !ok { + return "", "", false + } + requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match"))) + if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) { + return "", "", false + } + connection, err := s.securityEventManager.Get(r.Context()) + if err != nil { + writeSecurityEventConnectionError(w, err) + return "", "", false + } + return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) +} + +func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool { + if s.store == nil { + return false + } + record, err := s.store.SecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return false + } + if err != nil { + writeError(w, http.StatusServiceUnavailable, "security event idempotency state is unavailable", "security_event_state_unavailable") + return true + } + if record.RequestHash != requestHash { + writeError(w, http.StatusConflict, "Idempotency-Key was already used for a different request", "idempotency_key_reused") + return true + } + var payload securityEventConnectionResponse + if json.Unmarshal(record.Response, &payload) != nil { + writeError(w, http.StatusServiceUnavailable, "security event idempotency response is unavailable", "security_event_state_unavailable") + return true + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Idempotent-Replayed", "true") + w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version)) + writeJSON(w, http.StatusAccepted, payload) + return true +} + +func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string, connection ssfreceiver.ConnectionView) { + payload := securityEventConnectionResponse{Connected: true, Connection: connection} + encoded, _ := json.Marshal(payload) + if s.store != nil { + if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil { + s.logger.Error("security event idempotency result could not be recorded", "error_category", "idempotency_store_failed", "operation", operation) + } + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, connection.Version)) + writeJSON(w, http.StatusAccepted, payload) +} + +func securityEventOperationHash(operation, canonicalRequest string) string { + digest := sha256.Sum256([]byte(operation + "\x00" + canonicalRequest)) + return fmt.Sprintf("%x", digest[:]) +} + +func normalizedConnectionETag(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "W/") + return strings.Trim(value, `"`) +} + +func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int64) bool { + value := strings.TrimSpace(r.Header.Get("If-Match")) + value = strings.TrimPrefix(value, "W/") + value = strings.Trim(value, `"`) + version, err := strconv.ParseInt(value, 10, 64) + if err != nil { + writeError(w, http.StatusPreconditionRequired, "If-Match is required", "if_match_required") + return false + } + if version != expected { + writeError(w, http.StatusPreconditionFailed, "security event connection version changed", "version_conflict") + return false + } + return true +} + +func writeSecurityEventConnectionError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrSecurityEventConnectionNotFound): + writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") + case errors.Is(err, store.ErrSecurityEventConnectionConflict): + writeError(w, http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict") + case strings.Contains(err.Error(), "configured"), strings.Contains(err.Error(), "invalid"), strings.Contains(err.Error(), "HTTPS"): + writeError(w, http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing") + default: + writeError(w, http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable") + } +} diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go new file mode 100644 index 0000000..f92ade8 --- /dev/null +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -0,0 +1,51 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" +) + +func TestSecurityEventConnectionWriteHeaders(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil) + recorder := httptest.NewRecorder() + if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest { + t.Fatalf("missing Idempotency-Key status=%d", recorder.Code) + } + + request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil) + request.Header.Set("If-Match", `W/"7"`) + recorder = httptest.NewRecorder() + if !matchConnectionVersion(recorder, request, 7) { + t.Fatalf("valid weak ETag was rejected: status=%d", recorder.Code) + } + + request.Header.Set("If-Match", `"6"`) + recorder = httptest.NewRecorder() + if matchConnectionVersion(recorder, request, 7) || recorder.Code != http.StatusPreconditionFailed { + t.Fatalf("stale ETag status=%d", recorder.Code) + } + if normalizedConnectionETag(`W/"7"`) != "7" || + securityEventOperationHash("verify", "7") == securityEventOperationHash("verify", "8") { + t.Fatal("security event idempotency request fingerprint is not stable") + } +} + +func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) { + server := &Server{cfg: config.Config{ + OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer/shared", OIDCTenantID: "stable-tenant", + OIDCIntrospectionClientID: "gateway-machine", OIDCIntrospectionClientSecret: "must-not-escape", + PublicBaseURL: "https://gateway.example", + }} + payload, err := json.Marshal(server.securityEventPrerequisites()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), "must-not-escape") || strings.Contains(strings.ToLower(string(payload)), "secret") { + t.Fatalf("secret escaped in prerequisites: %s", payload) + } +} diff --git a/apps/api/internal/httpapi/security_events.go b/apps/api/internal/httpapi/security_events.go index bbe48a8..addf64e 100644 --- a/apps/api/internal/httpapi/security_events.go +++ b/apps/api/internal/httpapi/security_events.go @@ -18,5 +18,9 @@ import "net/http" // @Failure 503 {object} map[string]string // @Router /api/v1/security-events/ssf [post] func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) { + if s.securityEventReceiver == nil { + http.NotFound(w, r) + return + } s.securityEventReceiver.ServeHTTP(w, r) } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 2ab5fc1..1b03891 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -30,6 +30,7 @@ type Server struct { logger *slog.Logger geminiUploadSessions sync.Map securityEventReceiver http.Handler + securityEventManager *ssfreceiver.ConnectionManager } type oidcPublicClient interface { @@ -66,46 +67,29 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret securityEventMetrics := &ssfreceiver.Metrics{} - var securityEventService *ssfreceiver.Service - if cfg.OIDCSecurityEventsEnabled { - ssfVerifier, err := ssfreceiver.NewVerifier(ssfreceiver.VerifierConfig{ - TransmitterIssuer: cfg.OIDCSecurityEventsTransmitterIssuer, - Audience: cfg.OIDCSecurityEventsReceiverAudience, SubjectIssuer: cfg.OIDCIssuer, - TenantID: cfg.OIDCTenantID, StreamID: cfg.OIDCSecurityEventsStreamID, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, - }) + if cfg.OIDCEnabled { + secretStore, err := securityEventSecretStore(cfg) if err != nil { - panic("invalid OIDC security event verifier configuration: " + err.Error()) + panic("invalid OIDC security event secret store: " + err.Error()) } - ssfVerifier.SetMetrics(securityEventMetrics) - securityEventService, err = ssfreceiver.NewService(db, ssfreceiver.ServiceConfig{ - Issuer: cfg.OIDCSecurityEventsTransmitterIssuer, Audience: cfg.OIDCSecurityEventsReceiverAudience, - StreamID: cfg.OIDCSecurityEventsStreamID, ManagementTokenURL: cfg.OIDCSecurityEventsManagementTokenURL, - ManagementClientID: cfg.OIDCSecurityEventsManagementClientID, ManagementSecret: cfg.OIDCSecurityEventsManagementClientSecret, + manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{ + AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID, + ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret, + PublicBaseURL: cfg.PublicBaseURL, HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - }) + ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + }, securityEventMetrics) if err != nil { - panic("invalid OIDC security event heartbeat configuration: " + err.Error()) + panic("initialize OIDC security event connection manager: " + err.Error()) } - securityEventService.SetMetrics(securityEventMetrics) - if err := securityEventService.Start(ctx); err != nil { - panic("OIDC security event state initialization failed") - } - receiver, err := ssfreceiver.NewHandler( - ssfVerifier, db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, - cfg.OIDCSecurityEventsStreamID, cfg.OIDCSecurityEventsBearerSecret, cfg.OIDCSecurityEventsBearerSecretNext, - ) - if err != nil { - panic("invalid OIDC security event receiver configuration: " + err.Error()) - } - receiver.SetMetrics(securityEventMetrics) - server.securityEventReceiver = receiver + server.securityEventManager = manager + server.securityEventReceiver = manager } if cfg.OIDCEnabled { var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) - if securityEventService != nil { - evaluator = securityEventService.Evaluate + if server.securityEventManager != nil { + evaluator = server.securityEventManager.Evaluate } verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, @@ -164,7 +148,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux := http.NewServeMux() mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /readyz", server.ready) - mux.Handle("GET /metrics", securityEventMetrics.Handler(db, cfg.OIDCSecurityEventsTransmitterIssuer, cfg.OIDCSecurityEventsReceiverAudience, cfg.OIDCSecurityEventsEnabled)) + mux.Handle("GET /metrics", securityEventMetrics.DynamicHandler(db)) mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset) mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset) mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset) @@ -175,9 +159,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin) mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession) mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession) - if server.securityEventReceiver != nil { - mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent) - } + mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent) mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders))) mux.Handle("GET /api/v1/public/catalog/base-models", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listBaseModels))) @@ -247,6 +229,11 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings))) mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings))) mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings))) + mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection))) + mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection))) + mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection))) + mux.Handle("POST /api/admin/system/identity/security-events/connection/rotate-credential", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rotateSecurityEventConnectionCredential))) + mux.Handle("DELETE /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteSecurityEventConnection))) mux.Handle("GET /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listFileStorageChannels))) mux.Handle("POST /api/admin/system/file-storage/channels", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createFileStorageChannel))) mux.Handle("PATCH /api/admin/system/file-storage/channels/{channelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageChannel))) @@ -324,6 +311,25 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor return server.recover(server.cors(server.protectOIDCSessionCookie(mux))) } +func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) { + switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) { + case "", "file": + directory := cfg.OIDCSecurityEventsSecretDir + if directory == "" { + directory = ".local-secrets/ssf" + } + return ssfreceiver.NewFileSecretStore(directory) + case "kubernetes": + return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{ + Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName, + APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile, + CAFile: cfg.OIDCSecurityEventsKubernetesCAFile, + }) + default: + return nil, errors.New("unsupported OIDC security event SecretStore") + } +} + func oidcSessionRequestError(err error) error { switch { case err == nil: @@ -361,7 +367,7 @@ func (s *Server) cors(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, If-Match, X-Comfy-Api-Key, X-Goog-Api-Key, X-Goog-Upload-Protocol, X-Goog-Upload-Command, X-Goog-Upload-Header-Content-Length, X-Goog-Upload-Header-Content-Type, X-Goog-Upload-Offset, X-Async, X-EasyAI-Conversation-ID") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") } if r.Method == http.MethodOptions { diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go new file mode 100644 index 0000000..1a02463 --- /dev/null +++ b/apps/api/internal/securityevents/connection_manager.go @@ -0,0 +1,940 @@ +package securityevents + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" +) + +const ( + sessionRevokedEvent = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + pushDeliveryMethod = "urn:ietf:rfc:8935" +) + +type ConnectionRepository interface { + Repository + StateRepository + CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) + SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) + BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error) + UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error) + SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) + PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) + DeleteSecurityEventConnection(context.Context, string) error + SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) +} + +type ConnectionManagerConfig struct { + AppEnv string + OIDCEnabled bool + OIDCIssuer string + OIDCTenantID string + ManagementClientID string + ManagementClientSecret string + PublicBaseURL string + HeartbeatInterval time.Duration + StaleAfter time.Duration + ClockSkew time.Duration + BootstrapDuration time.Duration + CredentialOverlapDuration time.Duration + RetirementDuration time.Duration + HTTPClient *http.Client +} + +type ConnectionView struct { + ConnectionID string `json:"connectionId"` + TransmitterIssuer string `json:"transmitterIssuer"` + ReceiverEndpoint string `json:"receiverEndpoint"` + Audience *string `json:"audience,omitempty"` + StreamID *string `json:"streamId,omitempty"` + LifecycleStatus string `json:"lifecycleStatus"` + HealthMode string `json:"healthMode"` + ManagementClientID string `json:"managementClientId"` + LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"` + LastErrorCategory *string `json:"lastErrorCategory,omitempty"` + Version int64 `json:"version"` +} + +type connectionRuntime struct { + handler *Handler + service *Service + cancel context.CancelFunc +} + +type ConnectionManager struct { + ctx context.Context + repository ConnectionRepository + secrets SecretStore + config ConnectionManagerConfig + metrics *Metrics + mutex sync.RWMutex + operation sync.Mutex + runtime *connectionRuntime +} + +type transmitterConfiguration struct { + SpecVersion string `json:"spec_version"` + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + ConfigurationEndpoint string `json:"configuration_endpoint"` + StatusEndpoint string `json:"status_endpoint"` + VerificationEndpoint string `json:"verification_endpoint"` + DeliveryMethodsSupported []string `json:"delivery_methods_supported"` +} + +type streamConfiguration struct { + StreamID string `json:"stream_id"` + Issuer string `json:"iss"` + Audience string `json:"aud"` + EventsRequested []string `json:"events_requested"` + Delivery struct { + Method string `json:"method"` + EndpointURL string `json:"endpoint_url"` + } `json:"delivery"` + Description *string `json:"description,omitempty"` +} + +type remoteHTTPError struct{ status int } + +func (e remoteHTTPError) Error() string { + return fmt.Sprintf("SSF endpoint returned HTTP %d", e.status) +} + +func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) { + if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" { + return nil, errors.New("security event connection prerequisites are incomplete") + } + if config.HeartbeatInterval <= 0 { + config.HeartbeatInterval = time.Minute + } + if config.StaleAfter < 2*config.HeartbeatInterval { + config.StaleAfter = 3 * time.Minute + } + if config.ClockSkew == 0 { + config.ClockSkew = time.Minute + } + if config.BootstrapDuration == 0 { + config.BootstrapDuration = 6 * time.Minute + } + if config.CredentialOverlapDuration == 0 { + config.CredentialOverlapDuration = 3 * time.Minute + } + if config.RetirementDuration == 0 { + config.RetirementDuration = 6 * time.Minute + } + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: config, metrics: metrics} + connection, err := repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return manager, nil + } + if err != nil { + return nil, fmt.Errorf("load security event connection: %w", err) + } + if connection.LifecycleStatus == "retiring" { + if connection.StreamID != nil && connection.Audience != nil { + _ = manager.activate(ctx, connection) + } + go manager.finishRetirement(connection) + return manager, nil + } + if connection.LifecycleStatus == "disconnect_pending" { + if connection.StreamID != nil && connection.Audience != nil { + _ = manager.activate(ctx, connection) + } + go manager.retryDisconnect(connection.ConnectionID) + return manager, nil + } + if connection.StreamID != nil && connection.Audience != nil { + if err := manager.activate(ctx, connection); err != nil { + category := "credential_unavailable" + _, _ = repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "degraded", &category) + } + } + return manager, nil +} + +func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return ConnectionView{}, err + } + return m.view(connection), nil +} + +func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) { + m.operation.Lock() + defer m.operation.Unlock() + issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + if idempotencyKey == "" { + return ConnectionView{}, errors.New("idempotency key is required") + } + if err := m.validatePrerequisites(issuer); err != nil { + return ConnectionView{}, err + } + existing, err := m.repository.SecurityEventConnection(ctx) + if err == nil { + if existing.TransmitterIssuer != issuer { + return ConnectionView{}, store.ErrSecurityEventConnectionConflict + } + if existing.StreamID != nil { + return m.view(existing), nil + } + return m.resumeConnecting(ctx, existing) + } + if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return ConnectionView{}, err + } + connectionID := uuid.NewString() + reference := "ssf-push-" + connectionID + secret, err := randomPushBearer() + if err != nil { + return ConnectionView{}, err + } + defer clear(secret) + if err := m.secrets.Put(ctx, reference, secret); err != nil { + return ConnectionView{}, err + } + endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf" + connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint, + CredentialRef: reference, IdempotencyKey: idempotencyKey, + }) + if err != nil { + _ = m.secrets.Delete(ctx, reference) + return ConnectionView{}, err + } + return m.resumeConnecting(ctx, connection) +} + +func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) { + secret, err := m.secrets.Get(ctx, connection.CredentialRef) + if err != nil { + return ConnectionView{}, err + } + defer clear(secret) + configuration, client, err := m.discover(ctx, connection.TransmitterIssuer) + if err != nil { + return ConnectionView{}, m.connectionError(ctx, connection, "discovery_failed", err) + } + token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage") + if err != nil { + return ConnectionView{}, m.connectionError(ctx, connection, "management_token_failed", err) + } + description := "easyai-gateway:" + connection.ConnectionID + stream, err := m.findStream(ctx, client, configuration, token, description, connection.EndpointURL) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + stream, err = m.createStream(ctx, client, configuration, token, connection.EndpointURL, string(secret), description) + } + if err != nil { + return ConnectionView{}, m.connectionError(ctx, connection, "stream_create_failed", err) + } + if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil { + return ConnectionView{}, m.connectionError(ctx, connection, "stream_response_invalid", err) + } + connection, err = m.repository.BindSecurityEventConnection(ctx, connection.ConnectionID, stream.Audience, stream.StreamID, "verifying", connection.Version) + if err != nil { + return ConnectionView{}, err + } + started := time.Now().UTC() + if err := m.activate(ctx, connection); err != nil { + return ConnectionView{}, m.connectionError(ctx, connection, "receiver_activation_failed", err) + } + go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, false) + return m.view(connection), nil +} + +func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return ConnectionView{}, err + } + m.mutex.RLock() + runtime := m.runtime + m.mutex.RUnlock() + if runtime == nil || runtime.service == nil { + return ConnectionView{}, errors.New("security event receiver is unavailable") + } + started := time.Now().UTC() + runtime.service.RequestVerification(ctx) + if connection.LifecycleStatus == "verifying" || connection.LifecycleStatus == "rotating" { + configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer) + if discoverErr != nil { + return ConnectionView{}, discoverErr + } + token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage") + if tokenErr != nil { + return ConnectionView{}, tokenErr + } + go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, connection.LifecycleStatus == "rotating") + } + return m.view(connection), nil +} + +func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionView, error) { + m.operation.Lock() + defer m.operation.Unlock() + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return ConnectionView{}, err + } + if connection.StreamID == nil || connection.Audience == nil { + return ConnectionView{}, store.ErrSecurityEventConnectionConflict + } + var nextReference string + var next []byte + if connection.NextCredentialRef == nil { + nextReference = "ssf-push-" + connection.ConnectionID + "-next-" + uuid.NewString() + next, err = randomPushBearer() + if err != nil { + return ConnectionView{}, err + } + if err := m.secrets.Put(ctx, nextReference, next); err != nil { + clear(next) + return ConnectionView{}, err + } + connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference) + if err != nil { + clear(next) + _ = m.secrets.Delete(ctx, nextReference) + return ConnectionView{}, err + } + } else { + nextReference = *connection.NextCredentialRef + next, err = m.secrets.Get(ctx, nextReference) + if err != nil { + return ConnectionView{}, err + } + } + defer clear(next) + configuration, client, err := m.discover(ctx, connection.TransmitterIssuer) + if err != nil { + return ConnectionView{}, err + } + token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage") + if err != nil { + return ConnectionView{}, err + } + if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "paused", "credential_rotation"); err != nil { + return ConnectionView{}, err + } + if err := m.patchStreamCredential(ctx, client, configuration, token, *connection.StreamID, connection.EndpointURL, string(next)); err != nil { + return ConnectionView{}, err + } + started := time.Now().UTC() + if err := m.activate(ctx, connection); err != nil { + return ConnectionView{}, err + } + go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, true) + return m.view(connection), nil +} + +func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, error) { + m.operation.Lock() + defer m.operation.Unlock() + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return ConnectionView{}, err + } + if connection.StreamID != nil { + configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer) + if discoverErr == nil { + token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage") + if tokenErr == nil { + discoverErr = m.deleteStream(ctx, client, configuration, token, *connection.StreamID) + if isRemoteHTTPStatus(discoverErr, http.StatusNotFound) { + discoverErr = nil + } + } + } + if discoverErr != nil { + category := "remote_disconnect_failed" + connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "disconnect_pending", &category) + go m.retryDisconnect(connection.ConnectionID) + return m.view(connection), nil + } + } + connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "retiring", nil) + if err != nil { + return ConnectionView{}, err + } + go m.finishRetirement(connection) + return m.view(connection), nil +} + +func (m *ConnectionManager) retryDisconnect(connectionID string) { + delay := time.Second + for { + select { + case <-m.ctx.Done(): + return + case <-time.After(delay): + } + connection, err := m.repository.SecurityEventConnection(m.ctx) + if err != nil || connection.ConnectionID != connectionID || connection.LifecycleStatus != "disconnect_pending" || connection.StreamID == nil { + return + } + configuration, client, err := m.discover(m.ctx, connection.TransmitterIssuer) + if err == nil { + var token string + token, err = m.managementToken(m.ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage") + if err == nil { + err = m.deleteStream(m.ctx, client, configuration, token, *connection.StreamID) + if isRemoteHTTPStatus(err, http.StatusNotFound) { + err = nil + } + } + } + if err == nil { + connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "retiring", nil) + if err == nil { + go m.finishRetirement(connection) + } + return + } + if delay < time.Minute { + delay *= 2 + } + } +} + +func (m *ConnectionManager) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.mutex.RLock() + runtime := m.runtime + m.mutex.RUnlock() + if runtime == nil || runtime.handler == nil { + w.Header().Set("Cache-Control", "no-store") + if _, err := m.repository.SecurityEventConnection(r.Context()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + http.NotFound(w, r) + return + } + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + runtime.handler.ServeHTTP(w, r) +} + +func (m *ConnectionManager) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return auth.OIDCSecurityEventEvaluation{}, nil + } + if err != nil { + return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, err + } + m.mutex.RLock() + runtime := m.runtime + m.mutex.RUnlock() + if runtime == nil || runtime.service == nil { + if connection.Audience == nil { + return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, nil + } + result, evaluateErr := m.repository.EvaluateOIDCSecurityEvent( + ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.TenantID, identity.Subject, + identity.IssuedAt, time.Now().UTC(), m.config.StaleAfter, + ) + if result.Revoked && m.metrics != nil { + m.metrics.ObserveWatermarkRejection() + } + return auth.OIDCSecurityEventEvaluation{Enabled: true, Revoked: result.Revoked, RequireIntrospection: true}, evaluateErr + } + evaluation, err := runtime.service.Evaluate(ctx, identity) + evaluation.Enabled = true + if connection.LifecycleStatus != "enabled" && connection.LifecycleStatus != "bootstrap" { + evaluation.RequireIntrospection = true + } + return evaluation, err +} + +func (m *ConnectionManager) activate(ctx context.Context, connection store.SecurityEventConnection) error { + if connection.StreamID == nil || connection.Audience == nil { + return errors.New("security event connection binding is incomplete") + } + current, err := m.secrets.Get(ctx, connection.CredentialRef) + if err != nil { + return err + } + defer clear(current) + var next []byte + if connection.NextCredentialRef != nil { + next, err = m.secrets.Get(ctx, *connection.NextCredentialRef) + if err != nil { + return err + } + defer clear(next) + } + issuerURL, err := validatedIssuerURL(connection.TransmitterIssuer, m.config.AppEnv) + if err != nil { + return err + } + safeClient := m.config.HTTPClient + if safeClient == nil { + safeClient = safeHTTPClient(issuerURL, m.config.AppEnv) + } + verifier, err := NewVerifier(VerifierConfig{ + TransmitterIssuer: connection.TransmitterIssuer, Audience: *connection.Audience, + SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, StreamID: *connection.StreamID, + ClockSkew: m.config.ClockSkew, HTTPClient: safeClient, + }) + if err != nil { + return err + } + verifier.SetMetrics(m.metrics) + handler, err := NewHandler(verifier, m.repository, connection.TransmitterIssuer, *connection.Audience, *connection.StreamID, string(current), string(next)) + if err != nil { + return err + } + handler.SetMetrics(m.metrics) + service, err := NewService(m.repository, ServiceConfig{ + Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID, + ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token", + ManagementClientID: m.config.ManagementClientID, ManagementSecret: m.config.ManagementClientSecret, + HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter, + HTTPClient: safeClient, + }) + if err != nil { + return err + } + service.SetMetrics(m.metrics) + runtimeContext, cancel := context.WithCancel(m.ctx) + if err := service.Initialize(runtimeContext); err != nil { + cancel() + return err + } + m.mutex.Lock() + previous := m.runtime + m.runtime = &connectionRuntime{handler: handler, service: service, cancel: cancel} + m.mutex.Unlock() + if previous != nil && previous.cancel != nil { + previous.cancel() + } + go service.Run(runtimeContext) + return nil +} + +func (m *ConnectionManager) stopRuntime() { + m.mutex.Lock() + previous := m.runtime + m.runtime = nil + m.mutex.Unlock() + if previous != nil && previous.cancel != nil { + previous.cancel() + } +} + +func (m *ConnectionManager) finishAutomaticVerification(connectionID string, configuration transmitterConfiguration, client *http.Client, token string, started time.Time, rotating bool) { + ctx, cancel := context.WithTimeout(m.ctx, 90*time.Second) + defer cancel() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil { + return + } + _, verifiedAt, _ := m.repository.SecurityEventMetrics(ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience)) + if verifiedAt != nil && !verifiedAt.Before(started) { + if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "enabled", "verification_succeeded"); err != nil { + category := "stream_enable_failed" + lifecycle := "verifying" + if rotating { + lifecycle = "rotating" + } + _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, lifecycle, &category) + return + } + if rotating && connection.NextCredentialRef != nil { + oldReference, nextReference := connection.CredentialRef, *connection.NextCredentialRef + select { + case <-m.ctx.Done(): + return + case <-time.After(m.config.CredentialOverlapDuration): + } + connection, err = m.repository.PromoteSecurityEventCredential(m.ctx, connectionID, nextReference) + if err == nil { + _ = m.secrets.Delete(m.ctx, oldReference) + _ = m.activate(m.ctx, connection) + go m.finishBootstrap(connectionID) + } + return + } + connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, "bootstrap", nil) + go m.finishBootstrap(connectionID) + return + } + select { + case <-ctx.Done(): + category := "verification_timeout" + lifecycle := "verifying" + if rotating { + lifecycle = "rotating" + } + _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, lifecycle, &category) + return + case <-ticker.C: + } + } +} + +func (m *ConnectionManager) finishBootstrap(connectionID string) { + select { + case <-m.ctx.Done(): + return + case <-time.After(m.config.BootstrapDuration): + } + connection, err := m.repository.SecurityEventConnection(m.ctx) + if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" { + mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience)) + if metricsErr == nil && mode == "push_healthy" { + _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "enabled", nil) + return + } + category := "bootstrap_verification_stale" + _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "degraded", &category) + } +} + +func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConnection) { + elapsed := time.Since(connection.UpdatedAt) + delay := m.config.RetirementDuration - elapsed + if delay > 0 { + select { + case <-m.ctx.Done(): + return + case <-time.After(delay): + } + } + delay = time.Second + for { + if err := m.repository.DeleteSecurityEventConnection(m.ctx, connection.ConnectionID); err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + break + } + select { + case <-m.ctx.Done(): + return + case <-time.After(delay): + } + if delay < time.Minute { + delay *= 2 + } + } + m.stopRuntime() + _ = m.secrets.Delete(m.ctx, connection.CredentialRef) + if connection.NextCredentialRef != nil { + _ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef) + } +} + +func (m *ConnectionManager) validatePrerequisites(issuer string) error { + if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" { + return errors.New("OIDC and RFC 7662 machine client must be configured") + } + if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil { + return errors.New("OIDC tenant id is invalid") + } + if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil { + return err + } + endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf") + if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid") + } + if endpoint.Scheme != "https" && !(isLocalEnvironment(m.config.AppEnv) && endpoint.Scheme == "http" && isLocalHostname(endpoint.Hostname())) { + return errors.New("AI_GATEWAY_PUBLIC_BASE_URL must use HTTPS") + } + return nil +} + +func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transmitterConfiguration, *http.Client, error) { + issuerURL, err := validatedIssuerURL(issuer, m.config.AppEnv) + if err != nil { + return transmitterConfiguration{}, nil, err + } + client := m.config.HTTPClient + if client == nil { + client = safeHTTPClient(issuerURL, m.config.AppEnv) + } + discoveryURL := *issuerURL + discoveryURL.Path = "/.well-known/ssf-configuration" + strings.TrimRight(issuerURL.EscapedPath(), "/") + var configuration transmitterConfiguration + if err := requestJSON(ctx, client, http.MethodGet, discoveryURL.String(), "", nil, &configuration); err != nil { + return transmitterConfiguration{}, nil, err + } + if configuration.SpecVersion != "1_0" || strings.TrimRight(configuration.Issuer, "/") != strings.TrimRight(issuer, "/") || + !contains(configuration.DeliveryMethodsSupported, pushDeliveryMethod) { + return transmitterConfiguration{}, nil, errors.New("SSF discovery metadata is incompatible") + } + for _, endpoint := range []string{configuration.JWKSURI, configuration.ConfigurationEndpoint, configuration.StatusEndpoint, configuration.VerificationEndpoint} { + parsed, parseErr := url.Parse(endpoint) + if parseErr != nil || parsed.Scheme != issuerURL.Scheme || !strings.EqualFold(parsed.Host, issuerURL.Host) || parsed.User != nil || parsed.Fragment != "" { + return transmitterConfiguration{}, nil, errors.New("SSF discovery endpoint is not trusted") + } + } + return configuration, client, nil +} + +func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) { + form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.SetBasicAuth(m.config.ManagementClientID, m.config.ManagementClientSecret) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return "", fmt.Errorf("machine token request returned HTTP %d", response.StatusCode) + } + var payload struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + if err := decodeLimitedJSON(response.Body, &payload); err != nil || payload.AccessToken == "" || !strings.EqualFold(payload.TokenType, "Bearer") { + return "", errors.New("machine token response is invalid") + } + return payload.AccessToken, nil +} + +func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) { + var streams []streamConfiguration + if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil { + return streamConfiguration{}, err + } + for _, stream := range streams { + if stream.Description != nil && *stream.Description == description && stream.Delivery.EndpointURL == endpoint { + return stream, nil + } + } + return streamConfiguration{}, store.ErrSecurityEventConnectionNotFound +} + +func (m *ConnectionManager) createStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, endpoint, secret, description string) (streamConfiguration, error) { + body := map[string]any{ + "delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret}, + "events_requested": []string{sessionRevokedEvent}, "description": description, + } + var stream streamConfiguration + err := requestJSON(ctx, client, http.MethodPost, configuration.ConfigurationEndpoint, token, body, &stream) + return stream, err +} + +func (m *ConnectionManager) patchStreamCredential(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, endpoint, secret string) error { + body := map[string]any{"stream_id": streamID, "delivery": map[string]any{ + "method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret, + }} + return requestJSON(ctx, client, http.MethodPatch, configuration.ConfigurationEndpoint, token, body, &streamConfiguration{}) +} + +func (m *ConnectionManager) setStreamStatus(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, status, reason string) error { + body := map[string]any{"stream_id": streamID, "status": status, "reason": reason} + return requestJSON(ctx, client, http.MethodPost, configuration.StatusEndpoint, token, body, &map[string]any{}) +} + +func (m *ConnectionManager) deleteStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID string) error { + endpoint, _ := url.Parse(configuration.ConfigurationEndpoint) + query := endpoint.Query() + query.Set("stream_id", streamID) + endpoint.RawQuery = query.Encode() + return requestJSON(ctx, client, http.MethodDelete, endpoint.String(), token, nil, nil) +} + +func (m *ConnectionManager) connectionError(ctx context.Context, connection store.SecurityEventConnection, category string, cause error) error { + _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "error", &category) + return cause +} + +func (m *ConnectionManager) view(connection store.SecurityEventConnection) ConnectionView { + return ConnectionView{ + ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer, + ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID, + LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode, + ManagementClientID: m.config.ManagementClientID, LastVerificationAt: connection.LastVerificationAt, + LastErrorCategory: connection.LastErrorCategory, Version: connection.Version, + } +} + +func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error { + if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" || + stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint || + len(stream.EventsRequested) != 1 || stream.EventsRequested[0] != sessionRevokedEvent { + return errors.New("created SSF stream does not match the requested receiver") + } + return nil +} + +func randomPushBearer() ([]byte, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + encoded := make([]byte, base64.RawURLEncoding.EncodedLen(len(raw))) + base64.RawURLEncoding.Encode(encoded, raw) + clear(raw) + return encoded, nil +} + +func requestJSON(ctx context.Context, client *http.Client, method, endpoint, token string, body, output any) error { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return remoteHTTPError{status: response.StatusCode} + } + if output == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return nil + } + return decodeLimitedJSON(response.Body, output) +} + +func isRemoteHTTPStatus(err error, status int) bool { + var remoteError remoteHTTPError + return errors.As(err, &remoteError) && remoteError.status == status +} + +func decodeLimitedJSON(reader io.Reader, output any) error { + payload, err := io.ReadAll(io.LimitReader(reader, 64*1024+1)) + if err != nil || len(payload) > 64*1024 { + return errors.New("security event response is too large") + } + if len(payload) == 0 { + return nil + } + return json.Unmarshal(payload, output) +} + +func validatedIssuerURL(raw, appEnv string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("transmitter issuer is invalid") + } + if parsed.Scheme != "https" && !(isLocalEnvironment(appEnv) && parsed.Scheme == "http" && isLocalHostname(parsed.Hostname())) { + return nil, errors.New("transmitter issuer must use HTTPS") + } + return parsed, nil +} + +func safeHTTPClient(issuer *url.URL, appEnv string) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DisableCompression = true + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addresses) == 0 { + return nil, errors.New("transmitter DNS resolution failed") + } + allowLocal := isLocalEnvironment(appEnv) && isLocalHostname(issuer.Hostname()) + for _, address := range addresses { + if blockedAddress(address.IP) && !allowLocal { + return nil, errors.New("transmitter resolved to a blocked network") + } + } + dialer := &net.Dialer{Timeout: 5 * time.Second} + return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + } + return &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} +} + +func blockedAddress(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsUnspecified() || ip.IsMulticast() { + return true + } + address, ok := netip.AddrFromSlice(ip) + if !ok { + return true + } + address = address.Unmap() + for _, prefix := range blockedNetworkPrefixes { + if prefix.Contains(address) { + return true + } + } + return false +} + +var blockedNetworkPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001:db8::/32"), +} + +func isLocalHostname(value string) bool { + return value == "localhost" || value == "127.0.0.1" || value == "::1" +} + +func isLocalEnvironment(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "development", "dev", "local", "test": + return true + default: + return false + } +} + +func contains(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go new file mode 100644 index 0000000..4d40535 --- /dev/null +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -0,0 +1,293 @@ +package securityevents + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" +) + +type memoryConnectionRepository struct { + mutex sync.Mutex + connection *store.SecurityEventConnection + mode string + verifiedAt *time.Time + evaluation store.SecurityEventEvaluation +} + +func (*memoryConnectionRepository) ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) { + return store.ApplySecurityEventResult{}, nil +} +func (*memoryConnectionRepository) ApplySecurityEventStreamUpdated(context.Context, string, string, string, string, string, string, time.Time) (bool, error) { + return true, nil +} +func (*memoryConnectionRepository) ConfirmSecurityEventVerification(context.Context, string, string, string, string, []byte, time.Time) (bool, error) { + return true, nil +} +func (*memoryConnectionRepository) RecordSecurityEventReceipt(context.Context, string, string, string, string, string) (bool, error) { + return true, nil +} +func (r *memoryConnectionRepository) EnsureSecurityEventStreamState(context.Context, string, string, string) error { + r.mode = "bootstrap" + return nil +} +func (*memoryConnectionRepository) BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error { + return nil +} +func (*memoryConnectionRepository) RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error { + return nil +} +func (*memoryConnectionRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error { + return nil +} +func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) { + if r.evaluation.Mode == "" { + return store.SecurityEventEvaluation{Mode: "bootstrap", RequireIntrospection: true}, nil + } + return r.evaluation, nil +} +func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Context, input store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection != nil { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict + } + now := time.Now().UTC() + value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer, + EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1, + IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"} + r.connection = &value + return value, nil +} +func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound + } + value := *r.connection + value.HealthMode, value.LastVerificationAt = r.mode, r.verifiedAt + return value, nil +} +func (r *memoryConnectionRepository) BindSecurityEventConnection(_ context.Context, id, audience, streamID, lifecycle string, expected int64) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil || r.connection.ConnectionID != id || r.connection.Version != expected { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict + } + r.connection.Audience, r.connection.StreamID = &audience, &streamID + r.connection.LifecycleStatus, r.connection.Version = lifecycle, r.connection.Version+1 + return *r.connection, nil +} +func (r *memoryConnectionRepository) UpdateSecurityEventConnectionLifecycle(_ context.Context, id, lifecycle string, category *string) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil || r.connection.ConnectionID != id { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound + } + r.connection.LifecycleStatus, r.connection.LastErrorCategory = lifecycle, category + r.connection.Version++ + r.connection.UpdatedAt = time.Now().UTC() + return *r.connection, nil +} +func (r *memoryConnectionRepository) SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) { + return store.SecurityEventConnection{}, errors.New("not used") +} + +func TestConnectionManagerBootstrapOnlyBecomesEnabledWhenPushIsHealthy(t *testing.T) { + for _, test := range []struct { + mode, wantLifecycle string + }{ + {mode: "push_healthy", wantLifecycle: "enabled"}, + {mode: "introspection_fallback", wantLifecycle: "degraded"}, + } { + t.Run(test.mode, func(t *testing.T) { + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + repository := &memoryConnectionRepository{mode: test.mode, connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", Audience: &audience, + LifecycleStatus: "bootstrap", Version: 1, UpdatedAt: time.Now().UTC(), + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, + config: ConnectionManagerConfig{BootstrapDuration: time.Millisecond}} + manager.finishBootstrap(repository.connection.ConnectionID) + connection, err := repository.SecurityEventConnection(context.Background()) + if err != nil || connection.LifecycleStatus != test.wantLifecycle { + t.Fatalf("connection=%#v err=%v", connection, err) + } + }) + } +} + +func TestRemoteNotFoundIsRecognizedForIdempotentDisconnect(t *testing.T) { + if !isRemoteHTTPStatus(remoteHTTPError{status: http.StatusNotFound}, http.StatusNotFound) { + t.Fatal("remote 404 was not recognized as an already-deleted Stream") + } +} + +func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { + for _, address := range []string{ + "127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", + } { + if !blockedAddress(net.ParseIP(address)) { + t.Fatalf("reserved address %s was allowed", address) + } + } + if blockedAddress(net.ParseIP("8.8.8.8")) { + t.Fatal("public address was blocked") + } +} + +func TestRetiringConnectionStillAppliesRevocationWatermark(t *testing.T) { + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + repository := &memoryConnectionRepository{ + evaluation: store.SecurityEventEvaluation{Mode: "introspection_fallback", Revoked: true, RequireIntrospection: true}, + connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", Audience: &audience, + LifecycleStatus: "retiring", Version: 1, + }, + } + manager := &ConnectionManager{ctx: context.Background(), repository: repository, config: ConnectionManagerConfig{StaleAfter: 3 * time.Minute}} + evaluation, err := manager.Evaluate(context.Background(), auth.OIDCSecurityEventIdentity{IssuedAt: time.Now().Add(-time.Minute)}) + if err != nil || !evaluation.Enabled || !evaluation.Revoked || !evaluation.RequireIntrospection { + t.Fatalf("retiring evaluation=%#v err=%v", evaluation, err) + } +} +func (r *memoryConnectionRepository) PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) { + return store.SecurityEventConnection{}, errors.New("not used") +} +func (r *memoryConnectionRepository) DeleteSecurityEventConnection(context.Context, string) error { + r.connection = nil + return nil +} +func (r *memoryConnectionRepository) SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) { + return r.mode, r.verifiedAt, nil +} + +type memorySecretStore struct { + values map[string][]byte +} + +func (s *memorySecretStore) Put(_ context.Context, reference string, value []byte) error { + if s.values == nil { + s.values = map[string][]byte{} + } + s.values[reference] = append([]byte(nil), value...) + return nil +} +func (s *memorySecretStore) Get(_ context.Context, reference string) ([]byte, error) { + value, ok := s.values[reference] + if !ok { + return nil, ErrSecretNotFound + } + return append([]byte(nil), value...), nil +} +func (s *memorySecretStore) Delete(_ context.Context, reference string) error { + delete(s.values, reference) + return nil +} + +func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testing.T) { + repository := &memoryConnectionRepository{} + secrets := &memorySecretStore{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var transmitter *httptest.Server + verificationRequested := make(chan struct{}, 1) + var managementScopeRequested atomic.Bool + transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/ssf-configuration/ssf": + _ = json.NewEncoder(w).Encode(map[string]any{ + "spec_version": "1_0", "issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json", + "configuration_endpoint": transmitter.URL + "/ssf/v1/stream", "status_endpoint": transmitter.URL + "/ssf/v1/status", + "verification_endpoint": transmitter.URL + "/ssf/v1/verify", "delivery_methods_supported": []string{pushDeliveryMethod}, + }) + case "/oidc/token": + clientID, secret, ok := r.BasicAuth() + if !ok || clientID != "gateway-machine" || secret != "machine-secret" { + t.Fatal("RFC 7662 machine client was not reused") + } + _ = r.ParseForm() + if strings.Contains(r.Form.Get("scope"), "ssf.stream.manage") { + managementScopeRequested.Store(true) + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "machine-token", "token_type": "Bearer"}) + case "/ssf/v1/stream": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[]`)) + return + } + var request struct { + Delivery struct { + AuthorizationHeader string `json:"authorization_header"` + EndpointURL string `json:"endpoint_url"` + } `json:"delivery"` + Description string `json:"description"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + if len(strings.TrimPrefix(request.Delivery.AuthorizationHeader, "Bearer ")) < 43 { + t.Fatal("generated Push Bearer has less than 256 bits") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "stream_id": uuid.NewString(), "iss": transmitter.URL + "/ssf", + "aud": "urn:easyai:ssf:receiver:" + uuid.NewString(), "events_requested": []string{sessionRevokedEvent}, + "delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": request.Delivery.EndpointURL}, + "description": request.Description, + }) + case "/ssf/v1/verify": + w.WriteHeader(http.StatusNoContent) + select { + case verificationRequested <- struct{}{}: + default: + } + default: + http.NotFound(w, r) + } + })) + defer transmitter.Close() + manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(), + ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret", + PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(), + }, &Metrics{}) + if err != nil { + t.Fatal(err) + } + evaluation, err := manager.Evaluate(ctx, auth.OIDCSecurityEventIdentity{}) + if err != nil || evaluation.Enabled { + t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err) + } + view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "connect-once") + if err != nil { + t.Fatal(err) + } + if view.LifecycleStatus != "verifying" || view.StreamID == nil || view.Audience == nil { + t.Fatalf("connection view=%#v", view) + } + encoded, _ := json.Marshal(view) + if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret") || strings.Contains(string(encoded), "ssf-push-") { + t.Fatalf("secret metadata escaped in response: %s", encoded) + } + if len(secrets.values) != 1 { + t.Fatalf("secret count=%d", len(secrets.values)) + } + if !managementScopeRequested.Load() { + t.Fatal("management scopes were not requested automatically") + } + select { + case <-verificationRequested: + case <-time.After(2 * time.Second): + t.Fatal("verification was not started without a restart") + } +} diff --git a/apps/api/internal/securityevents/kubernetes_secret_store.go b/apps/api/internal/securityevents/kubernetes_secret_store.go new file mode 100644 index 0000000..34bf582 --- /dev/null +++ b/apps/api/internal/securityevents/kubernetes_secret_store.go @@ -0,0 +1,186 @@ +package securityevents + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +const ( + defaultKubernetesAPIServer = "https://kubernetes.default.svc" + defaultServiceAccountToken = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultServiceAccountCA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +) + +var kubernetesDNSNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`) + +type KubernetesSecretStoreConfig struct { + Namespace string + SecretName string + APIServer string + TokenFile string + CAFile string + HTTPClient *http.Client +} + +// KubernetesSecretStore keeps all generated Push Bearers as keys in one +// pre-provisioned Secret. It intentionally has no permission to create or +// delete Kubernetes Secret resources. +type KubernetesSecretStore struct { + endpoint string + tokenFile string + client *http.Client +} + +func NewKubernetesSecretStore(config KubernetesSecretStoreConfig) (*KubernetesSecretStore, error) { + if !validKubernetesDNSName(config.Namespace) || !validKubernetesDNSName(config.SecretName) { + return nil, errors.New("Kubernetes security event Secret namespace or name is invalid") + } + if config.APIServer == "" { + config.APIServer = defaultKubernetesAPIServer + } + if config.TokenFile == "" { + config.TokenFile = defaultServiceAccountToken + } + parsed, err := url.Parse(strings.TrimRight(config.APIServer, "/")) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Scheme != "https" && config.HTTPClient == nil) { + return nil, errors.New("Kubernetes API server URL is invalid") + } + client := config.HTTPClient + if client == nil { + if config.CAFile == "" { + config.CAFile = defaultServiceAccountCA + } + certificate, readErr := os.ReadFile(config.CAFile) + if readErr != nil { + return nil, fmt.Errorf("read Kubernetes service account CA: %w", readErr) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(certificate) { + return nil, errors.New("Kubernetes service account CA is invalid") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DisableCompression = true + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} + client = &http.Client{Timeout: 10 * time.Second, Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + } + endpoint := fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", strings.TrimRight(config.APIServer, "/"), config.Namespace, config.SecretName) + return &KubernetesSecretStore{endpoint: endpoint, tokenFile: config.TokenFile, client: client}, nil +} + +func (s *KubernetesSecretStore) Put(ctx context.Context, reference string, value []byte) error { + if !secretReferencePattern.MatchString(reference) { + return errors.New("security event secret reference is invalid") + } + if len(value) < 32 || len(value) > 4096 { + return errors.New("security event secret length is invalid") + } + return s.patch(ctx, map[string]any{reference: base64.StdEncoding.EncodeToString(value)}) +} + +func (s *KubernetesSecretStore) Get(ctx context.Context, reference string) ([]byte, error) { + if !secretReferencePattern.MatchString(reference) { + return nil, errors.New("security event secret reference is invalid") + } + request, err := s.request(ctx, http.MethodGet, nil) + if err != nil { + return nil, err + } + response, err := s.client.Do(request) + if err != nil { + return nil, fmt.Errorf("read Kubernetes security event Secret: %w", err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotFound { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return nil, ErrSecretNotFound + } + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + return nil, fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode) + } + var payload struct { + Data map[string]string `json:"data"` + } + if err := decodeLimitedJSON(response.Body, &payload); err != nil { + return nil, err + } + encoded, ok := payload.Data[reference] + if !ok { + return nil, ErrSecretNotFound + } + value, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(value) < 32 || len(value) > 4096 { + clear(value) + return nil, errors.New("Kubernetes security event Secret value is invalid") + } + return value, nil +} + +func (s *KubernetesSecretStore) Delete(ctx context.Context, reference string) error { + if !secretReferencePattern.MatchString(reference) { + return errors.New("security event secret reference is invalid") + } + return s.patch(ctx, map[string]any{reference: nil}) +} + +func (s *KubernetesSecretStore) patch(ctx context.Context, data map[string]any) error { + payload, err := json.Marshal(map[string]any{"data": data}) + if err != nil { + return err + } + request, err := s.request(ctx, http.MethodPatch, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/merge-patch+json") + response, err := s.client.Do(request) + if err != nil { + return fmt.Errorf("update Kubernetes security event Secret: %w", err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024)) + if response.StatusCode == http.StatusNotFound { + return errors.New("Kubernetes security event Secret is not provisioned") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Kubernetes security event Secret returned HTTP %d", response.StatusCode) + } + return nil +} + +func (s *KubernetesSecretStore) request(ctx context.Context, method string, body io.Reader) (*http.Request, error) { + token, err := os.ReadFile(s.tokenFile) + if err != nil || strings.TrimSpace(string(token)) == "" { + clear(token) + return nil, errors.New("Kubernetes service account token is unavailable") + } + request, err := http.NewRequestWithContext(ctx, method, s.endpoint, body) + if err != nil { + clear(token) + return nil, err + } + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) + request.Header.Set("Accept", "application/json") + clear(token) + return request, nil +} + +func validKubernetesDNSName(value string) bool { + return len(value) > 0 && len(value) <= 253 && kubernetesDNSNamePattern.MatchString(value) +} diff --git a/apps/api/internal/securityevents/kubernetes_secret_store_test.go b/apps/api/internal/securityevents/kubernetes_secret_store_test.go new file mode 100644 index 0000000..fadbc31 --- /dev/null +++ b/apps/api/internal/securityevents/kubernetes_secret_store_test.go @@ -0,0 +1,95 @@ +package securityevents + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestKubernetesSecretStoreUsesOnePreprovisionedSecret(t *testing.T) { + var mutex sync.Mutex + data := map[string]string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/namespaces/easyai/secrets/easyai-gateway-security-events" || r.Header.Get("Authorization") != "Bearer service-account-token" { + t.Fatalf("unexpected Kubernetes request: %s authorization=%t", r.URL.Path, r.Header.Get("Authorization") != "") + } + mutex.Lock() + defer mutex.Unlock() + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + case http.MethodPatch: + var patch struct { + Data map[string]*string `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + t.Fatal(err) + } + for key, value := range patch.Data { + if value == nil { + delete(data, key) + } else { + data[key] = *value + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + default: + http.Error(w, "unsupported", http.StatusMethodNotAllowed) + } + })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("service-account-token\n"), 0o600); err != nil { + t.Fatal(err) + } + secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{ + Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL, + TokenFile: tokenFile, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + value := []byte("0123456789abcdef0123456789abcdef") + if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil { + t.Fatal(err) + } + if data["ssf-push-connection"] != base64.StdEncoding.EncodeToString(value) { + t.Fatal("Push Bearer was not stored in Kubernetes Secret data") + } + loaded, err := secrets.Get(context.Background(), "ssf-push-connection") + if err != nil || string(loaded) != string(value) { + t.Fatalf("loaded secret mismatch: err=%v", err) + } + clear(loaded) + if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil { + t.Fatal(err) + } + if _, ok := data["ssf-push-connection"]; ok { + t.Fatal("Push Bearer key was not removed") + } +} + +func TestKubernetesSecretStoreRejectsUnprovisionedSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) })) + defer server.Close() + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("token"), 0o600); err != nil { + t.Fatal(err) + } + secrets, err := NewKubernetesSecretStore(KubernetesSecretStoreConfig{ + Namespace: "easyai", SecretName: "easyai-gateway-security-events", APIServer: server.URL, + TokenFile: tokenFile, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if err := secrets.Put(context.Background(), "ssf-push-connection", []byte("0123456789abcdef0123456789abcdef")); err == nil { + t.Fatal("missing pre-provisioned Kubernetes Secret was accepted") + } +} diff --git a/apps/api/internal/securityevents/metrics.go b/apps/api/internal/securityevents/metrics.go index 39a5fc3..b7a8e88 100644 --- a/apps/api/internal/securityevents/metrics.go +++ b/apps/api/internal/securityevents/metrics.go @@ -2,16 +2,24 @@ package securityevents import ( "context" + "errors" "fmt" "net/http" "sync/atomic" "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) type MetricsSnapshotProvider interface { SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) } +type DynamicMetricsSnapshotProvider interface { + MetricsSnapshotProvider + SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) +} + type Metrics struct { accepted atomic.Uint64 rejected atomic.Uint64 @@ -151,6 +159,25 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str }) } +func (m *Metrics) DynamicHandler(provider DynamicMetricsSnapshotProvider) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + connection, err := provider.SecurityEventConnection(r.Context()) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + m.Handler(provider, "", "", false).ServeHTTP(w, r) + return + } + if err != nil { + http.Error(w, "metrics unavailable", http.StatusServiceUnavailable) + return + } + if connection.Audience == nil { + m.Handler(provider, "", "", false).ServeHTTP(w, r) + return + } + m.Handler(provider, connection.TransmitterIssuer, *connection.Audience, true).ServeHTTP(w, r) + }) +} + type outcomeValue struct { name string value uint64 diff --git a/apps/api/internal/securityevents/secret_store.go b/apps/api/internal/securityevents/secret_store.go new file mode 100644 index 0000000..2b39bd4 --- /dev/null +++ b/apps/api/internal/securityevents/secret_store.go @@ -0,0 +1,112 @@ +package securityevents + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" +) + +var secretReferencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`) + +var ErrSecretNotFound = errors.New("security event secret not found") + +type SecretStore interface { + Put(context.Context, string, []byte) error + Get(context.Context, string) ([]byte, error) + Delete(context.Context, string) error +} + +type FileSecretStore struct{ directory string } + +func NewFileSecretStore(directory string) (*FileSecretStore, error) { + if directory == "" { + return nil, errors.New("security event secret directory is required") + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, fmt.Errorf("create security event secret directory: %w", err) + } + if err := os.Chmod(directory, 0o700); err != nil { + return nil, fmt.Errorf("protect security event secret directory: %w", err) + } + return &FileSecretStore{directory: directory}, nil +} + +func (s *FileSecretStore) Put(_ context.Context, reference string, value []byte) error { + path, err := s.path(reference) + if err != nil { + return err + } + if len(value) < 32 || len(value) > 4096 { + return errors.New("security event secret length is invalid") + } + temporary, err := os.CreateTemp(s.directory, ".ssf-secret-*") + if err != nil { + return fmt.Errorf("create temporary security event secret: %w", err) + } + temporaryName := temporary.Name() + defer func() { _ = os.Remove(temporaryName) }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(value); err != nil { + _ = temporary.Close() + return fmt.Errorf("write security event secret: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, path); err != nil { + return fmt.Errorf("publish security event secret: %w", err) + } + return os.Chmod(path, 0o600) +} + +func (s *FileSecretStore) Get(_ context.Context, reference string) ([]byte, error) { + path, err := s.path(reference) + if err != nil { + return nil, err + } + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrSecretNotFound + } + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return nil, errors.New("security event secret file is not protected") + } + value, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read security event secret: %w", err) + } + if len(value) < 32 || len(value) > 4096 { + clear(value) + return nil, errors.New("security event secret length is invalid") + } + return value, nil +} + +func (s *FileSecretStore) Delete(_ context.Context, reference string) error { + path, err := s.path(reference) + if err != nil { + return err + } + err = os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func (s *FileSecretStore) path(reference string) (string, error) { + if !secretReferencePattern.MatchString(reference) { + return "", errors.New("security event secret reference is invalid") + } + return filepath.Join(s.directory, reference), nil +} diff --git a/apps/api/internal/securityevents/secret_store_test.go b/apps/api/internal/securityevents/secret_store_test.go new file mode 100644 index 0000000..d94934c --- /dev/null +++ b/apps/api/internal/securityevents/secret_store_test.go @@ -0,0 +1,40 @@ +package securityevents + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestFileSecretStoreCreatesProtectedFilesAndNeverAcceptsTraversal(t *testing.T) { + directory := filepath.Join(t.TempDir(), "ssf") + secrets, err := NewFileSecretStore(directory) + if err != nil { + t.Fatal(err) + } + value := bytes.Repeat([]byte{0x41}, 32) + if err := secrets.Put(context.Background(), "ssf-push-connection", value); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(directory, "ssf-push-connection")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("secret mode=%v err=%v", info.Mode().Perm(), err) + } + loaded, err := secrets.Get(context.Background(), "ssf-push-connection") + if err != nil || !bytes.Equal(loaded, value) { + t.Fatalf("secret mismatch err=%v", err) + } + clear(loaded) + if err := secrets.Put(context.Background(), "../escape", value); err == nil { + t.Fatal("path traversal reference was accepted") + } + if err := secrets.Delete(context.Background(), "ssf-push-connection"); err != nil { + t.Fatal(err) + } + if _, err := secrets.Get(context.Background(), "ssf-push-connection"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("deleted secret error=%v", err) + } +} diff --git a/apps/api/internal/securityevents/service.go b/apps/api/internal/securityevents/service.go index 6814161..053d60f 100644 --- a/apps/api/internal/securityevents/service.go +++ b/apps/api/internal/securityevents/service.go @@ -75,13 +75,24 @@ func NewService(repository StateRepository, config ServiceConfig) (*Service, err } func (s *Service) Start(ctx context.Context) error { - if err := s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID); err != nil { + if err := s.Initialize(ctx); err != nil { return err } - go s.run(ctx) + go s.Run(ctx) return nil } +func (s *Service) Initialize(ctx context.Context) error { + return s.repository.EnsureSecurityEventStreamState(ctx, s.config.Issuer, s.config.Audience, s.config.StreamID) +} + +func (s *Service) Run(ctx context.Context) { s.run(ctx) } + +// RequestVerification asks for an immediate verification without changing the +// remote stream status. In particular, this never re-enables a stream paused +// by an authentication-center administrator. +func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification(ctx) } + func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) { result, err := s.repository.EvaluateOIDCSecurityEvent( ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject, diff --git a/apps/api/internal/store/security_event_connections.go b/apps/api/internal/store/security_event_connections.go new file mode 100644 index 0000000..fb0e198 --- /dev/null +++ b/apps/api/internal/store/security_event_connections.go @@ -0,0 +1,175 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +var ( + ErrSecurityEventConnectionNotFound = errors.New("security event connection not found") + ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state") +) + +type SecurityEventConnection struct { + ConnectionID string `json:"connectionId"` + TransmitterIssuer string `json:"transmitterIssuer"` + EndpointURL string `json:"endpointUrl"` + Audience *string `json:"audience,omitempty"` + StreamID *string `json:"streamId,omitempty"` + CredentialRef string `json:"-"` + NextCredentialRef *string `json:"-"` + LifecycleStatus string `json:"lifecycleStatus"` + Version int64 `json:"version"` + LastErrorCategory *string `json:"lastErrorCategory,omitempty"` + IdempotencyKey string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"` + HealthMode string `json:"healthMode"` +} + +type CreateSecurityEventConnectionInput struct { + ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string +} + +type SecurityEventConnectionIdempotency struct { + RequestHash string + Response json.RawMessage +} + +func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) { + var value SecurityEventConnectionIdempotency + err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency +WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(&value.RequestHash, &value.Response) + if errors.Is(err, pgx.ErrNoRows) { + return SecurityEventConnectionIdempotency{}, ErrSecurityEventConnectionNotFound + } + return value, err +} + +func (s *Store) RecordSecurityEventConnectionIdempotency(ctx context.Context, operation, key, requestHash string, response []byte) error { + if !json.Valid(response) { + return errors.New("security event idempotency response is invalid") + } + _, err := s.pool.Exec(ctx, `INSERT INTO gateway_security_event_connection_idempotency(operation,idempotency_key,request_hash,response) +VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, operation, key, requestHash, response) + return err +} + +func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) { + var value SecurityEventConnection + err := s.pool.QueryRow(ctx, ` +INSERT INTO gateway_security_event_connections( + connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key +) VALUES($1::uuid,$2,$3,$4,'connecting',$5) +RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref, + next_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`, + input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.IdempotencyKey, + ).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, + &value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version, + &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt) + if err != nil { + if isUniqueViolation(err) { + existing, getErr := s.SecurityEventConnection(ctx) + if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer { + return existing, nil + } + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + return SecurityEventConnection{}, err + } + return value, nil +} + +func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) { + var value SecurityEventConnection + err := s.pool.QueryRow(ctx, ` +SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience, + connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,connection.lifecycle_status, + connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at, + state.last_verification_at,COALESCE(state.mode,'disabled') +FROM gateway_security_event_connections connection +LEFT JOIN gateway_security_event_stream_state state + ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience +WHERE connection.singleton=true`).Scan( + &value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, + &value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version, + &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt, + &value.LastVerificationAt, &value.HealthMode, + ) + if errors.Is(err, pgx.ErrNoRows) { + return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + } + return value, err +} + +func (s *Store) BindSecurityEventConnection(ctx context.Context, connectionID, audience, streamID, lifecycle string, expectedVersion int64) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET audience=$2,stream_id=$3::uuid,lifecycle_status=$4,last_error_category=NULL,version=version+1,updated_at=now() +WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, lifecycle, expectedVersion) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now() +WHERE connection_id=$1::uuid`, connectionID, reference) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL, + version=version+1,updated_at=now() +WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextReference) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) DeleteSecurityEventConnection(ctx context.Context, connectionID string) error { + if _, err := uuid.Parse(connectionID); err != nil { + return ErrSecurityEventConnectionNotFound + } + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSecurityEventConnectionNotFound + } + return nil +} diff --git a/apps/api/migrations/0063_security_event_connections.sql b/apps/api/migrations/0063_security_event_connections.sql new file mode 100644 index 0000000..21171b8 --- /dev/null +++ b/apps/api/migrations/0063_security_event_connections.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS gateway_security_event_connections ( + connection_id uuid PRIMARY KEY, + singleton boolean NOT NULL DEFAULT true UNIQUE CHECK (singleton), + transmitter_issuer text NOT NULL, + endpoint_url text NOT NULL, + audience text, + stream_id uuid, + credential_ref text NOT NULL, + next_credential_ref text, + lifecycle_status text NOT NULL, + version bigint NOT NULL DEFAULT 1, + last_error_category text, + idempotency_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT gateway_security_event_connection_status CHECK (lifecycle_status IN ( + 'connecting','verifying','bootstrap','enabled','degraded','rotating', + 'disconnect_pending','retiring','error' + )), + CONSTRAINT gateway_security_event_connection_stream_pair CHECK ( + (audience IS NULL AND stream_id IS NULL) OR (audience IS NOT NULL AND stream_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_security_event_connections_idempotency + ON gateway_security_event_connections(idempotency_key); + +CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency ( + operation text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (operation, idempotency_key), + CONSTRAINT gateway_security_event_connection_idempotency_operation + CHECK (operation IN ('connect','verify','rotate','disconnect')) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created + ON gateway_security_event_connection_idempotency(created_at); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d115dd0..9792c91 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -32,6 +32,7 @@ import type { PricingRuleSet, RateLimitWindow, RuntimePolicySet, + SecurityEventConnectionResponse, UserGroupUpsertRequest, UserGroup, WalletRechargeRequest, @@ -46,6 +47,7 @@ import { createPlatform, createTenant, createUserGroup, + connectSecurityEventTransmitter, deleteAccessRule, deleteApiKey, deleteFileStorageChannel, @@ -53,6 +55,7 @@ import { deletePlatform, deleteTenant, deleteUserGroup, + disconnectSecurityEventConnection, GatewayApiError, getClientCustomizationSettings, getHealth, @@ -61,6 +64,7 @@ import { getFileStorageSettings, getNetworkProxyConfig, getRunnerPolicy, + getSecurityEventConnection, getWalletSummary, listAccessRules, listAuditLogs, @@ -93,6 +97,7 @@ import { rechargeUserWalletBalance, replacePlatformModels, restoreModelRuntimeStatus, + rotateSecurityEventConnectionCredential, type HealthResponse, updateAccessRule, updateApiKeyScopes, @@ -104,6 +109,7 @@ import { updatePlatformDynamicPriority, updateTenant, updateUserGroup, + verifySecurityEventConnection, } from './api'; import type { ConsoleData, StatItem } from './app-state'; import { AppShell } from './components/layout/AppShell'; @@ -173,6 +179,7 @@ type DataKey = | 'clientCustomizationSettings' | 'fileStorageChannels' | 'fileStorageSettings' + | 'securityEventConnection' | 'platforms' | 'models' | 'providers' @@ -222,6 +229,7 @@ export function App() { const [clientCustomizationSettings, setClientCustomizationSettings] = useState(null); const [fileStorageChannels, setFileStorageChannels] = useState([]); const [fileStorageSettings, setFileStorageSettings] = useState(null); + const [securityEventConnection, setSecurityEventConnection] = useState(null); const [providers, setProviders] = useState([]); const [baseModels, setBaseModels] = useState([]); const [pricingRules, setPricingRules] = useState([]); @@ -383,6 +391,7 @@ export function App() { modelRateLimits, modelRateLimitsUpdatedAt, runtimePolicySets, + securityEventConnection, taskResult, tasks, tenants, @@ -390,7 +399,7 @@ export function App() { users, walletAccounts, walletTransactions, - }), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); + }), [accessRules, apiKeys, auditLogs, baseModels, clientCustomizationSettings, currentUser, currentUserGroups, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, securityEventConnection, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -480,6 +489,9 @@ export function App() { case 'fileStorageSettings': setFileStorageSettings(await getFileStorageSettings(nextToken)); return; + case 'securityEventConnection': + setSecurityEventConnection(await getSecurityEventConnection(nextToken)); + return; case 'playgroundModels': setPlaygroundModels((await listPlayableModels(nextToken)).items); return; @@ -1017,6 +1029,44 @@ export function App() { } } + async function connectSecurityEvents(transmitterIssuer: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + const connection = await connectSecurityEventTransmitter(token, transmitterIssuer); + setSecurityEventConnection(connection); + setCoreState('ready'); + setCoreMessage('认证中心安全事件连接正在验证。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '连接认证中心失败'); + throw err; + } + } + + async function refreshSecurityEvents() { + const connection = await getSecurityEventConnection(token); + setSecurityEventConnection(connection); + } + + async function verifySecurityEvents() { + const connection = securityEventConnection?.connection; + if (!connection) return; + setSecurityEventConnection(await verifySecurityEventConnection(token, connection.version)); + } + + async function rotateSecurityEventsCredential() { + const connection = securityEventConnection?.connection; + if (!connection) return; + setSecurityEventConnection(await rotateSecurityEventConnectionCredential(token, connection.version)); + } + + async function disconnectSecurityEvents() { + const connection = securityEventConnection?.connection; + if (!connection) return; + setSecurityEventConnection(await disconnectSecurityEventConnection(token, connection.version)); + } + async function removeFileStorageChannel(channelId: string) { setCoreState('loading'); setCoreMessage(''); @@ -1095,6 +1145,7 @@ export function App() { setPlaygroundModels([]); setNetworkProxyConfig(null); setFileStorageChannels([]); + setSecurityEventConnection(null); setProviders([]); setBaseModels([]); setPricingRules([]); @@ -1332,6 +1383,11 @@ export function App() { onSaveFileStorageChannel={saveFileStorageChannel} onSaveFileStorageSettings={saveFileStorageSettings} onSaveClientCustomizationSettings={saveClientCustomizationSettings} + onConnectSecurityEvents={connectSecurityEvents} + onDisconnectSecurityEvents={disconnectSecurityEvents} + onRefreshSecurityEvents={refreshSecurityEvents} + onRotateSecurityEventsCredential={rotateSecurityEventsCredential} + onVerifySecurityEvents={verifySecurityEvents} onSaveTenant={saveTenant} onSaveUser={saveUser} onRechargeUserWalletBalance={rechargeUserWallet} @@ -1553,7 +1609,7 @@ function dataKeysForRoute( case 'accessRules': return ['accessRules', 'userGroups', 'platforms', 'models']; case 'systemSettings': - return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels']; + return ['clientCustomizationSettings', 'fileStorageSettings', 'fileStorageChannels', 'securityEventConnection']; default: return []; } diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 3e50a58..ddaa1f1 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + connectSecurityEventTransmitter, deleteOIDCBrowserSession, GatewayApiError, gatewayErrorMessage, @@ -34,6 +35,29 @@ describe('Gateway provisioning errors', () => { }); }); +describe('security event connection transport', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends only the public transmitter issuer and concurrency headers', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf'); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PUT'); + expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' }); + expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); + }); +}); + describe('OIDC browser session transport', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a548d99..defcd37 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -43,6 +43,7 @@ import type { RateLimitWindow, RuntimePolicySet, RuntimePolicySetUpsertRequest, + SecurityEventConnectionResponse, UserGroup, UserGroupUpsertRequest, WalletAdjustmentResponse, @@ -1000,6 +1001,40 @@ export async function updateClientCustomizationSettings( }); } +const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection'; + +export async function getSecurityEventConnection(token: string): Promise { + return request(securityEventConnectionPath, { token }); +} + +export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise { + return request(securityEventConnectionPath, { + body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token, + headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }, + }); +} + +export async function verifySecurityEventConnection(token: string, version: number): Promise { + return securityEventConnectionWrite(token, '/verify', version, 'POST', 'verify'); +} + +export async function rotateSecurityEventConnectionCredential(token: string, version: number): Promise { + return securityEventConnectionWrite(token, '/rotate-credential', version, 'POST', 'rotate'); +} + +export async function disconnectSecurityEventConnection(token: string, version: number): Promise { + return securityEventConnectionWrite(token, '', version, 'DELETE', 'disconnect'); +} + +function securityEventConnectionWrite(token: string, suffix: string, version: number, method: string, action: string) { + return request(securityEventConnectionPath + suffix, { + method, token, headers: { + 'Idempotency-Key': `ssf-${action}-${crypto.randomUUID()}`, + 'If-Match': `W/"${version}"`, + }, + }); +} + export async function getPublicClientCustomization(): Promise { return request('/api/v1/public/client-customization', { auth: false }); } diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index a45c190..9ef928f 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -23,6 +23,7 @@ import type { PricingRuleSet, RateLimitWindow, RuntimePolicySet, + SecurityEventConnectionResponse, UserGroup, } from '@easyai-ai-gateway/contracts'; @@ -48,6 +49,7 @@ export interface ConsoleData { modelRateLimits: ModelRateLimitStatus[]; modelRateLimitsUpdatedAt: number | null; runtimePolicySets: RuntimePolicySet[]; + securityEventConnection: SecurityEventConnectionResponse | null; taskResult: GatewayTask | null; tasks: GatewayTask[]; tenants: GatewayTenant[]; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 829fe3e..e406a37 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -82,6 +82,11 @@ export function AdminPage(props: { onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; + onConnectSecurityEvents: (transmitterIssuer: string) => Promise; + onDisconnectSecurityEvents: () => Promise; + onRefreshSecurityEvents: () => Promise; + onRotateSecurityEventsCredential: () => Promise; + onVerifySecurityEvents: () => Promise; onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise; onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise; onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise; @@ -189,12 +194,18 @@ export function AdminPage(props: { channels={props.data.fileStorageChannels} clientCustomizationSettings={props.data.clientCustomizationSettings} settings={props.data.fileStorageSettings} + securityEventConnection={props.data.securityEventConnection} message={props.operationMessage} state={props.state} onDeleteFileStorageChannel={props.onDeleteFileStorageChannel} onSaveFileStorageChannel={props.onSaveFileStorageChannel} onSaveFileStorageSettings={props.onSaveFileStorageSettings} onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings} + onConnectSecurityEvents={props.onConnectSecurityEvents} + onDisconnectSecurityEvents={props.onDisconnectSecurityEvents} + onRefreshSecurityEvents={props.onRefreshSecurityEvents} + onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential} + onVerifySecurityEvents={props.onVerifySecurityEvents} /> )} diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 93bc7dc..0367987 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -1,10 +1,10 @@ import { useEffect, useState, type FormEvent } from 'react'; -import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, Trash2 } from 'lucide-react'; -import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts'; +import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react'; +import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui'; import type { LoadState } from '../../types'; -type SystemSettingsTab = 'fileStorage' | 'clientCustomization'; +type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents'; type ClientCustomizationForm = { clientEnglishName: string; @@ -58,11 +58,17 @@ export function SystemSettingsPanel(props: { clientCustomizationSettings: ClientCustomizationSettings | null; message: string; settings: FileStorageSettings | null; + securityEventConnection: SecurityEventConnectionResponse | null; state: LoadState; onDeleteFileStorageChannel: (channelId: string) => Promise; onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; + onConnectSecurityEvents: (transmitterIssuer: string) => Promise; + onDisconnectSecurityEvents: () => Promise; + onRefreshSecurityEvents: () => Promise; + onRotateSecurityEventsCredential: () => Promise; + onVerifySecurityEvents: () => Promise; }) { const [activeTab, setActiveTab] = useState('fileStorage'); const [dialogOpen, setDialogOpen] = useState(false); @@ -72,6 +78,8 @@ export function SystemSettingsPanel(props: { const [clientCustomizationForm, setClientCustomizationForm] = useState(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings)); const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); const [localError, setLocalError] = useState(''); + const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf'); + const [disconnectOpen, setDisconnectOpen] = useState(false); useEffect(() => { setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); @@ -81,6 +89,13 @@ export function SystemSettingsPanel(props: { setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings)); }, [props.clientCustomizationSettings]); + useEffect(() => { + const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus; + if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined; + const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000); + return () => window.clearInterval(timer); + }, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]); + function openCreateDialog() { setEditingChannel(null); setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`)); @@ -162,6 +177,7 @@ export function SystemSettingsPanel(props: { tabs={[ { value: 'fileStorage', label: '文件存储', icon: }, { value: 'clientCustomization', label: '客户端自定义', icon: }, + { value: 'securityEvents', label: '认证中心安全事件', icon: }, ]} onValueChange={setActiveTab} /> @@ -275,6 +291,20 @@ export function SystemSettingsPanel(props: { )} + {activeTab === 'securityEvents' && ( + setDisconnectOpen(true)} + onRefresh={props.onRefreshSecurityEvents} + onRotate={props.onRotateSecurityEventsCredential} + onVerify={props.onVerifySecurityEvents} + /> + )} + setPendingDeleteChannel(null)} onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined} /> + setDisconnectOpen(false)} + onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }} + /> ); } +function SecurityEventConnectionPanel(props: { + connection: SecurityEventConnectionResponse | null; + issuer: string; + loading: boolean; + onIssuerChange(value: string): void; + onConnect(issuer: string): Promise; + onDisconnect(): void; + onRefresh(): Promise; + onRotate(): Promise; + onVerify(): Promise; +}) { + const connection = props.connection?.connection; + const prerequisites = props.connection?.prerequisites; + const missing = [ + !prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '', + !prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '', + !prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '', + ].filter(Boolean); + return ( +
+
+
+ SSF / CAEP 实时会话撤销 + 只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。 +
+ + {securityEventLifecycleLabel(connection?.lifecycleStatus)} + + +
+ {!connection && ( + + +
+ 连接认证中心 +

请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。

+
+ {missing.length > 0 &&
{missing.join(';')}
} +
+ 复用机器 Client: {prerequisites?.managementClientId || '未配置'} +
+ + +
+
+ )} + {connection && ( + + +
+ 机器 Client: {connection.managementClientId} + Receiver Endpoint: {connection.receiverEndpoint} + Issuer: {connection.transmitterIssuer} + Audience: {connection.audience ?? '正在获取'} + Stream ID: {connection.streamId ?? '正在创建'} + 健康模式: {securityEventHealthLabel(connection.healthMode)} + 最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'} + {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}} +
+
+ + + +
+
+
+ )} +
+ ); +} + +function securityEventLifecycleLabel(status?: string) { + return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record)[status ?? ''] ?? '未连接'; +} + +function securityEventHealthLabel(mode: string) { + return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record)[mode] ?? mode; +} + function defaultClientCustomizationForm(): ClientCustomizationForm { return { clientEnglishName: 'EasyAI AI Gateway', diff --git a/deploy/kubernetes/security-events-secret-rbac.yaml b/deploy/kubernetes/security-events-secret-rbac.yaml new file mode 100644 index 0000000..ad40b69 --- /dev/null +++ b/deploy/kubernetes/security-events-secret-rbac.yaml @@ -0,0 +1,34 @@ +# Reference overlay for installations that run the Gateway in Kubernetes. +# Keep the Secret empty: the Gateway writes only generated Push Bearers. +apiVersion: v1 +kind: Secret +metadata: + name: easyai-gateway-security-events + namespace: easyai +type: Opaque +data: {} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: easyai-gateway-security-events + namespace: easyai +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["easyai-gateway-security-events"] + verbs: ["get", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: easyai-gateway-security-events + namespace: easyai +subjects: + - kind: ServiceAccount + name: easyai-ai-gateway + namespace: easyai +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: easyai-gateway-security-events diff --git a/docker-compose.yml b/docker-compose.yml index eda6f9b..3e0852f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,16 +17,8 @@ x-api-environment: &api-environment OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false} OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-} OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-} - OIDC_SECURITY_EVENTS_ENABLED: ${OIDC_SECURITY_EVENTS_ENABLED:-false} - OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER: ${OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER:-} - OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE: ${OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE:-} - OIDC_SECURITY_EVENTS_BEARER_SECRET: ${OIDC_SECURITY_EVENTS_BEARER_SECRET:-} - OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT: ${OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT:-} - OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT: ${OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT:-} - OIDC_SECURITY_EVENTS_STREAM_ID: ${OIDC_SECURITY_EVENTS_STREAM_ID:-} - OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL: ${OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL:-} - OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID:-} - OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET: ${OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET:-} + OIDC_SECURITY_EVENTS_SECRET_STORE: file + OIDC_SECURITY_EVENTS_SECRET_DIR: /app/data/security-events/secrets OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index f2cc589..aee2578 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -1,56 +1,67 @@ # SSF/CAEP 实时会话撤销运行手册 -Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。默认关闭;关闭时不会注册接收路由或启动状态机,也不会改变 OIDC、BFF、API Key 或 Legacy JWT 行为。 +Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。功能由数据库中的连接资源控制:没有连接时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为;不再使用 `OIDC_SECURITY_EVENTS_ENABLED`。 -## 启用前提 +## 用户接入流程 -- Auth Center 已创建属于当前 Application/租户的 paused Stream,Audience 为 `urn:easyai:ssf:receiver:{application-public-id}`。 -- Gateway OIDC `iss`、`tid` 与 Stream 绑定完全一致;Stream ID 为公开 UUID,不使用认证内核 ID。 -- 接收 Bearer、Management Client Secret、RFC 7662 Client Secret 仅从 Git 忽略的 `.env`、Kubernetes Secret 或 Secret Manager 注入。 -- `OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT` 必须是外部可访问的精确 HTTPS 地址;本地测试环境才允许 localhost HTTP。 -- Management Client 至少有 `ssf.stream.read ssf.stream.verify`,不能使用浏览器 PKCE Client。 +用户只执行两项操作: -完整配置键见仓库根目录 `.env.example`。启用时 `OIDC_SECURITY_EVENTS_ENABLED=true`,且 RFC 7662 Client ID/Secret 必须同时存在,否则启动失败。 +1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。 +2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer,点击“连接认证中心”。 -## 启用顺序 +Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。 -1. 运行数据库迁移,部署仍保持功能关闭。 -2. 在 Secret Store 创建接收 Bearer、Management Client Secret 和内省 Client Secret;不要把值放进日志或工单。 -3. 启用 Gateway。启动模式为 `bootstrap`,所有 OIDC Token 都先内省。 -4. 在 Auth Center 对 paused Stream 发起 Verification,确认 Gateway 返回空 Body 的 `202`。 -5. 启用 Stream。首次 Verification 成功后仍内省 360 秒,覆盖启用前已签发的最长 300 秒 Token。 -6. 观察 `easyai_gateway_ssf_mode{mode="push_healthy"}` 变为 1,再执行测试用户撤销。 +前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址: -健康时 Gateway 每 60 秒请求一次 Verification,不按业务 QPS 请求 Auth Center。180 秒没有匹配的有效 Verification 会进入 `introspection_fallback`;只有最近收到的 Stream 状态为 `enabled` 时,连续两次 Verification 成功才会恢复,`paused`/`disabled` 状态下心跳不会错误恢复推送信任。降级期间内省不可用会对 OIDC 返回可审计的 503,API Key 继续工作。 +- `OIDC_ISSUER`、`OIDC_TENANT_ID`; +- `OIDC_INTROSPECTION_ENABLED=true`; +- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同; +- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。 -## 零停机 Bearer 轮换 +第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。 -1. 在 Gateway 写入 `OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT`,同时接受 current/next。 -2. 更新 Auth Center 的 Stream `credential_ref` 指向 next。 -3. 发起 Verification,并确认投递和状态机健康。 -4. 等待至少 180 秒后,将 next 提升为 current 并清空旧值。 +## SecretStore -Gateway 使用常量时间比较两个值。响应、日志、审计和指标都不得包含 Authorization Header 或原始 SET。 +本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`;Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 `ssf-delivery-*` 文件。 -## 监控与告警 +Kubernetes 使用 `kubernetes` 驱动和一个部署时预创建的空 Secret: -`GET /metrics` 输出以下低基数指标;生产入口应仅允许监控网络访问该路径: +```text +OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes +OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai +OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events +``` -- `easyai_gateway_ssf_receipts_total{outcome}`:accepted/rejected/duplicate。 -- `easyai_gateway_ssf_sessions_deleted_total`:被撤销删除的 BFF Session。 -- `easyai_gateway_ssf_watermark_rejections_total`:本地水位拒绝的 Token。 -- `easyai_gateway_ssf_processing_duration_seconds`:Receiver 数据库事务处理直方图,用于 P99 告警。 -- `easyai_gateway_ssf_verification_age_seconds` 和 `easyai_gateway_ssf_mode{mode}`。 -- `easyai_gateway_ssf_heartbeat_requests_total{outcome}`。 -- `easyai_gateway_oidc_introspection_total{outcome}`。 -- `easyai_gateway_jwks_refresh_failures_total{source}`:SSF/OIDC JWKS 刷新失败。 +参考清单见 `deploy/kubernetes/security-events-secret-rbac.yaml`。ServiceAccount 只能对这个固定 Secret 执行 `get/update/patch`,不能创建、删除或读取其他 Secret。数据库、API、浏览器、日志和审计只保存或展示非敏感连接元数据。 -至少配置以下告警:Verification age 超过 120 秒、fallback 超过 5 分钟、内省 failed 增长、SET rejected 增长、撤销端到端 P99 超过 3 秒。日志只能携带脱敏 Trace ID、Audit ID 和错误类别。 +## 动态运行与状态 -## 回滚 +Receiver 路由始终注册:没有连接时返回无敏感信息的 `404`;存在连接但凭据丢失时返回 `503` 并进入 `introspection_fallback`。OIDC Evaluator 始终挂载但在无连接时无副作用。 -先在 Auth Center 暂停 Stream,再保持 Gateway 启用但持续使用 RFC 7662,确认没有遗漏撤销后才关闭接收功能。保留迁移表和 Secret,不做破坏性数据库回滚。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。 +连接生命周期包括 `connecting`、`verifying`、`bootstrap`、`enabled`、`degraded`、`rotating`、`disconnect_pending` 和 `retiring`。健康时每 60 秒一次 Verification,不随业务请求 QPS 增长;180 秒无匹配 Verification 时切换 RFC 7662。内省也不可用时 OIDC Fail Closed,API Key 继续工作。 -## 验收证据 +Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。认证中心管理员之后手工暂停或禁用 Stream,Gateway 的“重新验证”只检查链路,不会擅自恢复远端状态。 -真实链路验收只能使用明确标记为测试用途的 Application,且必须在自动化测试通过后执行。报告需包含脱敏 Trace、Claims 结构、截图、Audit ID、投递延迟、fallback 与恢复证据。涉及人工、真实设备或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`,Mock、契约和安全测试不得跳过。 +## 轮换与断开 + +“轮换凭据”由 Gateway 自动完成:生成 next、Receiver 同时接受 current/next、暂停并更新远端 Stream、Verification、重新启用、保留旧值至少 180 秒,最后提升 next 并删除旧值。中途失败保留两个凭据和 `rotating` 状态,可安全重试。 + +“断开连接”先删除远端 Stream,再进入至少 360 秒 `retiring`,期间继续应用撤销水位和 RFC 7662,避免旧 Token 因关闭功能重新有效。远端不可用时保持 `disconnect_pending` 并指数退避重试;不会提前删除 Push Secret。收据、水位和脱敏审计数据不会清表。 + +所有写管理接口要求 Gateway `Manager` 权限、`Idempotency-Key` 和 `If-Match`: + +```text +GET /api/admin/system/identity/security-events/connection +PUT /api/admin/system/identity/security-events/connection +POST /api/admin/system/identity/security-events/connection/verify +POST /api/admin/system/identity/security-events/connection/rotate-credential +DELETE /api/admin/system/identity/security-events/connection +``` + +## 监控、回滚与验收 + +`GET /metrics` 输出接收结果、删除 Session 数、水位拒绝数、Verification 年龄、健康模式、内省结果、JWKS 失败和处理延迟。至少告警 Verification age 超过 120 秒、fallback 超过 5 分钟、内省失败、SET 拒绝增长以及撤销 P99 超过 3 秒。日志只记录脱敏 Trace/Audit ID 和错误类别。 + +回滚时先在认证中心暂停 Stream,再通过 Gateway 安全断开;保留迁移表、水位和审计。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。 + +真实链路只使用明确标记为测试用途的 Application,并且必须在自动化测试后执行。报告包含脱敏 Trace、Claims、截图、Audit ID、投递延迟、fallback 和恢复证据;需要人工或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e4677e0..d229616 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -957,6 +957,34 @@ export interface ClientCustomizationSettingsUpdateRequest { iconPath?: string; } +export interface SecurityEventConnection { + connectionId: string; + transmitterIssuer: string; + receiverEndpoint: string; + audience?: string; + streamId?: string; + lifecycleStatus: 'connecting' | 'verifying' | 'bootstrap' | 'enabled' | 'degraded' | 'rotating' | 'disconnect_pending' | 'retiring' | 'error' | string; + healthMode: 'disabled' | 'bootstrap' | 'push_healthy' | 'introspection_fallback' | string; + managementClientId: string; + lastVerificationAt?: string; + lastErrorCategory?: string; + version: number; +} + +export interface SecurityEventConnectionPrerequisites { + oidcConfigured: boolean; + introspectionClientConfigured: boolean; + publicBaseUrlConfigured: boolean; + managementClientId: string; +} + +export interface SecurityEventConnectionResponse { + connected: boolean; + lifecycleStatus?: 'disconnected' | string; + connection?: SecurityEventConnection; + prerequisites?: SecurityEventConnectionPrerequisites; +} + export interface GatewayTask { id: string; kind: string; From 42ddc70b2749778652bb0d68946eeff75240d546 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 10:44:59 +0800 Subject: [PATCH 03/20] =?UTF-8?q?fix(ssf):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BA=8B=E4=BB=B6=E8=BF=9E=E6=8E=A5=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为已记录旧迁移版本的环境补充幂等表修复迁移,并让 Transmitter 网络连接在 IPv6 地址不可达时继续尝试 IPv4。失败连接现在通过 If-Match 安全重试,页面同步提供重试入口并清理过期错误提示。\n\n验证:Go securityevents/migrate 测试、Web Vitest 与 TypeScript 类型检查通过。 --- apps/api/cmd/migrate/main_test.go | 23 +++++++++++++ .../securityevents/connection_manager.go | 17 +++++++++- .../securityevents/connection_manager_test.go | 34 +++++++++++++++++++ ...ty_event_connection_idempotency_repair.sql | 15 ++++++++ apps/web/src/App.tsx | 16 +++++++-- apps/web/src/api.test.ts | 15 ++++++++ apps/web/src/api.ts | 6 ++-- .../src/pages/admin/SystemSettingsPanel.tsx | 3 ++ 8 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 apps/api/cmd/migrate/main_test.go create mode 100644 apps/api/migrations/0064_security_event_connection_idempotency_repair.sql diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go new file mode 100644 index 0000000..8cd63c8 --- /dev/null +++ b/apps/api/cmd/migrate/main_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0064_security_event_connection_idempotency_repair.sql") + if err != nil { + t.Fatalf("read repair migration: %v", err) + } + sql := string(payload) + for _, statement := range []string{ + "CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency", + "CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created", + } { + if !strings.Contains(sql, statement) { + t.Fatalf("repair migration is missing %q", statement) + } + } +} diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index 1a02463..d0e0448 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -879,11 +879,26 @@ func safeHTTPClient(issuer *url.URL, appEnv string) *http.Client { } } dialer := &net.Dialer{Timeout: 5 * time.Second} - return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + return dialResolvedAddresses(ctx, network, port, addresses, dialer) } return &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } +func dialResolvedAddresses(ctx context.Context, network, port string, addresses []net.IPAddr, dialer *net.Dialer) (net.Conn, error) { + var attempts []error + for _, address := range addresses { + connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port)) + if err == nil { + return connection, nil + } + attempts = append(attempts, err) + if ctx.Err() != nil { + break + } + } + return nil, fmt.Errorf("transmitter connection failed for every resolved address: %w", errors.Join(attempts...)) +} + func blockedAddress(ip net.IP) bool { if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 4d40535..4fbd96a 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "sync/atomic" @@ -148,6 +149,39 @@ func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { } } +func TestDialResolvedAddressesFallsBackFromIPv6ToIPv4(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + accepted := make(chan struct{}) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr == nil { + _ = connection.Close() + close(accepted) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + connection, err := dialResolvedAddresses(ctx, "tcp", strconv.Itoa(port), []net.IPAddr{ + {IP: net.ParseIP("::1")}, + {IP: net.ParseIP("127.0.0.1")}, + }, &net.Dialer{Timeout: 250 * time.Millisecond}) + if err != nil { + t.Fatalf("IPv4 fallback was not attempted: %v", err) + } + _ = connection.Close() + select { + case <-accepted: + case <-ctx.Done(): + t.Fatal("IPv4 listener did not receive the fallback connection") + } +} + func TestRetiringConnectionStillAppliesRevocationWatermark(t *testing.T) { audience := "urn:easyai:ssf:receiver:" + uuid.NewString() repository := &memoryConnectionRepository{ diff --git a/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql b/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql new file mode 100644 index 0000000..f6a5b0c --- /dev/null +++ b/apps/api/migrations/0064_security_event_connection_idempotency_repair.sql @@ -0,0 +1,15 @@ +-- 0063 was released before its idempotency table was added. Installations that +-- already recorded 0063 need a new immutable migration to repair that drift. +CREATE TABLE IF NOT EXISTS gateway_security_event_connection_idempotency ( + operation text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (operation, idempotency_key), + CONSTRAINT gateway_security_event_connection_idempotency_operation + CHECK (operation IN ('connect','verify','rotate','disconnect')) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_connection_idempotency_created + ON gateway_security_event_connection_idempotency(created_at); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9792c91..c74a7b6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1033,7 +1033,8 @@ export function App() { setCoreState('loading'); setCoreMessage(''); try { - const connection = await connectSecurityEventTransmitter(token, transmitterIssuer); + const version = securityEventConnection?.connection?.version; + const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version); setSecurityEventConnection(connection); setCoreState('ready'); setCoreMessage('认证中心安全事件连接正在验证。'); @@ -1045,8 +1046,17 @@ export function App() { } async function refreshSecurityEvents() { - const connection = await getSecurityEventConnection(token); - setSecurityEventConnection(connection); + setCoreState('loading'); + setCoreMessage(''); + try { + const connection = await getSecurityEventConnection(token); + setSecurityEventConnection(connection); + setCoreState('ready'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '刷新安全事件连接失败'); + throw err; + } } async function verifySecurityEvents() { diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index ddaa1f1..a9c1ea6 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -55,6 +55,21 @@ describe('security event connection transport', () => { expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' }); expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); + expect(new Headers(init.headers).has('If-Match')).toBe(false); + }); + + it('sends If-Match when retrying an existing failed connection', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); }); }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index defcd37..d71a66f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1007,10 +1007,12 @@ export async function getSecurityEventConnection(token: string): Promise(securityEventConnectionPath, { token }); } -export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string): Promise { +export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise { + const headers: Record = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }; + if (version !== undefined) headers['If-Match'] = `W/"${version}"`; return request(securityEventConnectionPath, { body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token, - headers: { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }, + headers, }); } diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 0367987..37b20e2 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -474,6 +474,9 @@ function SecurityEventConnectionPanel(props: { {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}}
+ {connection.lifecycleStatus === 'error' && ( + + )} From 2ccf041b35c5b94f7a5fe8c53ae12e09df1acf11 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 10:55:21 +0800 Subject: [PATCH 04/20] =?UTF-8?q?feat(ssf):=20=E5=A2=9E=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=93=8D=E4=BD=9C=E5=AE=A1=E8=AE=A1=E4=B8=8E=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全事件连接的成功和运行时失败现在写入脱敏审计,响应携带 Trace ID 与 Audit ID,管理页面展示最近一次成功操作证据。审计仅记录连接状态、健康模式和错误类别,不采集授权头、Token、Bearer 或 Secret。\n\n验证:HTTP API、Store、Security Events Go 测试以及 Web Vitest、TypeScript 类型检查通过。 --- .../security_event_connection_handlers.go | 127 +++++++++++++++--- ...security_event_connection_handlers_test.go | 27 ++++ .../src/pages/admin/SystemSettingsPanel.tsx | 2 + packages/contracts/src/index.ts | 2 + 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index bbd179a..ce0614c 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -9,8 +9,10 @@ import ( "strconv" "strings" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" ) type securityEventConnectionRequest struct { @@ -20,9 +22,12 @@ type securityEventConnectionRequest struct { type securityEventConnectionResponse struct { Connected bool `json:"connected"` Connection ssfreceiver.ConnectionView `json:"connection"` + TraceID string `json:"traceId,omitempty"` + AuditID string `json:"auditId,omitempty"` } func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + ensureSecurityEventTraceID(w, r) w.Header().Set("Cache-Control", "no-store") if s.securityEventManager == nil { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) @@ -42,6 +47,7 @@ func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Reque } func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) { + traceID := ensureSecurityEventTraceID(w, r) if s.securityEventManager == nil { writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing") return @@ -68,49 +74,52 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque } connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify", traceID) if !ok { return } connection, err := s.securityEventManager.Verify(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "verify", traceID, err) return } - s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) } func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate", traceID) if !ok { return } connection, err := s.securityEventManager.RotateCredential(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err) return } - s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) } func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect") + traceID := ensureSecurityEventTraceID(w, r) + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect", traceID) if !ok { return } connection, err := s.securityEventManager.Disconnect(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, connection) + s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) securityEventPrerequisites() map[string]any { @@ -131,7 +140,7 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s return value, true } -func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation string) (string, string, bool) { +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, traceID string) (string, string, bool) { if s.securityEventManager == nil { writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") return "", "", false @@ -146,7 +155,7 @@ func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Requ } connection, err := s.securityEventManager.Get(r.Context()) if err != nil { - writeSecurityEventConnectionError(w, err) + s.writeSecurityEventConnectionError(w, r, operation, traceID, err) return "", "", false } return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) @@ -176,12 +185,19 @@ func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Req w.Header().Set("Cache-Control", "no-store") w.Header().Set("Idempotent-Replayed", "true") w.Header().Set("ETag", fmt.Sprintf(`W/"%d"`, payload.Connection.Version)) + if payload.AuditID != "" { + w.Header().Set("X-Audit-Id", payload.AuditID) + } writeJSON(w, http.StatusAccepted, payload) return true } -func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string, connection ssfreceiver.ConnectionView) { - payload := securityEventConnectionResponse{Connected: true, Connection: connection} +func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID string, connection ssfreceiver.ConnectionView) { + auditID := s.recordSecurityEventConnectionAudit(r, operation, "success", "", traceID, connection) + if auditID != "" { + w.Header().Set("X-Audit-Id", auditID) + } + payload := securityEventConnectionResponse{Connected: true, Connection: connection, TraceID: traceID, AuditID: auditID} encoded, _ := json.Marshal(payload) if s.store != nil { if err := s.store.RecordSecurityEventConnectionIdempotency(r.Context(), operation, idempotencyKey, requestHash, encoded); err != nil && s.logger != nil { @@ -220,15 +236,86 @@ func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int return true } -func writeSecurityEventConnectionError(w http.ResponseWriter, err error) { +func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, operation, traceID string, err error) { + status, message, code := securityEventConnectionErrorProjection(err) + connection := ssfreceiver.ConnectionView{} + if s.securityEventManager != nil { + connection, _ = s.securityEventManager.Get(r.Context()) + } + errorCategory := code + if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" { + errorCategory = *connection.LastErrorCategory + } + if auditID := s.recordSecurityEventConnectionAudit(r, operation, "failure", errorCategory, traceID, connection); auditID != "" { + w.Header().Set("X-Audit-Id", auditID) + } + writeError(w, status, message, code) +} + +func securityEventConnectionErrorProjection(err error) (int, string, string) { switch { case errors.Is(err, store.ErrSecurityEventConnectionNotFound): - writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") + return http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found" case errors.Is(err, store.ErrSecurityEventConnectionConflict): - writeError(w, http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict") + return http.StatusConflict, "security event connection conflicts with current state", "security_event_connection_conflict" case strings.Contains(err.Error(), "configured"), strings.Contains(err.Error(), "invalid"), strings.Contains(err.Error(), "HTTPS"): - writeError(w, http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing") + return http.StatusConflict, "security event connection prerequisites are incomplete", "security_event_prerequisite_missing" default: - writeError(w, http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable") + return http.StatusBadGateway, "authentication center security event service is unavailable", "security_event_transmitter_unavailable" } } + +func ensureSecurityEventTraceID(w http.ResponseWriter, r *http.Request) string { + traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")) + if !validSecurityEventDiagnosticID(traceID) { + traceID = uuid.NewString() + } + w.Header().Set("X-Trace-Id", traceID) + return traceID +} + +func validSecurityEventDiagnosticID(value string) bool { + if len(value) < 8 || len(value) > 128 { + return false + } + for _, character := range value { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("-_.", character) { + continue + } + return false + } + return true +} + +func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) string { + if s.store == nil { + return "" + } + actor, _ := auth.UserFromContext(r.Context()) + input := securityEventConnectionAuditInput(r, actor, operation, outcome, errorCategory, traceID, connection) + audit, err := s.store.RecordAuditLog(r.Context(), input) + if err != nil { + if s.logger != nil { + s.logger.WarnContext(r.Context(), "record security event connection audit failed", "operation", operation, "outcome", outcome, "error_category", "audit_store_failed", "trace_id", traceID) + } + return "" + } + return audit.ID +} + +func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput { + input := store.AuditLogInput{ + Category: "identity", Action: "identity.security_event_connection." + operation, + TargetType: "security_event_connection", TargetID: firstNonEmptyText(connection.ConnectionID, "singleton"), + RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), + AfterState: map[string]any{"lifecycleStatus": connection.LifecycleStatus, "healthMode": connection.HealthMode}, + Metadata: map[string]any{"outcome": outcome, "errorCategory": errorCategory, "traceId": traceID}, + } + if actor != nil { + input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID)) + input.ActorUserID, input.ActorUsername, input.ActorSource = actor.ID, actor.Username, actor.Source + input.ActorRoles = actor.Roles + } + return input +} diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go index f92ade8..f8b6a8c 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers_test.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -7,9 +7,36 @@ import ( "strings" "testing" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" ) +func TestSecurityEventConnectionTraceAndAuditProjection(t *testing.T) { + request := httptest.NewRequest(http.MethodPut, "/connection", nil) + request.Header.Set("Authorization", "Bearer must-not-escape") + recorder := httptest.NewRecorder() + traceID := ensureSecurityEventTraceID(recorder, request) + if traceID == "" || recorder.Header().Get("X-Trace-Id") != traceID { + t.Fatalf("trace header=%q trace=%q", recorder.Header().Get("X-Trace-Id"), traceID) + } + actor := &auth.User{ID: "admin-id", Username: "admin", Source: "local", Roles: []string{"manager"}} + connection := ssfreceiver.ConnectionView{ConnectionID: "connection-id", LifecycleStatus: "error", HealthMode: "introspection_fallback"} + input := securityEventConnectionAuditInput(request, actor, "connect", "failure", "management_token_failed", traceID, connection) + payload, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if input.Action != "identity.security_event_connection.connect" || input.TargetID != "connection-id" || input.Metadata["traceId"] != traceID { + t.Fatalf("audit input=%#v", input) + } + for _, forbidden := range []string{"must-not-escape", "authorization_header", "push_bearer", "credential_ref"} { + if strings.Contains(strings.ToLower(string(payload)), forbidden) { + t.Fatalf("audit payload exposed forbidden field %q: %s", forbidden, payload) + } + } +} + func TestSecurityEventConnectionWriteHeaders(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/connection/verify", nil) recorder := httptest.NewRecorder() diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 37b20e2..dbc947f 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -471,6 +471,8 @@ function SecurityEventConnectionPanel(props: { Stream ID: {connection.streamId ?? '正在创建'} 健康模式: {securityEventHealthLabel(connection.healthMode)} 最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'} + {props.connection?.traceId && 最近操作 Trace ID: {props.connection.traceId}} + {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d229616..8b81266 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -980,6 +980,8 @@ export interface SecurityEventConnectionPrerequisites { export interface SecurityEventConnectionResponse { connected: boolean; + traceId?: string; + auditId?: string; lifecycleStatus?: 'disconnected' | string; connection?: SecurityEventConnection; prerequisites?: SecurityEventConnectionPrerequisites; From 29f24222fda69265d40f8bda237bc17d9a6405bd Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 11:54:01 +0800 Subject: [PATCH 05/20] =?UTF-8?q?fix(ssf):=20=E4=BF=AE=E5=A4=8D=20Verifica?= =?UTF-8?q?tion=20=E7=8A=B6=E6=80=81=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为已执行旧版 0062 的环境补充不可变修复迁移,恢复 Stream 状态字段和 Verification challenge 表;同时为 timestamptz 参数增加显式类型,避免 pgx 在真实 PostgreSQL 中推断冲突。\n\n验证:pnpm test;pnpm lint;pnpm build;真实 PostgreSQL 集成测试通过。 --- apps/api/cmd/migrate/main_test.go | 18 +++++++++ apps/api/internal/store/security_events.go | 4 +- .../store/security_events_integration_test.go | 3 ++ ...curity_event_verification_state_repair.sql | 37 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/api/migrations/0065_security_event_verification_state_repair.sql diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index 8cd63c8..e55c714 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -21,3 +21,21 @@ func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) { } } } + +func TestSecurityEventVerificationStateRepairMigrationExists(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0065_security_event_verification_state_repair.sql") + if err != nil { + t.Fatalf("read verification state repair migration: %v", err) + } + sql := string(payload) + for _, statement := range []string{ + "ADD COLUMN IF NOT EXISTS stream_status", + "ADD COLUMN IF NOT EXISTS created_at", + "CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges", + "CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry", + } { + if !strings.Contains(sql, statement) { + t.Fatalf("verification state repair migration is missing %q", statement) + } + } +} diff --git a/apps/api/internal/store/security_events.go b/apps/api/internal/store/security_events.go index 5ae18ed..bff029d 100644 --- a/apps/api/internal/store/security_events.go +++ b/apps/api/internal/store/security_events.go @@ -144,7 +144,7 @@ WHERE issuer=$1 AND audience=$2`, issuer, audience, stateHash, now) } if _, err := tx.Exec(ctx, ` INSERT INTO gateway_security_event_verification_challenges(issuer,audience,state_hash,created_at,expires_at) -VALUES($1,$2,$3,$4,$4 + interval '180 seconds') +VALUES($1,$2,$3,$4::timestamptz,$4::timestamptz + interval '180 seconds') ON CONFLICT(issuer,audience,state_hash) DO NOTHING`, issuer, audience, stateHash, now); err != nil { return err } @@ -278,7 +278,7 @@ UPDATE gateway_security_event_stream_state SET mode='introspection_fallback',fallback_since=COALESCE(fallback_since,$3), consecutive_verifications=0,last_error_category='verification_stale',updated_at=now() WHERE issuer=$1 AND audience=$2 AND mode <> 'introspection_fallback' - AND COALESCE(last_verification_at,created_at) < $3 - make_interval(secs => $4::double precision)`, + AND COALESCE(last_verification_at,created_at) < $3::timestamptz - make_interval(secs => $4::double precision)`, issuer, audience, now, staleAfter.Seconds()) return err } diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go index f2d9952..402737e 100644 --- a/apps/api/internal/store/security_events_integration_test.go +++ b/apps/api/internal/store/security_events_integration_test.go @@ -51,6 +51,9 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { t.Fatal(err) } now := time.Now().UTC().Truncate(time.Second) + if err := db.AdvanceSecurityEventStreamState(ctx, issuer, audience, now, 180*time.Second); err != nil { + t.Fatalf("advance fresh stream state: %v", err) + } evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second) if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" { t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err) diff --git a/apps/api/migrations/0065_security_event_verification_state_repair.sql b/apps/api/migrations/0065_security_event_verification_state_repair.sql new file mode 100644 index 0000000..fd9afcf --- /dev/null +++ b/apps/api/migrations/0065_security_event_verification_state_repair.sql @@ -0,0 +1,37 @@ +-- 0062 was released before the verification challenge table and the complete +-- stream-state columns were added. Installations that already recorded 0062 +-- need a new immutable migration to repair that drift. +ALTER TABLE gateway_security_event_stream_state + ADD COLUMN IF NOT EXISTS stream_status text NOT NULL DEFAULT 'unknown', + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'gateway_security_event_stream_state'::regclass + AND conname = 'gateway_security_event_stream_state_status' + ) THEN + ALTER TABLE gateway_security_event_stream_state + ADD CONSTRAINT gateway_security_event_stream_state_status + CHECK (stream_status IN ('unknown','enabled','paused','disabled')); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS gateway_security_event_verification_challenges ( + issuer text NOT NULL, + audience text NOT NULL, + state_hash bytea NOT NULL, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + PRIMARY KEY (issuer, audience, state_hash), + FOREIGN KEY (issuer, audience) + REFERENCES gateway_security_event_stream_state(issuer, audience) ON DELETE CASCADE, + CONSTRAINT gateway_security_event_verification_challenge_hash + CHECK (octet_length(state_hash) = 32) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_security_event_challenges_expiry + ON gateway_security_event_verification_challenges(expires_at); From 192e924dfbb2110f168bbbd34a906c4e5adfe343 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 12:06:43 +0800 Subject: [PATCH 06/20] =?UTF-8?q?fix(ssf):=20=E6=81=A2=E5=A4=8D=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E8=BF=9E=E6=8E=A5=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连续两次有效 Verification 将 Stream 状态恢复为 push_healthy 时,同一事务同步把 degraded 连接恢复为 enabled、清除陈旧错误并推进版本,避免管理页永久误报降级。\n\n验证:真实 PostgreSQL 集成测试;pnpm test;pnpm lint;pnpm build;真实 Transmitter/Introspection 故障与恢复演练。 --- apps/api/internal/store/security_events.go | 12 ++++++++++ .../store/security_events_integration_test.go | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/api/internal/store/security_events.go b/apps/api/internal/store/security_events.go index bff029d..4ccac66 100644 --- a/apps/api/internal/store/security_events.go +++ b/apps/api/internal/store/security_events.go @@ -210,6 +210,18 @@ WHERE issuer=$1 AND audience=$2 AND stream_id=$3::uuid WHERE issuer=$1 AND audience=$2 AND state_hash=$3`, issuer, audience, stateHash); err != nil { return false, err } + if _, err := tx.Exec(ctx, ` +UPDATE gateway_security_event_connections connection +SET lifecycle_status='enabled',last_error_category=NULL,version=version+1,updated_at=now() +WHERE connection.transmitter_issuer=$1 AND connection.audience=$2 + AND connection.lifecycle_status='degraded' + AND EXISTS( + SELECT 1 FROM gateway_security_event_stream_state state + WHERE state.issuer=$1 AND state.audience=$2 + AND state.mode='push_healthy' AND state.stream_status='enabled' + )`, issuer, audience); err != nil { + return false, err + } return true, tx.Commit(ctx) } diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go index 402737e..b84ed96 100644 --- a/apps/api/internal/store/security_events_integration_test.go +++ b/apps/api/internal/store/security_events_integration_test.go @@ -42,6 +42,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { audience := "urn:easyai:ssf:receiver:" + uuid.NewString() streamID, tenantID, subject := uuid.NewString(), uuid.NewString(), uuid.NewString() t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE transmitter_issuer=$1`, issuer) _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_receipts WHERE issuer=$1`, issuer) _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_oidc_revocation_watermarks WHERE issuer=$1`, subjectIssuer) _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_stream_state WHERE issuer=$1`, issuer) @@ -132,4 +133,26 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" { t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err) } + connectionID := uuid.NewString() + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_security_event_connections( + connection_id,transmitter_issuer,endpoint_url,audience,stream_id,credential_ref, + lifecycle_status,last_error_category,idempotency_key) + VALUES($1,$2,'https://gateway.test/api/v1/security-events/ssf',$3,$4,'test-secret-ref', + 'degraded','bootstrap_verification_stale',$5)`, connectionID, issuer, audience, streamID, uuid.NewString()); err != nil { + t.Fatal(err) + } + confirmAt("recovery-verification-one", now.Add(362*time.Second)) + confirmAt("recovery-verification-two", now.Add(363*time.Second)) + var lifecycle string + var lastError *string + if err := db.pool.QueryRow(ctx, `SELECT lifecycle_status,last_error_category + FROM gateway_security_event_connections WHERE connection_id=$1`, connectionID).Scan(&lifecycle, &lastError); err != nil { + t.Fatal(err) + } + if lifecycle != "enabled" || lastError != nil { + t.Fatalf("recovered connection lifecycle=%q lastError=%v", lifecycle, lastError) + } } From 8e33d1d33e57bcdc11805c802127e6d9ae4f24d7 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Thu, 16 Jul 2026 12:30:14 +0800 Subject: [PATCH 07/20] =?UTF-8?q?fix(ssf):=20=E6=94=AF=E6=8C=81=E6=96=AD?= =?UTF-8?q?=E5=BC=80=E5=90=8E=E5=AE=89=E5=85=A8=E9=87=8D=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建 Stream 时仅重置流级 Verification 状态,保留历史收据和撤销水位;允许显式恢复失败连接,并在进程重启后按持久化时间继续 bootstrap。\n\n已通过 pnpm test、pnpm lint、pnpm build、go vet、PostgreSQL 集成测试以及本地真实断开重连和停机重试验收。 --- .../securityevents/connection_manager.go | 31 +++++++++--- .../securityevents/connection_manager_test.go | 47 +++++++++++++++++++ apps/api/internal/store/security_events.go | 37 +++++++++++---- .../store/security_events_integration_test.go | 27 +++++++++++ 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index d0e0448..451a73c 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -167,6 +167,9 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository, _, _ = repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "degraded", &category) } } + if connection.LifecycleStatus == "bootstrap" { + go manager.finishBootstrap(connection.ConnectionID) + } return manager, nil } @@ -193,7 +196,7 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey if existing.TransmitterIssuer != issuer { return ConnectionView{}, store.ErrSecurityEventConnectionConflict } - if existing.StreamID != nil { + if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) { return m.view(existing), nil } return m.resumeConnecting(ctx, existing) @@ -223,6 +226,15 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey return m.resumeConnecting(ctx, connection) } +func resumableConnectionLifecycle(status string) bool { + switch status { + case "connecting", "verifying", "degraded", "error": + return true + default: + return false + } +} + func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) { secret, err := m.secrets.Get(ctx, connection.CredentialRef) if err != nil { @@ -594,12 +606,19 @@ func (m *ConnectionManager) finishAutomaticVerification(connectionID string, con } func (m *ConnectionManager) finishBootstrap(connectionID string) { - select { - case <-m.ctx.Done(): - return - case <-time.After(m.config.BootstrapDuration): - } connection, err := m.repository.SecurityEventConnection(m.ctx) + if err != nil || connection.ConnectionID != connectionID || connection.LifecycleStatus != "bootstrap" { + return + } + delay := m.config.BootstrapDuration - time.Since(connection.UpdatedAt) + if delay > 0 { + select { + case <-m.ctx.Done(): + return + case <-time.After(delay): + } + } + connection, err = m.repository.SecurityEventConnection(m.ctx) if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" { mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience)) if metricsErr == nil && mode == "push_healthy" { diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 4fbd96a..2647b80 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -130,12 +130,59 @@ func TestConnectionManagerBootstrapOnlyBecomesEnabledWhenPushIsHealthy(t *testin } } +func TestConnectionManagerResumesBootstrapAfterRestart(t *testing.T) { + repository := &memoryConnectionRepository{mode: "push_healthy", connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + LifecycleStatus: "bootstrap", Version: 1, UpdatedAt: time.Now().Add(-time.Second), + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if _, err := NewConnectionManager(ctx, repository, &memorySecretStore{}, ConnectionManagerConfig{ + OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + BootstrapDuration: 10 * time.Millisecond, + }, &Metrics{}); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + connection, err := repository.SecurityEventConnection(ctx) + if err == nil && connection.LifecycleStatus == "enabled" { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("bootstrap lifecycle did not resume after process restart") +} + func TestRemoteNotFoundIsRecognizedForIdempotentDisconnect(t *testing.T) { if !isRemoteHTTPStatus(remoteHTTPError{status: http.StatusNotFound}, http.StatusNotFound) { t.Fatal("remote 404 was not recognized as an already-deleted Stream") } } +func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testing.T) { + for _, test := range []struct { + status string + want bool + }{ + {status: "connecting", want: true}, + {status: "verifying", want: true}, + {status: "degraded", want: true}, + {status: "error", want: true}, + {status: "bootstrap"}, + {status: "enabled"}, + {status: "rotating"}, + {status: "disconnect_pending"}, + {status: "retiring"}, + } { + t.Run(test.status, func(t *testing.T) { + if got := resumableConnectionLifecycle(test.status); got != test.want { + t.Fatalf("resumableConnectionLifecycle(%q)=%t want %t", test.status, got, test.want) + } + }) + } +} + func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { for _, address := range []string{ "127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", diff --git a/apps/api/internal/store/security_events.go b/apps/api/internal/store/security_events.go index 4ccac66..df04a03 100644 --- a/apps/api/internal/store/security_events.go +++ b/apps/api/internal/store/security_events.go @@ -108,19 +108,38 @@ WHERE session.gateway_user_id = gateway_user.id } func (s *Store) EnsureSecurityEventStreamState(ctx context.Context, issuer, audience, streamID string) error { - tag, err := s.pool.Exec(ctx, ` -INSERT INTO gateway_security_event_stream_state(issuer,audience,stream_id,mode) -VALUES($1,$2,$3::uuid,'bootstrap') -ON CONFLICT(issuer,audience) DO UPDATE -SET stream_id=EXCLUDED.stream_id,updated_at=now() -WHERE gateway_security_event_stream_state.stream_id=EXCLUDED.stream_id`, issuer, audience, streamID) + tx, err := s.pool.Begin(ctx) if err != nil { return err } - if tag.RowsAffected() == 0 { - return errors.New("security event stream id cannot be changed") + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, ` +INSERT INTO gateway_security_event_stream_state(issuer,audience,stream_id,mode) +VALUES($1,$2,$3::uuid,'bootstrap') +ON CONFLICT(issuer,audience) DO NOTHING`, issuer, audience, streamID); err != nil { + return err } - return nil + var currentStreamID string + if err := tx.QueryRow(ctx, `SELECT stream_id::text FROM gateway_security_event_stream_state + WHERE issuer=$1 AND audience=$2 FOR UPDATE`, issuer, audience).Scan(¤tStreamID); err != nil { + return err + } + if currentStreamID != streamID { + if _, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_verification_challenges + WHERE issuer=$1 AND audience=$2`, issuer, audience); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +UPDATE gateway_security_event_stream_state +SET stream_id=$3::uuid,mode='bootstrap',stream_status='unknown', + last_verification_at=NULL,bootstrap_until=NULL,consecutive_verifications=0, + pending_state_hash=NULL,pending_state_created_at=NULL,fallback_since=NULL, + last_error_category=NULL,created_at=now(),updated_at=now() +WHERE issuer=$1 AND audience=$2`, issuer, audience, streamID); err != nil { + return err + } + } + return tx.Commit(ctx) } func (s *Store) BeginSecurityEventVerification(ctx context.Context, issuer, audience string, stateHash []byte, now time.Time) error { diff --git a/apps/api/internal/store/security_events_integration_test.go b/apps/api/internal/store/security_events_integration_test.go index b84ed96..cef4e2b 100644 --- a/apps/api/internal/store/security_events_integration_test.go +++ b/apps/api/internal/store/security_events_integration_test.go @@ -155,4 +155,31 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) { if lifecycle != "enabled" || lastError != nil { t.Fatalf("recovered connection lifecycle=%q lastError=%v", lifecycle, lastError) } + replacementStreamID := uuid.NewString() + if err := db.EnsureSecurityEventStreamState(ctx, issuer, audience, replacementStreamID); err != nil { + t.Fatalf("rebind retained receiver state: %v", err) + } + var reboundStreamID, reboundMode, reboundStatus string + var reboundVerification *time.Time + if err := db.pool.QueryRow(ctx, `SELECT stream_id::text,mode,stream_status,last_verification_at + FROM gateway_security_event_stream_state WHERE issuer=$1 AND audience=$2`, issuer, audience). + Scan(&reboundStreamID, &reboundMode, &reboundStatus, &reboundVerification); err != nil { + t.Fatal(err) + } + if reboundStreamID != replacementStreamID || reboundMode != "bootstrap" || reboundStatus != "unknown" || reboundVerification != nil { + t.Fatalf("rebound state stream=%q mode=%q status=%q verification=%v", + reboundStreamID, reboundMode, reboundStatus, reboundVerification) + } + var retainedReceipts, retainedWatermarks int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_security_event_receipts + WHERE issuer=$1`, issuer).Scan(&retainedReceipts); err != nil { + t.Fatal(err) + } + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_oidc_revocation_watermarks + WHERE issuer=$1 AND tenant_id=$2 AND subject=$3`, subjectIssuer, tenantID, subject).Scan(&retainedWatermarks); err != nil { + t.Fatal(err) + } + if retainedReceipts == 0 || retainedWatermarks != 1 { + t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks) + } } From ffb85b73af3a860e2632327ea2fdcf481f2dd855 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 09:13:57 +0800 Subject: [PATCH 08/20] =?UTF-8?q?feat(ssf):=20=E6=89=98=E7=AE=A1=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E5=87=AD=E6=8D=AE=E5=B9=B6=E6=94=AF=E6=8C=81=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway 连接表单接收认证中心一次性交付的 machine Client 凭据,后端立即写入 SecretStore,数据库和公开响应只保留引用及公开 Client ID。SSF 管理 Token、Verification 和 RFC 7662 内省统一从动态凭据读取,支持现有连接无重启修复及进程重启恢复,环境变量仅保留为旧部署回退。\n\n验证:go test ./apps/api/...;pnpm nx test web;真实重启、OIDC 登录与 session-revoked 1.29 秒失效验收。 --- .env.example | 2 + apps/api/internal/auth/oidc.go | 44 ++++-- apps/api/internal/auth/oidc_test.go | 6 +- .../security_event_connection_handlers.go | 17 ++- apps/api/internal/httpapi/server.go | 15 +- .../securityevents/connection_manager.go | 133 ++++++++++++++++-- .../securityevents/connection_manager_test.go | 42 +++++- .../store/security_event_connections.go | 64 ++++++--- ...6_security_event_management_credential.sql | 9 ++ apps/web/src/App.tsx | 4 +- apps/web/src/api.test.ts | 13 +- apps/web/src/api.ts | 14 +- .../src/pages/admin/SystemSettingsPanel.tsx | 42 ++++-- docs/security/ssf-session-revocation.md | 11 +- packages/contracts/src/index.ts | 1 + 15 files changed, 327 insertions(+), 90 deletions(-) create mode 100644 apps/api/migrations/0066_security_event_management_credential.sql diff --git a/.env.example b/.env.example index 5fe82c3..b2591c7 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,8 @@ OIDC_REQUIRED_SCOPES=gateway.access OIDC_JWKS_CACHE_TTL_SECONDS=300 OIDC_ACCEPT_LEGACY_HS256=true OIDC_INTROSPECTION_ENABLED=false +# Legacy/static fallback only. New SSF connections receive the machine +# credential once in the web flow and persist it in the configured SecretStore. OIDC_INTROSPECTION_CLIENT_ID= OIDC_INTROSPECTION_CLIENT_SECRET= # SSF/CAEP is activated by the durable connection created in System Settings; diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 39690d4..9d3b02f 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -25,19 +25,20 @@ const maxOIDCResponseBytes = 1 << 20 const defaultOIDCHTTPTimeout = 10 * time.Second type OIDCConfig struct { - Issuer string - Audience string - TenantID string - RolePrefix string - RequiredScopes []string - JWKSCacheTTL time.Duration - IntrospectionEnabled bool - IntrospectionClientID string - IntrospectionClientSecret string - HTTPClient *http.Client - SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) - IntrospectionObserver func(string) - JWKSRefreshFailureObserver func() + Issuer string + Audience string + TenantID string + RolePrefix string + RequiredScopes []string + JWKSCacheTTL time.Duration + IntrospectionEnabled bool + IntrospectionClientID string + IntrospectionClientSecret string + IntrospectionCredentialProvider func(context.Context) (string, []byte, error) + HTTPClient *http.Client + SecurityEventEvaluator func(context.Context, OIDCSecurityEventIdentity) (OIDCSecurityEventEvaluation, error) + IntrospectionObserver func(string) + JWKSRefreshFailureObserver func() } type OIDCSecurityEventIdentity struct { @@ -92,7 +93,8 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { return nil, errors.New("issuer, audience, tenant and role prefix are required") } - if config.IntrospectionEnabled && (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { + if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil && + (strings.TrimSpace(config.IntrospectionClientID) == "" || config.IntrospectionClientSecret == "") { return nil, errors.New("introspection client credentials are required when introspection is enabled") } if config.JWKSCacheTTL <= 0 { @@ -295,7 +297,19 @@ func (v *OIDCVerifier) introspect(ctx context.Context, raw string) (bool, error) } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Accept", "application/json") - request.SetBasicAuth(v.config.IntrospectionClientID, v.config.IntrospectionClientSecret) + clientID := v.config.IntrospectionClientID + secret := []byte(v.config.IntrospectionClientSecret) + if v.config.IntrospectionCredentialProvider != nil { + clientID, secret, err = v.config.IntrospectionCredentialProvider(ctx) + if err != nil { + return false, err + } + } + defer clear(secret) + if strings.TrimSpace(clientID) == "" || len(secret) < 16 { + return false, errors.New("OIDC introspection credential is unavailable") + } + request.SetBasicAuth(clientID, string(secret)) response, err := v.client.Do(request) if err != nil { return false, err diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 36ed7fd..da6952d 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -138,8 +138,10 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing verifier, err := NewOIDCVerifier(OIDCConfig{ Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), - IntrospectionEnabled: true, IntrospectionClientID: "gateway-api", - IntrospectionClientSecret: "introspection-secret", + IntrospectionEnabled: true, + IntrospectionCredentialProvider: func(context.Context) (string, []byte, error) { + return "gateway-api", []byte("introspection-secret"), nil + }, }) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index ce0614c..4e75115 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -16,7 +16,9 @@ import ( ) type securityEventConnectionRequest struct { - TransmitterIssuer string `json:"transmitter_issuer"` + TransmitterIssuer string `json:"transmitter_issuer"` + ManagementClientID string `json:"management_client_id"` + ManagementClientSecret string `json:"management_client_secret"` } type securityEventConnectionResponse struct { @@ -65,14 +67,22 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque return } request.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(request.TransmitterIssuer), "/") - requestHash := securityEventOperationHash("connect", request.TransmitterIssuer) + request.ManagementClientID = strings.TrimSpace(request.ManagementClientID) + if (request.ManagementClientID == "") != (request.ManagementClientSecret == "") || len(request.ManagementClientID) > 200 || len(request.ManagementClientSecret) > 512 { + writeError(w, http.StatusBadRequest, "machine Client ID and Secret must be provided together", "invalid_machine_credential") + return + } + secret := []byte(request.ManagementClientSecret) + defer clear(secret) + secretDigest := sha256.Sum256(secret) + requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:])) if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) { return } if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { return } - connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, idempotencyKey) + connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) if err != nil { s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) return @@ -128,6 +138,7 @@ func (s *Server) securityEventPrerequisites() map[string]any { "introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "", "publicBaseUrlConfigured": s.cfg.PublicBaseURL != "", "managementClientId": s.cfg.OIDCIntrospectionClientID, + "credentialInputSupported": true, } } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 1b03891..26e37df 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -94,13 +94,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, - IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, - IntrospectionClientID: cfg.OIDCIntrospectionClientID, - IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, - SecurityEventEvaluator: evaluator, - IntrospectionObserver: securityEventMetrics.ObserveIntrospection, - JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, + JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, + IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, + IntrospectionClientID: cfg.OIDCIntrospectionClientID, + IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, + IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential, + SecurityEventEvaluator: evaluator, + IntrospectionObserver: securityEventMetrics.ObserveIntrospection, + JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, }) if err != nil { panic("invalid OIDC configuration: " + err.Error()) diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index 451a73c..6d572d2 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -34,6 +34,7 @@ type ConnectionRepository interface { CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error) + SetSecurityEventManagementCredential(context.Context, string, string, string) (store.SecurityEventConnection, error) UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error) SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) @@ -181,51 +182,114 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) { return m.view(connection), nil } -func (m *ConnectionManager) Connect(ctx context.Context, issuer, idempotencyKey string) (ConnectionView, error) { +func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" { + return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil + } + return "", nil, errors.New("managed machine credential is unavailable") + } + if err != nil { + return "", nil, err + } + return m.managementCredential(ctx, connection) +} + +func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClientID string, managementSecret []byte, idempotencyKey string) (ConnectionView, error) { m.operation.Lock() defer m.operation.Unlock() issuer = strings.TrimRight(strings.TrimSpace(issuer), "/") + managementClientID = strings.TrimSpace(managementClientID) if idempotencyKey == "" { return ConnectionView{}, errors.New("idempotency key is required") } - if err := m.validatePrerequisites(issuer); err != nil { + existing, err := m.repository.SecurityEventConnection(ctx) + if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return ConnectionView{}, err + } + credentialsProvided := managementClientID != "" || len(managementSecret) > 0 + if (managementClientID == "") != (len(managementSecret) == 0) { + return ConnectionView{}, errors.New("machine client id and secret must be provided together") + } + if !credentialsProvided { + if err == nil { + managementClientID, managementSecret, _ = m.managementCredential(ctx, existing) + } else { + managementClientID = strings.TrimSpace(m.config.ManagementClientID) + managementSecret = []byte(m.config.ManagementClientSecret) + } + } + defer clear(managementSecret) + if err := m.validatePrerequisites(issuer, managementClientID, managementSecret, errors.Is(err, store.ErrSecurityEventConnectionNotFound)); err != nil { return ConnectionView{}, err } - existing, err := m.repository.SecurityEventConnection(ctx) if err == nil { if existing.TransmitterIssuer != issuer { return ConnectionView{}, store.ErrSecurityEventConnectionConflict } + if credentialsProvided { + existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret) + if err != nil { + return ConnectionView{}, err + } + } if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) { + if existing.Audience != nil { + if err := m.activate(ctx, existing); err != nil { + return ConnectionView{}, err + } + } return m.view(existing), nil } return m.resumeConnecting(ctx, existing) } - if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { - return ConnectionView{}, err - } connectionID := uuid.NewString() reference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil { + return ConnectionView{}, err + } secret, err := randomPushBearer() if err != nil { + _ = m.secrets.Delete(ctx, managementReference) return ConnectionView{}, err } defer clear(secret) if err := m.secrets.Put(ctx, reference, secret); err != nil { + _ = m.secrets.Delete(ctx, managementReference) return ConnectionView{}, err } endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf" connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{ ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint, - CredentialRef: reference, IdempotencyKey: idempotencyKey, + CredentialRef: reference, ManagementClientID: managementClientID, + ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey, }) if err != nil { _ = m.secrets.Delete(ctx, reference) + _ = m.secrets.Delete(ctx, managementReference) return ConnectionView{}, err } return m.resumeConnecting(ctx, connection) } +func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) { + reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString() + if err := m.secrets.Put(ctx, reference, secret); err != nil { + return store.SecurityEventConnection{}, err + } + updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference) + if err != nil { + _ = m.secrets.Delete(ctx, reference) + return store.SecurityEventConnection{}, err + } + if connection.ManagementCredentialRef != nil { + _ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef) + } + return updated, nil +} + func resumableConnectionLifecycle(status string) bool { switch status { case "connecting", "verifying", "degraded", "error": @@ -484,6 +548,11 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur return err } defer clear(current) + managementClientID, managementSecret, err := m.managementCredential(ctx, connection) + if err != nil { + return err + } + defer clear(managementSecret) var next []byte if connection.NextCredentialRef != nil { next, err = m.secrets.Get(ctx, *connection.NextCredentialRef) @@ -517,7 +586,7 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur service, err := NewService(m.repository, ServiceConfig{ Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID, ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token", - ManagementClientID: m.config.ManagementClientID, ManagementSecret: m.config.ManagementClientSecret, + ManagementClientID: managementClientID, ManagementSecret: string(managementSecret), HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter, HTTPClient: safeClient, }) @@ -656,13 +725,16 @@ func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConne } m.stopRuntime() _ = m.secrets.Delete(m.ctx, connection.CredentialRef) + if connection.ManagementCredentialRef != nil { + _ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef) + } if connection.NextCredentialRef != nil { _ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef) } } -func (m *ConnectionManager) validatePrerequisites(issuer string) error { - if !m.config.OIDCEnabled || m.config.ManagementClientID == "" || m.config.ManagementClientSecret == "" { +func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error { + if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 { return errors.New("OIDC and RFC 7662 machine client must be configured") } if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil { @@ -671,6 +743,9 @@ func (m *ConnectionManager) validatePrerequisites(issuer string) error { if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil { return err } + if !requirePublicBaseURL { + return nil + } endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf") if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid") @@ -710,12 +785,21 @@ func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transm } func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) { + connection, err := m.repository.SecurityEventConnection(ctx) + if err != nil { + return "", err + } + managementClientID, managementSecret, err := m.managementCredential(ctx, connection) + if err != nil { + return "", err + } + defer clear(managementSecret) form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}} request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode())) if err != nil { return "", err } - request.SetBasicAuth(m.config.ManagementClientID, m.config.ManagementClientSecret) + request.SetBasicAuth(managementClientID, string(managementSecret)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := client.Do(request) if err != nil { @@ -736,6 +820,24 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl return payload.AccessToken, nil } +func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) { + if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" { + secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef) + if err != nil { + return "", nil, err + } + if len(secret) < 16 { + clear(secret) + return "", nil, errors.New("managed machine credential is invalid") + } + return connection.ManagementClientID, secret, nil + } + if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" { + return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil + } + return "", nil, errors.New("managed machine credential is unavailable") +} + func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) { var streams []streamConfiguration if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil { @@ -789,11 +891,18 @@ func (m *ConnectionManager) view(connection store.SecurityEventConnection) Conne ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer, ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID, LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode, - ManagementClientID: m.config.ManagementClientID, LastVerificationAt: connection.LastVerificationAt, + ManagementClientID: managementClientIDForView(connection.ManagementClientID, m.config.ManagementClientID), LastVerificationAt: connection.LastVerificationAt, LastErrorCategory: connection.LastErrorCategory, Version: connection.Version, } } +func managementClientIDForView(managed, fallback string) string { + if strings.TrimSpace(managed) != "" { + return managed + } + return fallback +} + func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error { if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" || stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint || diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 2647b80..6b1fbef 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -66,11 +66,21 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con } now := time.Now().UTC() value := store.SecurityEventConnection{ConnectionID: input.ConnectionID, TransmitterIssuer: input.TransmitterIssuer, - EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, LifecycleStatus: "connecting", Version: 1, + EndpointURL: input.EndpointURL, CredentialRef: input.CredentialRef, ManagementClientID: input.ManagementClientID, + ManagementCredentialRef: &input.ManagementCredentialRef, LifecycleStatus: "connecting", Version: 1, IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"} r.connection = &value return value, nil } +func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference string) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil || r.connection.ConnectionID != id { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound + } + r.connection.ManagementClientID, r.connection.ManagementCredentialRef = clientID, &reference + return *r.connection, nil +} func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) { r.mutex.Lock() defer r.mutex.Unlock() @@ -183,6 +193,20 @@ func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testin } } +func TestExistingConnectionCredentialRepairDoesNotRequirePublicBaseURL(t *testing.T) { + manager := &ConnectionManager{config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCTenantID: uuid.NewString(), + }} + secret := []byte("machine-secret-for-existing-connection") + defer clear(secret) + if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, false); err != nil { + t.Fatalf("existing connection credential repair unexpectedly required a public base URL: %v", err) + } + if err := manager.validatePrerequisites("https://auth.example/ssf", "gateway-machine", secret, true); err == nil { + t.Fatal("a new connection must still require AI_GATEWAY_PUBLIC_BASE_URL") + } +} + func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { for _, address := range []string{ "127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", @@ -296,7 +320,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi }) case "/oidc/token": clientID, secret, ok := r.BasicAuth() - if !ok || clientID != "gateway-machine" || secret != "machine-secret" { + if !ok || clientID != "gateway-machine" || secret != "machine-secret-for-test" { t.Fatal("RFC 7662 machine client was not reused") } _ = r.ParseForm() @@ -339,7 +363,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi defer transmitter.Close() manager, err := NewConnectionManager(ctx, repository, secrets, ConnectionManagerConfig{ AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(), - ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret", + ManagementClientID: "gateway-machine", ManagementClientSecret: "machine-secret-for-test", PublicBaseURL: transmitter.URL, HeartbeatInterval: time.Hour, StaleAfter: 2 * time.Hour, HTTPClient: transmitter.Client(), }, &Metrics{}) if err != nil { @@ -349,7 +373,7 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi if err != nil || evaluation.Enabled { t.Fatalf("unconfigured manager evaluation=%#v err=%v", evaluation, err) } - view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "connect-once") + view, err := manager.Connect(ctx, transmitter.URL+"/ssf", "", nil, "connect-once") if err != nil { t.Fatal(err) } @@ -357,12 +381,18 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi t.Fatalf("connection view=%#v", view) } encoded, _ := json.Marshal(view) - if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret") || strings.Contains(string(encoded), "ssf-push-") { + if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret-for-test") || strings.Contains(string(encoded), "ssf-push-") { t.Fatalf("secret metadata escaped in response: %s", encoded) } - if len(secrets.values) != 1 { + if len(secrets.values) != 2 { t.Fatalf("secret count=%d", len(secrets.values)) } + restarted := &ConnectionManager{repository: repository, secrets: secrets, config: ConnectionManagerConfig{}} + restartedClientID, restartedSecret, err := restarted.IntrospectionCredential(ctx) + if err != nil || restartedClientID != "gateway-machine" || string(restartedSecret) != "machine-secret-for-test" { + t.Fatalf("persisted management credential was not recoverable after restart: client=%q secret_present=%v err=%v", restartedClientID, len(restartedSecret) > 0, err) + } + clear(restartedSecret) if !managementScopeRequested.Load() { t.Fatal("management scopes were not requested automatically") } diff --git a/apps/api/internal/store/security_event_connections.go b/apps/api/internal/store/security_event_connections.go index fb0e198..e53851e 100644 --- a/apps/api/internal/store/security_event_connections.go +++ b/apps/api/internal/store/security_event_connections.go @@ -16,25 +16,27 @@ var ( ) type SecurityEventConnection struct { - ConnectionID string `json:"connectionId"` - TransmitterIssuer string `json:"transmitterIssuer"` - EndpointURL string `json:"endpointUrl"` - Audience *string `json:"audience,omitempty"` - StreamID *string `json:"streamId,omitempty"` - CredentialRef string `json:"-"` - NextCredentialRef *string `json:"-"` - LifecycleStatus string `json:"lifecycleStatus"` - Version int64 `json:"version"` - LastErrorCategory *string `json:"lastErrorCategory,omitempty"` - IdempotencyKey string `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"` - HealthMode string `json:"healthMode"` + ConnectionID string `json:"connectionId"` + TransmitterIssuer string `json:"transmitterIssuer"` + EndpointURL string `json:"endpointUrl"` + Audience *string `json:"audience,omitempty"` + StreamID *string `json:"streamId,omitempty"` + CredentialRef string `json:"-"` + NextCredentialRef *string `json:"-"` + ManagementClientID string `json:"managementClientId"` + ManagementCredentialRef *string `json:"-"` + LifecycleStatus string `json:"lifecycleStatus"` + Version int64 `json:"version"` + LastErrorCategory *string `json:"lastErrorCategory,omitempty"` + IdempotencyKey string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"` + HealthMode string `json:"healthMode"` } type CreateSecurityEventConnectionInput struct { - ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, IdempotencyKey string + ConnectionID, TransmitterIssuer, EndpointURL, CredentialRef, ManagementClientID, ManagementCredentialRef, IdempotencyKey string } type SecurityEventConnectionIdempotency struct { @@ -65,13 +67,14 @@ func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateS var value SecurityEventConnection err := s.pool.QueryRow(ctx, ` INSERT INTO gateway_security_event_connections( - connection_id,transmitter_issuer,endpoint_url,credential_ref,lifecycle_status,idempotency_key -) VALUES($1::uuid,$2,$3,$4,'connecting',$5) + connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key +) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7) RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref, - next_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`, - input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.IdempotencyKey, + next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`, + input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, + input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey, ).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, - &value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version, + &value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version, &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt) if err != nil { if isUniqueViolation(err) { @@ -90,7 +93,8 @@ func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConne var value SecurityEventConnection err := s.pool.QueryRow(ctx, ` SELECT connection.connection_id::text,connection.transmitter_issuer,connection.endpoint_url,connection.audience, - connection.stream_id::text,connection.credential_ref,connection.next_credential_ref,connection.lifecycle_status, + connection.stream_id::text,connection.credential_ref,connection.next_credential_ref, + COALESCE(connection.management_client_id,''),connection.management_credential_ref,connection.lifecycle_status, connection.version,connection.last_error_category,connection.idempotency_key,connection.created_at,connection.updated_at, state.last_verification_at,COALESCE(state.mode,'disabled') FROM gateway_security_event_connections connection @@ -98,7 +102,8 @@ LEFT JOIN gateway_security_event_stream_state state ON state.issuer=connection.transmitter_issuer AND state.audience=connection.audience WHERE connection.singleton=true`).Scan( &value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, - &value.CredentialRef, &value.NextCredentialRef, &value.LifecycleStatus, &value.Version, + &value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, + &value.LifecycleStatus, &value.Version, &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt, &value.LastVerificationAt, &value.HealthMode, ) @@ -121,6 +126,19 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, return s.SecurityEventConnection(ctx) } +func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now() +WHERE connection_id=$1::uuid`, connectionID, clientID, reference) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + } + return s.SecurityEventConnection(ctx) +} + func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) { tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory) diff --git a/apps/api/migrations/0066_security_event_management_credential.sql b/apps/api/migrations/0066_security_event_management_credential.sql new file mode 100644 index 0000000..d6df346 --- /dev/null +++ b/apps/api/migrations/0066_security_event_management_credential.sql @@ -0,0 +1,9 @@ +ALTER TABLE gateway_security_event_connections + ADD COLUMN IF NOT EXISTS management_client_id text, + ADD COLUMN IF NOT EXISTS management_credential_ref text; + +ALTER TABLE gateway_security_event_connections + ADD CONSTRAINT gateway_security_event_management_credential_pair CHECK ( + (management_client_id IS NULL AND management_credential_ref IS NULL) OR + (management_client_id IS NOT NULL AND management_credential_ref IS NOT NULL) + ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c74a7b6..7a4ecff 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1029,12 +1029,12 @@ export function App() { } } - async function connectSecurityEvents(transmitterIssuer: string) { + async function connectSecurityEvents(transmitterIssuer: string, managementClientId = '', managementClientSecret = '') { setCoreState('loading'); setCoreMessage(''); try { const version = securityEventConnection?.connection?.version; - const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, version); + const connection = await connectSecurityEventTransmitter(token, transmitterIssuer, managementClientId, managementClientSecret, version); setSecurityEventConnection(connection); setCoreState('ready'); setCoreMessage('认证中心安全事件连接正在验证。'); diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index a9c1ea6..d0cbc8e 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -40,7 +40,7 @@ describe('security event connection transport', () => { vi.unstubAllGlobals(); }); - it('sends only the public transmitter issuer and concurrency headers', async () => { + it('sends the one-time machine credential only in the connection request', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ connected: true }), { status: 202, headers: { 'Content-Type': 'application/json' }, @@ -48,12 +48,15 @@ describe('security event connection transport', () => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); - await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf'); + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret'); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(init.method).toBe('PUT'); - expect(JSON.parse(String(init.body))).toEqual({ transmitter_issuer: 'https://auth.example/ssf' }); - expect(String(init.body)).not.toMatch(/secret|bearer|scope|credential/i); + expect(JSON.parse(String(init.body))).toEqual({ + transmitter_issuer: 'https://auth.example/ssf', + management_client_id: 'gateway-machine', + management_client_secret: 'one-time-machine-secret', + }); expect(new Headers(init.headers).get('Idempotency-Key')).toContain('ssf-connect-'); expect(new Headers(init.headers).has('If-Match')).toBe(false); }); @@ -66,7 +69,7 @@ describe('security event connection transport', () => { vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); - await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 7); + await connectSecurityEventTransmitter('manager-token', 'https://auth.example/ssf', 'gateway-machine', 'one-time-machine-secret', 7); const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index d71a66f..9232fe4 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1007,11 +1007,21 @@ export async function getSecurityEventConnection(token: string): Promise(securityEventConnectionPath, { token }); } -export async function connectSecurityEventTransmitter(token: string, transmitterIssuer: string, version?: number): Promise { +export async function connectSecurityEventTransmitter( + token: string, + transmitterIssuer: string, + managementClientId: string, + managementClientSecret: string, + version?: number, +): Promise { const headers: Record = { 'Idempotency-Key': `ssf-connect-${crypto.randomUUID()}` }; if (version !== undefined) headers['If-Match'] = `W/"${version}"`; return request(securityEventConnectionPath, { - body: { transmitter_issuer: transmitterIssuer }, method: 'PUT', token, + body: { + transmitter_issuer: transmitterIssuer, + management_client_id: managementClientId, + management_client_secret: managementClientSecret, + }, method: 'PUT', token, headers, }); } diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index dbc947f..94dccb6 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -64,7 +64,7 @@ export function SystemSettingsPanel(props: { onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; - onConnectSecurityEvents: (transmitterIssuer: string) => Promise; + onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise; onDisconnectSecurityEvents: () => Promise; onRefreshSecurityEvents: () => Promise; onRotateSecurityEventsCredential: () => Promise; @@ -411,7 +411,7 @@ function SecurityEventConnectionPanel(props: { issuer: string; loading: boolean; onIssuerChange(value: string): void; - onConnect(issuer: string): Promise; + onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise; onDisconnect(): void; onRefresh(): Promise; onRotate(): Promise; @@ -419,9 +419,13 @@ function SecurityEventConnectionPanel(props: { }) { const connection = props.connection?.connection; const prerequisites = props.connection?.prerequisites; + const [managementClientId, setManagementClientId] = useState(''); + const [managementClientSecret, setManagementClientSecret] = useState(''); + useEffect(() => { + if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId); + }, [managementClientId, prerequisites?.managementClientId]); const missing = [ !prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '', - !prerequisites?.introspectionClientConfigured ? '配置 RFC 7662 机器 Client ID / Secret' : '', !prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '', ].filter(Boolean); return ( @@ -446,15 +450,22 @@ function SecurityEventConnectionPanel(props: {

请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。

{missing.length > 0 &&
{missing.join(';')}
} -
- 复用机器 Client: {prerequisites?.managementClientId || '未配置'} -
- @@ -475,6 +486,21 @@ function SecurityEventConnectionPanel(props: { {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}} + {(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') &&
+
认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。
+ + + +
}
{connection.lifecycleStatus === 'error' && ( diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index aee2578..7133aa3 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -7,17 +7,18 @@ Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Securit 用户只执行两项操作: 1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。 -2. 在 Gateway“系统设置 → 认证中心安全事件”中填写认证中心公开 SSF Issuer,点击“连接认证中心”。 +2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。 +3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。 -Gateway 后端随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器看不到 Push Bearer、OAuth Secret 或 Secret 引用,管理员不编辑 Secret 文件,也不需要重启 Gateway。 +Gateway 后端先把 machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine Secret;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 -前置配置只有既有 OIDC/RFC 7662 配置和 Gateway 公共地址: +环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址: - `OIDC_ISSUER`、`OIDC_TENANT_ID`; -- `OIDC_INTROSPECTION_ENABLED=true`; -- `OIDC_INTROSPECTION_CLIENT_ID/SECRET`,与认证中心准备 Receiver 时选择的机器 Client 相同; - `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。 +`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。 + 第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。 ## SecretStore diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8b81266..00faee8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -976,6 +976,7 @@ export interface SecurityEventConnectionPrerequisites { introspectionClientConfigured: boolean; publicBaseUrlConfigured: boolean; managementClientId: string; + credentialInputSupported: boolean; } export interface SecurityEventConnectionResponse { From 2a73e181233c25904494ba09133b782521feedf0 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 11:05:48 +0800 Subject: [PATCH 09/20] =?UTF-8?q?docs(identity):=20=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录 Revision 状态机、验证后热切换、Break-glass 与 Secret 边界,明确网页/API 是身份业务配置唯一来源。\n\n验证:git diff --check。 --- ...standard-identity-runtime-configuration.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/standard-identity-runtime-configuration.md diff --git a/docs/standard-identity-runtime-configuration.md b/docs/standard-identity-runtime-configuration.md new file mode 100644 index 0000000..45865d2 --- /dev/null +++ b/docs/standard-identity-runtime-configuration.md @@ -0,0 +1,52 @@ +# 统一认证运行时配置与标准应用接入 + +## 状态 + +Accepted,2026-07-17。 + +## 决策 + +Gateway 是标准应用接入协议的首个消费方。认证中心保持消费方无关;“统一认证”页面、Gateway 本地租户映射、Legacy JWT 兼容和 Session 策略均属于 Gateway 自己的管理边界。 + +OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/数据库为唯一来源。数据库、SecretStore、Session 加密根、Bootstrap 管理凭据、TLS 与 RBAC 仍由部署环境提供。开发阶段不读取或迁移旧 `OIDC_*`、`VITE_OIDC_*` 业务变量,升级后由管理员重新配对。 + +## Revision 状态机 + +```text +draft -> validated -> active -> superseded +draft | validated -> failed +superseded -> validated -> active +active -> superseded (回滚、替换或禁用) +``` + +同一时刻最多一个 Active Revision。Revision 保存非敏感配置与 Secret 引用,不保存机器 Secret 或 Session 加密根。关键身份字段变化时清理旧 BFF Session;回滚后用户重新登录。 + +## 激活顺序 + +1. 从 Draft 和 SecretStore 构建不可变候选 Runtime。 +2. 验证 Discovery、Issuer、JWKS、Audience、Tenant、Client、本地租户、Introspection 和可选 SSF Discovery。 +3. 确认存在可用的本地 Break-glass Manager。 +4. 在数据库事务中把 Revision 原子设为 Active,并把旧版本设为 Superseded。 +5. 原子替换进程内 Runtime 引用;在途请求继续使用旧实例。 +6. 若内存替换失败,恢复数据库指针并保持旧 Runtime;不得部分启用。 + +验证失败不会修改当前 Active Runtime;首次配置失败时统一认证保持关闭,本地管理登录继续可用。 + +## 配对与 Secret 边界 + +管理员只填写认证中心地址、一次性接入码、Gateway API/Web 公网地址、本地租户映射和 Legacy JWT 开关。Gateway 后端领取接入码、提交精确 URI、轮询资源状态、获取 Manifest 与一次性机器凭据,并先写入 SecretStore 再确认 Exchange 完成。 + +接入码、Exchange Token、机器 Secret 不进入 URL、数据库、日志、审计、指标或浏览器持久存储。SecretStore 写入失败时请求认证中心轮换,旧 Secret 立即失效。 + +## 威胁模型摘要 + +- 伪造/越权:所有管理写操作要求 Manager、`Idempotency-Key`、`If-Match` 和审计;Exchange Token 仅授权一个 Exchange。 +- 篡改:Manifest 使用严格 schema_version 和字段白名单;Issuer 与 Discovery issuer 必须精确相等。 +- 信息泄露:公开运行时接口只返回启用状态、登录/退出入口和脱敏健康状态。 +- SSRF:认证中心地址必须通过协议与地址策略校验,禁止 userinfo、重定向和非开发 HTTP;Discovery/Token/Introspection 请求使用超时与响应大小上限。 +- 锁死:激活、禁用与回滚前确认本地 Break-glass Manager 可用。 +- 降级:OIDC 校验 fail closed;Introspection/SSF 不可用时遵循已有降级窗口,不绕过身份校验。 + +## 前端运行时配置 + +Web 前端启动后读取公开只读统一认证状态,不使用 `VITE_OIDC_*` 构建变量。安全事件作为统一认证能力状态显示,原独立页面只保留运行运维动作。 From c2ce42fead682934e47b47b89d134ae2a529e707 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 11:52:01 +0800 Subject: [PATCH 10/20] =?UTF-8?q?feat(identity):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E9=85=8D=E7=BD=AE=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增统一认证 Revision 状态机、单 Active 数据库约束、SecretStore 引用字段、Break-glass 与本地租户门禁,并在关键身份变化或禁用时清理旧 BFF Session。\n\n同时实现标准应用接入 Manifest v1 消费端,接入码只进入请求 Body,Exchange Token 只进入 Authorization Header,禁用重定向并限制响应大小。\n\n验证:go test ./...;go vet ./... --- apps/api/cmd/migrate/main_test.go | 24 ++ apps/api/internal/identity/configuration.go | 222 +++++++++++++ .../internal/identity/configuration_test.go | 89 ++++++ .../internal/identity/onboarding_client.go | 300 ++++++++++++++++++ .../identity/onboarding_client_test.go | 97 ++++++ .../internal/store/identity_configurations.go | 240 ++++++++++++++ .../0067_identity_configuration_revisions.sql | 53 ++++ 7 files changed, 1025 insertions(+) create mode 100644 apps/api/internal/identity/configuration.go create mode 100644 apps/api/internal/identity/configuration_test.go create mode 100644 apps/api/internal/identity/onboarding_client.go create mode 100644 apps/api/internal/identity/onboarding_client_test.go create mode 100644 apps/api/internal/store/identity_configurations.go create mode 100644 apps/api/migrations/0067_identity_configuration_revisions.sql diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index e55c714..4c17357 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -39,3 +39,27 @@ func TestSecurityEventVerificationStateRepairMigrationExists(t *testing.T) { } } } + +func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0067_identity_configuration_revisions.sql") + if err != nil { + t.Fatal(err) + } + content := string(payload) + for _, required := range []string{ + "CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions", + "machine_credential_ref text", + "session_encryption_key_ref text", + "WHERE state = 'active'", + "CHECK (state IN ('draft','validated','active','superseded','failed'))", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity configuration migration is missing %q", required) + } + } + for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} { + if strings.Contains(strings.ToLower(content), forbidden) { + t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden) + } + } +} diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go new file mode 100644 index 0000000..2faf81c --- /dev/null +++ b/apps/api/internal/identity/configuration.go @@ -0,0 +1,222 @@ +package identity + +import ( + "errors" + "net" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrRevisionNotFound = errors.New("identity configuration revision not found") + ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state") + ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required") + ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid") +) + +type RevisionState string + +const ( + RevisionDraft RevisionState = "draft" + RevisionValidated RevisionState = "validated" + RevisionActive RevisionState = "active" + RevisionSuperseded RevisionState = "superseded" + RevisionFailed RevisionState = "failed" +) + +type Revision struct { + ID string `json:"id"` + State RevisionState `json:"state"` + SchemaVersion int `json:"schemaVersion"` + AuthCenterURL string `json:"authCenterUrl"` + Issuer string `json:"issuer,omitempty"` + TenantID string `json:"tenantId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + Audience string `json:"audience,omitempty"` + BrowserClientID string `json:"browserClientId,omitempty"` + MachineClientID string `json:"machineClientId,omitempty"` + Scopes []string `json:"scopes"` + Capabilities []string `json:"capabilities"` + RolePrefix string `json:"rolePrefix"` + LocalTenantKey string `json:"localTenantKey"` + PublicBaseURL string `json:"publicBaseUrl"` + WebBaseURL string `json:"webBaseUrl"` + JITEnabled bool `json:"jitEnabled"` + LegacyJWTEnabled bool `json:"legacyJwtEnabled"` + TokenIntrospection bool `json:"tokenIntrospection"` + SessionRevocation bool `json:"sessionRevocation"` + MachineCredentialRef string `json:"-"` + SessionEncryptionKeyRef string `json:"-"` + SessionIdleSeconds int `json:"sessionIdleSeconds"` + SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"` + SessionRefreshSeconds int `json:"sessionRefreshSeconds"` + Version int64 `json:"version"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + LastTraceID string `json:"lastTraceId,omitempty"` + LastAuditID string `json:"lastAuditId,omitempty"` + ValidatedAt *time.Time `json:"validatedAt,omitempty"` + ActivatedAt *time.Time `json:"activatedAt,omitempty"` + SupersededAt *time.Time `json:"supersededAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PairingInput struct { + AuthCenterURL string `json:"authCenterUrl"` + OnboardingCode string `json:"onboardingCode"` + PublicBaseURL string `json:"publicBaseUrl"` + WebBaseURL string `json:"webBaseUrl"` + LocalTenantKey string `json:"localTenantKey"` + LegacyJWTEnabled bool `json:"legacyJwtEnabled"` +} + +type ConsumerMetadata struct { + PublicBaseURL string `json:"public_base_url"` + WebBaseURL string `json:"web_base_url"` + RedirectURIs []string `json:"redirect_uris"` + LogoutURIs []string `json:"logout_uris"` + ReceiverEndpoint string `json:"receiver_endpoint,omitempty"` +} + +type ManifestApplication struct { + Manifest ManifestV1 + MachineCredentialRef string + SessionEncryptionKeyRef string + TraceID string + AuditID string +} + +func NewDraft(input PairingInput) (Revision, error) { + if _, err := input.ConsumerMetadata(false); err != nil { + return Revision{}, err + } + authCenter, _ := exactBaseURL(input.AuthCenterURL) + publicBase, _ := exactBaseURL(input.PublicBaseURL) + webBase, _ := exactBaseURL(input.WebBaseURL) + return Revision{ + ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, + AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey), + PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled, + Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, + SessionRefreshSeconds: 60, Version: 1, + }, nil +} + +func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) { + if revision.State != RevisionDraft { + return Revision{}, ErrRevisionConflict + } + if err := input.Manifest.Validate(); err != nil { + return Revision{}, err + } + capabilities := make(map[string]bool, len(input.Manifest.Capabilities)) + for _, capability := range input.Manifest.Capabilities { + capabilities[capability] = true + } + if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" { + return Revision{}, errors.New("machine credential reference is required") + } + if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" { + return Revision{}, errors.New("session encryption key reference is required") + } + revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/") + revision.TenantID = input.Manifest.TenantID + revision.ApplicationID = input.Manifest.ApplicationID + revision.Audience = input.Manifest.Audience + revision.Scopes = append([]string(nil), input.Manifest.Scopes...) + revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...) + if input.Manifest.Clients.BrowserLogin != nil { + revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID + } + if input.Manifest.Clients.MachineToMachine != nil { + revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID + } + revision.TokenIntrospection = capabilities["token_introspection"] + revision.SessionRevocation = capabilities["session_revocation"] + revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef) + revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef) + revision.LastTraceID = strings.TrimSpace(input.TraceID) + revision.LastAuditID = strings.TrimSpace(input.AuditID) + return revision, nil +} + +func CanTransition(from, to RevisionState) bool { + switch from { + case RevisionDraft: + return to == RevisionValidated || to == RevisionFailed + case RevisionValidated: + return to == RevisionActive || to == RevisionFailed + case RevisionActive: + return to == RevisionSuperseded + case RevisionSuperseded: + return to == RevisionValidated + default: + return false + } +} + +func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) { + authCenter, err := exactBaseURL(input.AuthCenterURL) + if err != nil { + return ConsumerMetadata{}, errors.New("auth center URL is invalid") + } + _ = authCenter + publicBase, err := exactBaseURL(input.PublicBaseURL) + if err != nil { + return ConsumerMetadata{}, errors.New("public base URL is invalid") + } + webBase, err := exactBaseURL(input.WebBaseURL) + if err != nil { + return ConsumerMetadata{}, errors.New("web base URL is invalid") + } + if strings.TrimSpace(input.LocalTenantKey) == "" { + return ConsumerMetadata{}, errors.New("local tenant mapping is required") + } + metadata := ConsumerMetadata{ + PublicBaseURL: publicBase, WebBaseURL: webBase, + RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"}, + LogoutURIs: []string{webBase + "/"}, + } + if sessionRevocation { + metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf" + } + return metadata, nil +} + +func exactBaseURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("invalid public URL") + } + hostname := strings.ToLower(parsed.Hostname()) + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("base URL must not contain a path") + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) { + return "", errors.New("public URL must use HTTPS") + } + port := parsed.Port() + if scheme == "https" && port == "443" || scheme == "http" && port == "80" { + port = "" + } + host := hostname + if strings.Contains(hostname, ":") { + host = "[" + hostname + "]" + } + if port != "" { + host = net.JoinHostPort(hostname, port) + } + return scheme + "://" + host, nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/apps/api/internal/identity/configuration_test.go b/apps/api/internal/identity/configuration_test.go new file mode 100644 index 0000000..1b7b745 --- /dev/null +++ b/apps/api/internal/identity/configuration_test.go @@ -0,0 +1,89 @@ +package identity + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRevisionStateTransitions(t *testing.T) { + tests := []struct { + from, to RevisionState + allowed bool + }{ + {RevisionDraft, RevisionValidated, true}, + {RevisionDraft, RevisionFailed, true}, + {RevisionValidated, RevisionActive, true}, + {RevisionValidated, RevisionFailed, true}, + {RevisionActive, RevisionSuperseded, true}, + {RevisionSuperseded, RevisionValidated, true}, + {RevisionSuperseded, RevisionActive, false}, + {RevisionDraft, RevisionActive, false}, + {RevisionFailed, RevisionActive, false}, + {RevisionActive, RevisionValidated, false}, + } + for _, test := range tests { + if got := CanTransition(test.from, test.to); got != test.allowed { + t.Errorf("CanTransition(%q, %q)=%v, want %v", test.from, test.to, got, test.allowed) + } + } +} + +func TestRevisionNeverSerializesSecretValues(t *testing.T) { + type secretFields interface { + MachineSecret() string + } + var _ = any((*Revision)(nil)) + if _, exposesSecret := any((*Revision)(nil)).(secretFields); exposesSecret { + t.Fatal("Revision unexpectedly exposes a machine secret value") + } + revision := Revision{MachineCredentialRef: "identity-machine-example", SessionEncryptionKeyRef: "identity-session-example"} + if revision.MachineCredentialRef == "" || revision.SessionEncryptionKeyRef == "" { + t.Fatal("Revision must retain SecretStore references") + } +} + +func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) { + input := PairingInput{ + AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + } + metadata, err := input.ConsumerMetadata(true) + if err != nil { + t.Fatal(err) + } + if metadata.RedirectURIs[0] != "https://api.example.com/api/v1/auth/oidc/callback" || + metadata.LogoutURIs[0] != "https://gateway.example.com/" || + metadata.ReceiverEndpoint != "https://api.example.com/api/v1/security-events/ssf" { + t.Fatalf("unexpected derived metadata: %#v", metadata) + } +} + +func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) { + for _, input := range []PairingInput{ + {AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, + {AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, + } { + if _, err := input.ConsumerMetadata(false); err == nil { + t.Fatalf("unsafe pairing input accepted: %#v", input) + } + } +} + +func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) { + draft, err := NewDraft(PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", + LocalTenantKey: "default", LegacyJWTEnabled: true, + }) + if err != nil { + t.Fatal(err) + } + if draft.State != RevisionDraft || draft.SessionIdleSeconds != 1800 || draft.SessionAbsoluteSeconds != 28800 || draft.SessionRefreshSeconds != 60 { + t.Fatalf("unexpected draft defaults: %#v", draft) + } + payload, _ := json.Marshal(draft) + if strings.Contains(string(payload), "must-never-be-persisted") || strings.Contains(string(payload), "onboardingCode") { + t.Fatalf("draft exposed onboarding code: %s", payload) + } +} diff --git a/apps/api/internal/identity/onboarding_client.go b/apps/api/internal/identity/onboarding_client.go new file mode 100644 index 0000000..1b4030b --- /dev/null +++ b/apps/api/internal/identity/onboarding_client.go @@ -0,0 +1,300 @@ +package identity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +const maxOnboardingResponseBytes = 1 << 20 + +type ExchangeStatus string + +const ( + ExchangeMetadataPending ExchangeStatus = "metadata_pending" + ExchangePreparing ExchangeStatus = "preparing" + ExchangeReady ExchangeStatus = "ready" + ExchangeCredentialDelivered ExchangeStatus = "credential_delivered" + ExchangeCompleted ExchangeStatus = "completed" + ExchangeFailed ExchangeStatus = "failed" + ExchangeExpired ExchangeStatus = "expired" +) + +type ClaimedExchange struct { + ExchangeID string `json:"exchange_id"` + ExchangeToken string `json:"exchange_token"` + ExpiresAt time.Time `json:"expires_at"` + Version int64 `json:"version"` +} + +type Exchange struct { + ExchangeID string `json:"exchange_id"` + ApplicationID string `json:"application_id"` + Status ExchangeStatus `json:"status"` + Version int64 `json:"version"` + ExpiresAt time.Time `json:"expires_at"` + LastErrorCategory string `json:"last_error_category,omitempty"` + AuditID string `json:"audit_id,omitempty"` +} + +type ManifestClient struct { + ClientID string `json:"client_id"` +} + +type ManifestClients struct { + BrowserLogin *ManifestClient `json:"browser_login,omitempty"` + MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"` +} + +type ManifestSecurityEvents struct { + TransmitterIssuer string `json:"transmitter_issuer"` + ConfigurationEndpoint string `json:"configuration_endpoint"` + Audience string `json:"audience"` +} + +type ManifestV1 struct { + SchemaVersion int `json:"schema_version"` + Issuer string `json:"issuer"` + TenantID string `json:"tenant_id"` + ApplicationID string `json:"application_id"` + Capabilities []string `json:"capabilities"` + Audience string `json:"audience,omitempty"` + Scopes []string `json:"scopes"` + Clients ManifestClients `json:"clients"` + SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"` +} + +type MachineCredential struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + IssuedAt time.Time `json:"issued_at"` +} + +type CredentialDelivery struct { + Manifest ManifestV1 `json:"manifest"` + MachineCredential *MachineCredential `json:"machine_credential,omitempty"` + Version int64 `json:"-"` +} + +func (manifest ManifestV1) Validate() error { + if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil { + return errors.New("application manifest identity metadata is invalid") + } + if _, err := uuid.Parse(manifest.TenantID); err != nil { + return errors.New("application manifest tenant is invalid") + } + if _, err := uuid.Parse(manifest.ApplicationID); err != nil { + return errors.New("application manifest application is invalid") + } + allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true} + capabilities := make(map[string]bool, len(manifest.Capabilities)) + for _, capability := range manifest.Capabilities { + if !allowed[capability] || capabilities[capability] { + return errors.New("application manifest capabilities are invalid") + } + capabilities[capability] = true + } + if capabilities["session_revocation"] && !capabilities["token_introspection"] || + capabilities["token_introspection"] && !capabilities["machine_to_machine"] { + return errors.New("application manifest capability dependencies are invalid") + } + if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") { + return errors.New("application manifest browser client is missing") + } + if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") { + return errors.New("application manifest machine client is missing") + } + if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" { + return errors.New("application manifest audience is missing") + } + if capabilities["session_revocation"] { + if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil || + validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" { + return errors.New("application manifest security event metadata is invalid") + } + } + seenScopes := make(map[string]bool, len(manifest.Scopes)) + for _, scope := range manifest.Scopes { + scope = strings.TrimSpace(scope) + if scope == "" || seenScopes[scope] { + return errors.New("application manifest scopes are invalid") + } + seenScopes[scope] = true + } + return nil +} + +func validatePublicIdentityURL(raw string) error { + _, err := exactBaseURL(raw) + if err == nil { + return nil + } + // Discovery endpoints may contain a path, but must retain the same URL + // safety constraints as a base URL. + parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil) + if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" { + return errors.New("identity URL is invalid") + } + scheme := strings.ToLower(parsed.URL.Scheme) + if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) { + return nil + } + return errors.New("identity URL must use HTTPS") +} + +type OnboardingClient struct { + baseURL string + client *http.Client +} + +func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) { + normalized, err := exactBaseURL(baseURL) + if err != nil { + return nil, errors.New("auth center URL is invalid") + } + if base == nil { + base = &http.Client{Timeout: 10 * time.Second} + } + client := *base + if client.Timeout <= 0 { + client.Timeout = 10 * time.Second + } + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + return &OnboardingClient{baseURL: normalized, client: &client}, nil +} + +func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) { + var output ClaimedExchange + status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0, + map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output) + if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" { + return ClaimedExchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) { + var output Exchange + status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata", + claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output) + if err != nil || status != http.StatusAccepted || output.Version < 1 { + return Exchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) { + var output Exchange + status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output) + if err != nil || status != http.StatusOK || output.Version < 1 { + return Exchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) { + var output CredentialDelivery + status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries", + exchangeToken, idempotencyKey, exchange.Version, nil, &output) + if err != nil || status != http.StatusOK { + return CredentialDelivery{}, onboardingProtocolError(err) + } + if err := output.Manifest.Validate(); err != nil { + return CredentialDelivery{}, err + } + output.Version, err = parseWeakETag(etag) + if err != nil { + return CredentialDelivery{}, errors.New("onboarding response ETag is invalid") + } + return output, nil +} + +func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error { + status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion", + exchangeToken, idempotencyKey, version, nil, nil) + if err != nil || status != http.StatusNoContent { + return onboardingProtocolError(err) + } + return nil +} + +func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return 0, "", err + } + reader = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader) + if err != nil { + return 0, "", err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if idempotencyKey != "" { + request.Header.Set("Idempotency-Key", idempotencyKey) + } + if version > 0 { + request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version)) + } + response, err := client.client.Do(request) + if err != nil { + return 0, "", errors.New("onboarding endpoint request failed") + } + defer response.Body.Close() + if response.StatusCode >= 300 && response.StatusCode < 400 { + return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden") + } + if output == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes)) + return response.StatusCode, response.Header.Get("ETag"), nil + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1)) + if err != nil || len(payload) > maxOnboardingResponseBytes { + return response.StatusCode, "", errors.New("onboarding endpoint response is invalid") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response.StatusCode, "", errors.New("onboarding endpoint rejected the request") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return response.StatusCode, "", errors.New("onboarding endpoint response is invalid") + } + return response.StatusCode, response.Header.Get("ETag"), nil +} + +func onboardingProtocolError(cause error) error { + if cause == nil { + return errors.New("onboarding endpoint returned an unexpected status") + } + return cause +} + +func parseWeakETag(value string) (int64, error) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) { + return 0, errors.New("invalid ETag") + } + version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64) + if err != nil || version < 1 { + return 0, errors.New("invalid ETag") + } + return version, nil +} diff --git a/apps/api/internal/identity/onboarding_client_test.go b/apps/api/internal/identity/onboarding_client_test.go new file mode 100644 index 0000000..aadce38 --- /dev/null +++ b/apps/api/internal/identity/onboarding_client_test.go @@ -0,0 +1,97 @@ +package identity + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) { + const code = "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH" + const token = "ex1.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.abcdefghijklmnopqrstuvwxyzABCDEFGH" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.String(), code) || strings.Contains(r.URL.String(), token) { + t.Fatal("onboarding credential leaked into URL") + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/onboarding-exchanges": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["onboarding_code"] != code || r.Header.Get("Authorization") != "" { + t.Fatalf("unexpected claim request: body=%#v authorization=%q", body, r.Header.Get("Authorization")) + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","exchange_token":"` + token + `","expires_at":"2026-07-17T12:30:00Z","version":1}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/onboarding-exchanges/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/metadata": + if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("If-Match") != `W/"1"` || len(r.Header.Get("Idempotency-Key")) < 16 { + t.Fatalf("unexpected exchange headers: %#v", r.Header) + } + w.Header().Set("ETag", `W/"2"`) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","application_id":"cccccccc-cccc-cccc-cccc-cccccccccccc","status":"preparing","version":2,"expires_at":"2026-07-17T12:30:00Z"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := NewOnboardingClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + claimed, err := client.Claim(context.Background(), code) + if err != nil { + t.Fatal(err) + } + metadata := ConsumerMetadata{PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", RedirectURIs: []string{"https://api.example.com/api/v1/auth/oidc/callback"}, LogoutURIs: []string{"https://gateway.example.com/"}} + view, err := client.SubmitMetadata(context.Background(), claimed, metadata, "pairing-metadata-123456") + if err != nil || view.Status != ExchangePreparing || view.Version != 2 { + t.Fatalf("view=%#v err=%v", view, err) + } +} + +func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *testing.T) { + valid := ManifestV1{ + SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", + TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, + Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Scopes: []string{"openid", "gateway.access"}, + Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}}, + SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}, + } + if err := valid.Validate(); err != nil { + t.Fatal(err) + } + invalid := valid + invalid.Capabilities = []string{"session_revocation"} + if err := invalid.Validate(); err == nil { + t.Fatal("manifest with missing capability dependencies was accepted") + } + invalid = valid + invalid.SchemaVersion = 2 + if err := invalid.Validate(); err == nil { + t.Fatal("unsupported manifest schema was accepted") + } +} + +func TestOnboardingClientRejectsRedirects(t *testing.T) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "should not be reached", http.StatusTeapot) + })) + defer target.Close() + redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) + })) + defer redirect.Close() + client, err := NewOnboardingClient(redirect.URL, redirect.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := client.Claim(context.Background(), "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"); err == nil { + t.Fatal("redirecting onboarding endpoint was accepted") + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go new file mode 100644 index 0000000..2c3aa73 --- /dev/null +++ b/apps/api/internal/store/identity_configurations.go @@ -0,0 +1,240 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/jackc/pgx/v5" +) + +const identityRevisionColumns = ` +id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), +COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, +local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation, +COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds, +session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''), +validated_at,activated_at,superseded_at,created_at,updated_at` + +func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) { + scopes, _ := json.Marshal(revision.Scopes) + capabilities, _ := json.Marshal(revision.Capabilities) + return scanIdentityRevision(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, + jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds +) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15) +RETURNING `+identityRevisionColumns, + revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix, + revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled, + string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds, + )) +} + +func (s *Store) IdentityConfigurationRevision(ctx context.Context, id string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE id=$1::uuid`, id)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) ActiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE state='active'`)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) { + current, err := s.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if current.Version != expectedVersion { + return identity.Revision{}, identity.ErrRevisionConflict + } + updated, err := identity.ApplyManifest(current, applied) + if err != nil { + return identity.Revision{}, err + } + scopes, _ := json.Marshal(updated.Scopes) + capabilities, _ := json.Marshal(updated.Capabilities) + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET + issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''), + scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12, + machine_credential_ref=NULLIF($13,''),session_encryption_key_ref=NULLIF($14,''),last_trace_id=NULLIF($15,''), + last_audit_id=NULLIF($16,''),last_error_category=NULL,version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='draft' +RETURNING `+identityRevisionColumns, + id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, + updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection, + updated.SessionRevocation, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, + )) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL, + last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state IN ('draft','superseded') +RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''), + last_trace_id=NULLIF($4,''),last_audit_id=NULLIF($5,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state IN ('draft','validated') +RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64) (identity.Revision, bool, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.Revision{}, false, err + } + defer tx.Rollback(ctx) + if ok, err := hasBreakGlassManager(ctx, tx); err != nil { + return identity.Revision{}, false, err + } else if !ok { + return identity.Revision{}, false, identity.ErrBreakGlassRequired + } + var tenantExists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=( +SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil { + return identity.Revision{}, false, err + } + if !tenantExists { + return identity.Revision{}, false, identity.ErrLocalTenantInvalid + } + var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string + _ = tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), +COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions +WHERE state='active' FOR UPDATE`).Scan(&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef) + var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string + if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), +COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions +WHERE id=$1::uuid AND version=$2 AND state='validated' FOR UPDATE`, id, expectedVersion).Scan( + &nextIssuer, &nextTenant, &nextAudience, &nextClient, &nextSessionRef, + ); errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, false, identity.ErrRevisionConflict + } else if err != nil { + return identity.Revision{}, false, err + } + if previousID != "" { + if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',superseded_at=now(), +version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previousID); err != nil { + return identity.Revision{}, false, err + } + } + revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active', +activated_at=now(),superseded_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='validated' +RETURNING `+identityRevisionColumns, id, expectedVersion)) + if err != nil { + return identity.Revision{}, false, err + } + identityChanged := previousID != "" && (previousIssuer != nextIssuer || previousTenant != nextTenant || previousAudience != nextAudience || previousClient != nextClient || previousSessionRef != nextSessionRef) + if identityChanged { + if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { + return identity.Revision{}, false, err + } + } + if err := tx.Commit(ctx); err != nil { + return identity.Revision{}, false, err + } + return revision, identityChanged, nil +} + +func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64) (identity.Revision, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.Revision{}, err + } + defer tx.Rollback(ctx) + if ok, err := hasBreakGlassManager(ctx, tx); err != nil { + return identity.Revision{}, err + } else if !ok { + return identity.Revision{}, identity.ErrBreakGlassRequired + } + revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded', +superseded_at=now(),version=version+1,updated_at=now() WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + if err != nil { + return identity.Revision{}, err + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { + return identity.Revision{}, err + } + if err := tx.Commit(ctx); err != nil { + return identity.Revision{}, err + } + return revision, nil +} + +func (s *Store) HasBreakGlassManager(ctx context.Context) (bool, error) { + return hasBreakGlassManager(ctx, s.pool) +} + +func (s *Store) HasActiveTenantKey(ctx context.Context, tenantKey string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=$1 AND status='active')`, tenantKey).Scan(&exists) + return exists, err +} + +func hasBreakGlassManager(ctx context.Context, query interface { + QueryRow(context.Context, string, ...any) pgx.Row +}) (bool, error) { + var exists bool + err := query.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_users WHERE source='gateway' AND status='active' +AND deleted_at IS NULL AND password_hash IS NOT NULL AND password_hash <> '' AND (roles ? 'manager' OR roles ? 'admin'))`).Scan(&exists) + return exists, err +} + +func scanIdentityRevision(row scanner) (identity.Revision, error) { + var revision identity.Revision + var state string + var scopes, capabilities []byte + if err := row.Scan( + &revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID, + &revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes, + &capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL, + &revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation, + &revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds, + &revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory, + &revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt, + &revision.CreatedAt, &revision.UpdatedAt, + ); err != nil { + return identity.Revision{}, err + } + revision.State = identity.RevisionState(state) + _ = json.Unmarshal(scopes, &revision.Scopes) + _ = json.Unmarshal(capabilities, &revision.Capabilities) + if revision.Scopes == nil { + revision.Scopes = []string{} + } + if revision.Capabilities == nil { + revision.Capabilities = []string{} + } + return revision, nil +} + +func normalizeIdentityRevisionError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return identity.ErrRevisionNotFound + } + return err +} diff --git a/apps/api/migrations/0067_identity_configuration_revisions.sql b/apps/api/migrations/0067_identity_configuration_revisions.sql new file mode 100644 index 0000000..12edebc --- /dev/null +++ b/apps/api/migrations/0067_identity_configuration_revisions.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','validated','active','superseded','failed')), + schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1), + auth_center_url text NOT NULL, + issuer text, + tenant_id text, + application_id text, + audience text, + browser_client_id text, + machine_client_id text, + scopes jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(scopes) = 'array'), + capabilities jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(capabilities) = 'array'), + role_prefix text NOT NULL DEFAULT 'gateway.', + local_tenant_key text NOT NULL, + public_base_url text NOT NULL, + web_base_url text NOT NULL, + jit_enabled boolean NOT NULL DEFAULT true, + legacy_jwt_enabled boolean NOT NULL DEFAULT false, + token_introspection boolean NOT NULL DEFAULT false, + session_revocation boolean NOT NULL DEFAULT false, + machine_credential_ref text, + session_encryption_key_ref text, + session_idle_seconds integer NOT NULL DEFAULT 1800 CHECK (session_idle_seconds > 0), + session_absolute_seconds integer NOT NULL DEFAULT 28800 CHECK (session_absolute_seconds > session_idle_seconds), + session_refresh_seconds integer NOT NULL DEFAULT 60 CHECK (session_refresh_seconds > 0 AND session_refresh_seconds < session_idle_seconds), + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + last_error_category text, + last_trace_id text, + last_audit_id text, + validated_at timestamptz, + activated_at timestamptz, + superseded_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (machine_credential_ref IS NULL OR machine_credential_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'), + CHECK (session_encryption_key_ref IS NULL OR session_encryption_key_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$') +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_configuration_single_active + ON gateway_identity_configuration_revisions ((true)) WHERE state = 'active'; + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_configuration_revisions_created + ON gateway_identity_configuration_revisions(created_at DESC); + +CREATE TABLE IF NOT EXISTS gateway_identity_management_requests ( + operation text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (operation, idempotency_key) +); From 96bbd3a2f6957fe78a76ba99f945ede160b47d01 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 11:55:24 +0800 Subject: [PATCH 11/20] =?UTF-8?q?feat(identity):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=A0=81=E9=85=8D=E5=AF=B9=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway 使用一次性接入码创建 Exchange,将 Exchange Token、机器凭据与 Session Key 仅写入 SecretStore,并持久化脱敏配对进度。\n\n资源就绪后先保存凭据和 Manifest,再自动准备 SSF,最后确认远端 Exchange;SecretStore 失败时保持 ready,重试会触发新 Secret 轮换,不回放旧值。\n\n验证:go test ./...;go vet ./... --- apps/api/cmd/migrate/main_test.go | 22 ++ apps/api/internal/identity/configuration.go | 8 + apps/api/internal/identity/pairing_service.go | 301 ++++++++++++++++++ .../internal/identity/pairing_service_test.go | 156 +++++++++ .../internal/store/identity_configurations.go | 10 +- apps/api/internal/store/identity_pairing.go | 61 ++++ .../0068_identity_onboarding_exchanges.sql | 23 ++ 7 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 apps/api/internal/identity/pairing_service.go create mode 100644 apps/api/internal/identity/pairing_service_test.go create mode 100644 apps/api/internal/store/identity_pairing.go create mode 100644 apps/api/migrations/0068_identity_onboarding_exchanges.sql diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index 4c17357..9ccf709 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -63,3 +63,25 @@ func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleA } } } + +func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0068_identity_onboarding_exchanges.sql") + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "create table if not exists gateway_identity_onboarding_exchanges", + "exchange_token_ref text not null", + "security_event_configuration_url text", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity onboarding migration is missing %q", required) + } + } + for _, forbidden := range []string{"exchange_token text", "onboarding_code", "client_secret", "machine_secret"} { + if strings.Contains(content, forbidden) { + t.Fatalf("identity onboarding migration stores forbidden secret field %q", forbidden) + } + } +} diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index 2faf81c..a43f78e 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -48,6 +48,9 @@ type Revision struct { LegacyJWTEnabled bool `json:"legacyJwtEnabled"` TokenIntrospection bool `json:"tokenIntrospection"` SessionRevocation bool `json:"sessionRevocation"` + SecurityEventIssuer string `json:"securityEventIssuer,omitempty"` + SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"` + SecurityEventAudience string `json:"securityEventAudience,omitempty"` MachineCredentialRef string `json:"-"` SessionEncryptionKeyRef string `json:"-"` SessionIdleSeconds int `json:"sessionIdleSeconds"` @@ -136,6 +139,11 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro } revision.TokenIntrospection = capabilities["token_introspection"] revision.SessionRevocation = capabilities["session_revocation"] + if input.Manifest.SecurityEvents != nil { + revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer + revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint + revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience + } revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef) revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef) revision.LastTraceID = strings.TrimSpace(input.TraceID) diff --git a/apps/api/internal/identity/pairing_service.go b/apps/api/internal/identity/pairing_service.go new file mode 100644 index 0000000..7a63c73 --- /dev/null +++ b/apps/api/internal/identity/pairing_service.go @@ -0,0 +1,301 @@ +package identity + +import ( + "context" + "crypto/rand" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +type PairingStatus string + +const ( + PairingMetadataPending PairingStatus = "metadata_pending" + PairingPreparing PairingStatus = "preparing" + PairingReady PairingStatus = "ready" + PairingCredentialsSaved PairingStatus = "credentials_saved" + PairingCompleted PairingStatus = "completed" + PairingFailed PairingStatus = "failed" + PairingExpired PairingStatus = "expired" +) + +type PairingExchange struct { + ID string `json:"id"` + RevisionID string `json:"revisionId"` + RemoteExchangeID string `json:"remoteExchangeId"` + ExchangeTokenRef string `json:"-"` + Status PairingStatus `json:"status"` + RemoteVersion int64 `json:"remoteVersion"` + ExpiresAt time.Time `json:"expiresAt"` + Version int64 `json:"version"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + AuthCenterAuditID string `json:"authCenterAuditId,omitempty"` + LastTraceID string `json:"lastTraceId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PairingExchangeUpdate struct { + Status PairingStatus + RemoteVersion int64 + LastErrorCategory string + AuthCenterAuditID string +} + +type PairingRepository interface { + CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error) + IdentityConfigurationRevision(context.Context, string) (Revision, error) + ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error) + CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error) + IdentityPairingExchange(context.Context, string) (PairingExchange, error) + UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error) +} + +type PairingSecretStore interface { + Put(context.Context, string, []byte) error + Get(context.Context, string) ([]byte, error) + Delete(context.Context, string) error +} + +type OnboardingRemote interface { + Claim(context.Context, string) (ClaimedExchange, error) + SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) + Get(context.Context, string, string) (Exchange, error) + DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) + Complete(context.Context, string, string, string, int64) error +} + +type SecurityEventPreparer interface { + PrepareSecurityEvents(context.Context, Revision, []byte) error +} + +type PairingService struct { + repository PairingRepository + secrets PairingSecretStore + remote func(string) (OnboardingRemote, error) + security SecurityEventPreparer +} + +func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService { + return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security} +} + +func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) { + draft, err := NewDraft(input) + if err != nil { + return PairingExchange{}, err + } + draft.LastTraceID = strings.TrimSpace(traceID) + draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft) + if err != nil { + return PairingExchange{}, err + } + remote, err := service.remote(draft.AuthCenterURL) + if err != nil { + return PairingExchange{}, err + } + claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode)) + if err != nil { + return PairingExchange{}, err + } + pairingID := uuid.NewString() + tokenReference := "identity-exchange-" + pairingID + if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil { + return PairingExchange{}, err + } + claimed.ExchangeToken = "" + pairing := PairingExchange{ + ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference, + Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt, + Version: 1, LastTraceID: strings.TrimSpace(traceID), + } + pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing) + if err != nil { + _ = service.secrets.Delete(ctx, tokenReference) + return PairingExchange{}, err + } + return pairing, nil +} + +func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) { + for step := 0; step < 6; step++ { + pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) + if err != nil { + return PairingExchange{}, err + } + if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired { + return pairing, nil + } + revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) + if err != nil { + return PairingExchange{}, err + } + token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef) + if err != nil { + return PairingExchange{}, err + } + remote, err := service.remote(revision.AuthCenterURL) + if err != nil { + clear(token) + return PairingExchange{}, err + } + switch pairing.Status { + case PairingMetadataPending: + metadata, metadataErr := PairingInput{ + AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL, + WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey, + }.ConsumerMetadata(true) + if metadataErr != nil { + clear(token) + return PairingExchange{}, metadataErr + } + view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{ + ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion, + }, metadata, "gateway-pairing-metadata-"+pairing.ID) + clear(token) + if submitErr != nil { + return PairingExchange{}, submitErr + } + if _, err := service.updateFromRemote(ctx, pairing, view); err != nil { + return PairingExchange{}, err + } + case PairingPreparing: + view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token)) + clear(token) + if getErr != nil { + return PairingExchange{}, getErr + } + updated, err := service.updateFromRemote(ctx, pairing, view) + if err != nil { + return PairingExchange{}, err + } + if updated.Status == PairingPreparing { + return updated, nil + } + case PairingReady: + delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{ + ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID, + Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt, + }, string(token), "gateway-pairing-credential-"+uuid.NewString()) + clear(token) + if deliveryErr != nil { + return PairingExchange{}, deliveryErr + } + updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery) + if saveErr != nil { + return PairingExchange{}, saveErr + } + _ = updatedRevision + if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ + Status: PairingCredentialsSaved, RemoteVersion: delivery.Version, + }); err != nil { + return PairingExchange{}, err + } + case PairingCredentialsSaved: + if revision.SessionRevocation { + if service.security == nil { + clear(token) + return PairingExchange{}, errors.New("security event preparation is unavailable") + } + secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef) + if secretErr != nil { + clear(token) + return PairingExchange{}, secretErr + } + prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret) + clear(secret) + if prepareErr != nil { + clear(token) + return PairingExchange{}, prepareErr + } + } + completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion) + clear(token) + if completeErr != nil { + return PairingExchange{}, completeErr + } + completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ + Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion, + }) + if err != nil { + return PairingExchange{}, err + } + _ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef) + return completed, nil + default: + clear(token) + return PairingExchange{}, ErrRevisionConflict + } + } + return service.repository.IdentityPairingExchange(ctx, pairingID) +} + +func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) { + if err := delivery.Manifest.Validate(); err != nil { + return Revision{}, err + } + machineReference := "" + if delivery.Manifest.Clients.MachineToMachine != nil { + if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID { + return Revision{}, errors.New("onboarding machine credential is invalid") + } + machineReference = "identity-machine-" + revision.ID + secret := []byte(delivery.MachineCredential.ClientSecret) + delivery.MachineCredential.ClientSecret = "" + if err := service.secrets.Put(ctx, machineReference, secret); err != nil { + clear(secret) + return Revision{}, err + } + clear(secret) + } + sessionReference := "" + if delivery.Manifest.Clients.BrowserLogin != nil { + sessionReference = "identity-session-" + revision.ID + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + _ = service.secrets.Delete(ctx, machineReference) + return Revision{}, err + } + if err := service.secrets.Put(ctx, sessionReference, key); err != nil { + clear(key) + _ = service.secrets.Delete(ctx, machineReference) + return Revision{}, err + } + clear(key) + } + updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{ + Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference, + TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, + }) + if err != nil { + if machineReference != "" { + _ = service.secrets.Delete(ctx, machineReference) + } + if sessionReference != "" { + _ = service.secrets.Delete(ctx, sessionReference) + } + return Revision{}, err + } + return updated, nil +} + +func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) { + status := PairingPreparing + switch remote.Status { + case ExchangeReady, ExchangeCredentialDelivered: + status = PairingReady + case ExchangeCompleted: + status = PairingFailed + remote.LastErrorCategory = "remote_state_invalid" + case ExchangeFailed: + status = PairingFailed + case ExchangeExpired: + status = PairingExpired + } + return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ + Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID, + }) +} diff --git a/apps/api/internal/identity/pairing_service_test.go b/apps/api/internal/identity/pairing_service_test.go new file mode 100644 index 0000000..f8823c8 --- /dev/null +++ b/apps/api/internal/identity/pairing_service_test.go @@ -0,0 +1,156 @@ +package identity + +import ( + "context" + "errors" + "testing" + "time" +) + +type pairingRepositoryFake struct { + revision Revision + exchange PairingExchange +} + +func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) { + f.revision = revision + return revision, nil +} +func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, string) (Revision, error) { + return f.revision, nil +} +func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) { + revision, err := ApplyManifest(f.revision, applied) + if err != nil { + return Revision{}, err + } + revision.Version++ + f.revision = revision + return revision, nil +} +func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) { + f.exchange = exchange + return exchange, nil +} +func (f *pairingRepositoryFake) IdentityPairingExchange(context.Context, string) (PairingExchange, error) { + return f.exchange, nil +} +func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, id string, expected int64, update PairingExchangeUpdate) (PairingExchange, error) { + if f.exchange.ID != id || f.exchange.Version != expected { + return PairingExchange{}, ErrRevisionConflict + } + f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion + f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID + f.exchange.Version++ + return f.exchange, nil +} + +type secretStoreFake struct { + values map[string][]byte + failMachine bool +} + +func (f *secretStoreFake) Put(_ context.Context, reference string, value []byte) error { + if f.failMachine && len(reference) > len("identity-machine-") && reference[:len("identity-machine-")] == "identity-machine-" { + return errors.New("secret store unavailable") + } + if f.values == nil { + f.values = map[string][]byte{} + } + f.values[reference] = append([]byte(nil), value...) + return nil +} +func (f *secretStoreFake) Get(_ context.Context, reference string) ([]byte, error) { + value, ok := f.values[reference] + if !ok { + return nil, errors.New("secret not found") + } + return append([]byte(nil), value...), nil +} +func (f *secretStoreFake) Delete(_ context.Context, reference string) error { + delete(f.values, reference) + return nil +} + +type onboardingRemoteFake struct { + claimed ClaimedExchange + view Exchange + delivery CredentialDelivery + deliveryCalls int + completed bool +} + +func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) { + return f.claimed, nil +} +func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) { + f.view.Status, f.view.Version = ExchangePreparing, 2 + return f.view, nil +} +func (f *onboardingRemoteFake) Get(context.Context, string, string) (Exchange, error) { + f.view.Status, f.view.Version = ExchangeReady, 3 + return f.view, nil +} +func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) { + f.deliveryCalls++ + f.delivery.MachineCredential.ClientSecret = "rotated-machine-secret-value-000000000000-" + string(rune('0'+f.deliveryCalls)) + f.delivery.Version = int64(3 + f.deliveryCalls) + return f.delivery, nil +} +func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error { + f.completed = true + return nil +} + +type securityEventPreparerFake struct{ called bool } + +func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error { + f.called = true + return nil +} + +func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) { + repository := &pairingRepositoryFake{} + secrets := &secretStoreFake{values: map[string][]byte{}, failMachine: true} + remote := &onboardingRemoteFake{ + claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + delivery: CredentialDelivery{Manifest: ManifestV1{ + SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333", + ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222", + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"}, + Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}}, + SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"}, + }, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}}, + } + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer) + pairing, err := service.Start(context.Background(), PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + }, "trace-pairing") + if err != nil { + t.Fatal(err) + } + if _, ok := secrets.values[pairing.ExchangeTokenRef]; !ok { + t.Fatal("exchange token was not saved in SecretStore") + } + + if _, err := service.Continue(context.Background(), pairing.ID); err == nil { + t.Fatal("machine SecretStore failure was ignored") + } + if remote.completed || repository.revision.ApplicationID != "" { + t.Fatal("failed secret save advanced the exchange or revision") + } + secrets.failMachine = false + completed, err := service.Continue(context.Background(), pairing.ID) + if err != nil { + t.Fatal(err) + } + if !remote.completed || completed.Status != PairingCompleted || remote.deliveryCalls != 2 || !preparer.called { + t.Fatalf("pairing did not recover safely: pairing=%#v calls=%d completed=%v", completed, remote.deliveryCalls, remote.completed) + } + if repository.revision.MachineCredentialRef == "" || repository.revision.SessionEncryptionKeyRef == "" { + t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 2c3aa73..1dcf285 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -13,6 +13,7 @@ const identityRevisionColumns = ` id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation, +COALESCE(security_event_issuer,''),COALESCE(security_event_configuration_url,''),COALESCE(security_event_audience,''), COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds, session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''), validated_at,activated_at,superseded_at,created_at,updated_at` @@ -62,13 +63,15 @@ func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVe UPDATE gateway_identity_configuration_revisions SET issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''), scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12, - machine_credential_ref=NULLIF($13,''),session_encryption_key_ref=NULLIF($14,''),last_trace_id=NULLIF($15,''), - last_audit_id=NULLIF($16,''),last_error_category=NULL,version=version+1,updated_at=now() + security_event_issuer=NULLIF($13,''),security_event_configuration_url=NULLIF($14,''),security_event_audience=NULLIF($15,''), + machine_credential_ref=NULLIF($16,''),session_encryption_key_ref=NULLIF($17,''),last_trace_id=NULLIF($18,''), + last_audit_id=NULLIF($19,''),last_error_category=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='draft' RETURNING `+identityRevisionColumns, id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection, - updated.SessionRevocation, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, + updated.SessionRevocation, updated.SecurityEventIssuer, updated.SecurityEventConfigURL, updated.SecurityEventAudience, + updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, )) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict @@ -213,6 +216,7 @@ func scanIdentityRevision(row scanner) (identity.Revision, error) { &revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes, &capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL, &revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation, + &revision.SecurityEventIssuer, &revision.SecurityEventConfigURL, &revision.SecurityEventAudience, &revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds, &revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory, &revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt, diff --git a/apps/api/internal/store/identity_pairing.go b/apps/api/internal/store/identity_pairing.go new file mode 100644 index 0000000..e37a215 --- /dev/null +++ b/apps/api/internal/store/identity_pairing.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "errors" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/jackc/pgx/v5" +) + +const identityPairingColumns = ` +id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,remote_version,expires_at,version, +COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),created_at,updated_at` + +func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) { + return scanIdentityPairing(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_identity_onboarding_exchanges ( + id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_trace_id +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,NULLIF($8,'')) +RETURNING `+identityPairingColumns, + exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef, + exchange.Status, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, + )) +} + +func (s *Store) IdentityPairingExchange(ctx context.Context, id string) (identity.PairingExchange, error) { + exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionNotFound + } + return exchange, err +} + +func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) { + exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''), +auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 +RETURNING `+identityPairingColumns, + id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID, + )) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + return exchange, err +} + +func scanIdentityPairing(row scanner) (identity.PairingExchange, error) { + var exchange identity.PairingExchange + var status string + if err := row.Scan( + &exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, + &exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory, + &exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CreatedAt, &exchange.UpdatedAt, + ); err != nil { + return identity.PairingExchange{}, err + } + exchange.Status = identity.PairingStatus(status) + return exchange, nil +} diff --git a/apps/api/migrations/0068_identity_onboarding_exchanges.sql b/apps/api/migrations/0068_identity_onboarding_exchanges.sql new file mode 100644 index 0000000..c3168ac --- /dev/null +++ b/apps/api/migrations/0068_identity_onboarding_exchanges.sql @@ -0,0 +1,23 @@ +ALTER TABLE gateway_identity_configuration_revisions + ADD COLUMN IF NOT EXISTS security_event_issuer text, + ADD COLUMN IF NOT EXISTS security_event_configuration_url text, + ADD COLUMN IF NOT EXISTS security_event_audience text; + +CREATE TABLE IF NOT EXISTS gateway_identity_onboarding_exchanges ( + id uuid PRIMARY KEY, + revision_id uuid NOT NULL UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE CASCADE, + remote_exchange_id uuid NOT NULL UNIQUE, + exchange_token_ref text NOT NULL CHECK (exchange_token_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'), + status text NOT NULL CHECK (status IN ('metadata_pending','preparing','ready','credentials_saved','completed','failed','expired')), + remote_version bigint NOT NULL CHECK (remote_version > 0), + expires_at timestamptz NOT NULL, + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + last_error_category text, + auth_center_audit_id text, + last_trace_id text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_status + ON gateway_identity_onboarding_exchanges(status, updated_at DESC); From a9e23cb237f4f532ddcdf26ff6df9d57e4a0a77a Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:05:00 +0800 Subject: [PATCH 12/20] =?UTF-8?q?feat(identity):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E7=83=AD=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 Active Revision 构造并验证 OIDC、BFF Session、Introspection 与 SSF Runtime,在数据库激活成功后原子替换内存引用。请求链路使用不可变快照,失败保留当前运行时,本地管理登录不受影响。\n\n验证:go test ./apps/api/...;go vet ./apps/api/... --- apps/api/internal/auth/auth.go | 48 ++-- apps/api/internal/auth/oidc.go | 14 ++ apps/api/internal/auth/oidc_client.go | 6 + apps/api/internal/auth/oidc_client_test.go | 3 + .../internal/auth/oidc_session_cookie_test.go | 23 ++ apps/api/internal/auth/oidc_test.go | 3 + apps/api/internal/httpapi/identity_runtime.go | 96 ++++++++ apps/api/internal/httpapi/oidc_session.go | 88 +++++--- .../internal/httpapi/oidc_user_middleware.go | 15 +- .../security_event_connection_handlers.go | 67 +++--- apps/api/internal/httpapi/server.go | 106 +++------ apps/api/internal/identityruntime/builder.go | 209 ++++++++++++++++++ apps/api/internal/identityruntime/manager.go | 151 +++++++++++++ .../internal/identityruntime/manager_test.go | 120 ++++++++++ 14 files changed, 797 insertions(+), 152 deletions(-) create mode 100644 apps/api/internal/httpapi/identity_runtime.go create mode 100644 apps/api/internal/identityruntime/builder.go create mode 100644 apps/api/internal/identityruntime/manager.go create mode 100644 apps/api/internal/identityruntime/manager_test.go diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index 087b4b5..b401837 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -73,16 +73,19 @@ func NewRequestAuthError(status int, code, message string) error { } type Authenticator struct { - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - ServerMainInternalKey string - ServerMainInternalSecret string - HTTPClient *http.Client - LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) - OIDCVerifier *OIDCVerifier - OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error) - LegacyJWTEnabled bool + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + HTTPClient *http.Client + LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error) + OIDCVerifier *OIDCVerifier + OIDCVerifierProvider func() *OIDCVerifier + OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error) + OIDCSessionResolverProvider func(ctx context.Context, sessionID string) (*User, error) + LegacyJWTEnabled bool + LegacyJWTEnabledProvider func() bool } func New(jwtSecret string, serverMainBaseURL string, internalToken string) *Authenticator { @@ -152,10 +155,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if token == "" { if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil { sessionID := strings.TrimSpace(cookie.Value) - if sessionID == "" || a.OIDCSessionResolver == nil { + resolver := a.OIDCSessionResolver + if a.OIDCSessionResolverProvider != nil { + resolver = a.OIDCSessionResolverProvider + } + if sessionID == "" || resolver == nil { return nil, ErrUnauthorized } - return a.OIDCSessionResolver(r.Context(), sessionID) + return resolver(r.Context(), sessionID) } } if token == "" { @@ -168,7 +175,7 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if algorithm == "RS256" || algorithm == "ES256" { return a.AuthenticateOIDCAccessToken(r.Context(), token) } - if !a.LegacyJWTEnabled { + if !a.legacyJWTEnabled() { return nil, ErrUnauthorized } return a.verifyJWT(token) @@ -176,10 +183,21 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) { algorithm := jwtAlgorithm(token) - if a.OIDCVerifier == nil || algorithm != "RS256" && algorithm != "ES256" { + verifier := a.OIDCVerifier + if a.OIDCVerifierProvider != nil { + verifier = a.OIDCVerifierProvider() + } + if verifier == nil || algorithm != "RS256" && algorithm != "ES256" { return nil, ErrUnauthorized } - return a.OIDCVerifier.Verify(ctx, token) + return verifier.Verify(ctx, token) +} + +func (a *Authenticator) legacyJWTEnabled() bool { + if a.LegacyJWTEnabledProvider != nil { + return a.LegacyJWTEnabledProvider() + } + return a.LegacyJWTEnabled } func (a *Authenticator) verifyJWT(tokenString string) (*User, error) { diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index 9d3b02f..d86cd9c 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -112,6 +112,20 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { return &OIDCVerifier{config: config, client: client, keys: map[string]any{}}, nil } +// ValidateConfiguration eagerly validates Discovery, JWKS and, when enabled, +// the RFC 7662 endpoint and machine credential before a runtime is activated. +func (v *OIDCVerifier) ValidateConfiguration(ctx context.Context) error { + if err := v.refresh(ctx, true); err != nil { + return err + } + if v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil { + if _, err := v.introspect(ctx, "identity-configuration-probe"); err != nil { + return err + } + } + return nil +} + func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) { parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"})) unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{}) diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index a2f484b..edd9128 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -70,6 +70,12 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil } +// ValidateConfiguration eagerly checks public-client Discovery metadata. +func (c *OIDCPublicClient) ValidateConfiguration(ctx context.Context) error { + _, _, err := c.configuration(ctx) + return err +} + func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) { if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) { return "", errors.New("state, nonce and PKCE verifier are required") diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index e01e426..19f16df 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -63,6 +63,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T if err != nil { t.Fatal(err) } + if err := client.ValidateConfiguration(context.Background()); err != nil { + t.Fatalf("ValidateConfiguration() error = %v", err) + } authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier) if err != nil { t.Fatal(err) diff --git a/apps/api/internal/auth/oidc_session_cookie_test.go b/apps/api/internal/auth/oidc_session_cookie_test.go index 3167f95..8c596ac 100644 --- a/apps/api/internal/auth/oidc_session_cookie_test.go +++ b/apps/api/internal/auth/oidc_session_cookie_test.go @@ -58,6 +58,29 @@ func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) { } } +func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *testing.T) { + authenticator := New("local-jwt-secret", "", "") + currentUser := &User{ID: "first", Roles: []string{"manager"}} + authenticator.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*User, error) { + return currentUser, nil + } + authenticator.LegacyJWTEnabledProvider = func() bool { return false } + request := httptest.NewRequest(http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "opaque-session-id"}) + user, err := authenticator.Authenticate(request) + if err != nil || user.ID != "first" { + t.Fatalf("first runtime session resolution failed: user=%#v err=%v", user, err) + } + currentUser = &User{ID: "second", Roles: []string{"manager"}} + user, err = authenticator.Authenticate(request) + if err != nil || user.ID != "second" { + t.Fatalf("swapped runtime session resolution failed: user=%#v err=%v", user, err) + } + if authenticator.legacyJWTEnabled() { + t.Fatal("dynamic legacy JWT policy was ignored") + } +} + func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) { authenticator := New("local-jwt-secret", "", "") authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) { diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index da6952d..5a0f637 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -49,6 +49,9 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) { if err != nil { t.Fatal(err) } + if err := verifier.ValidateConfiguration(context.Background()); err != nil { + t.Fatalf("ValidateConfiguration() error = %v", err) + } for _, test := range []struct { name string kid string diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go new file mode 100644 index 0000000..cea5c6c --- /dev/null +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -0,0 +1,96 @@ +package httpapi + +import ( + "context" + "net/url" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" +) + +type oidcTokenVerifier interface { + Verify(context.Context, string) (*auth.User, error) +} + +// identityRequestRuntime is an immutable request-level snapshot. A handler that +// starts with one runtime keeps using it even when an administrator activates a +// new revision while that request is in flight. +type identityRequestRuntime struct { + Revision identity.Revision + Verifier oidcTokenVerifier + PublicClient oidcPublicClient + Sessions oidcSessionManager + SessionCipher *oidcsession.Cipher + SecurityEvents *ssfreceiver.ConnectionManager + CookieSecure bool + BrowserEnabled bool +} + +func (s *Server) currentIdentityRuntime() *identityRequestRuntime { + if s.identityRuntime != nil { + runtime := s.identityRuntime.Current() + if runtime == nil { + return nil + } + return &identityRequestRuntime{ + Revision: runtime.Revision, Verifier: runtime.Verifier, PublicClient: runtime.PublicClient, + Sessions: runtime.Sessions, SessionCipher: runtime.SessionCipher, SecurityEvents: runtime.SecurityEvents, + CookieSecure: runtime.CookieSecure, BrowserEnabled: runtime.PublicClient != nil, + } + } + + // Compatibility path for focused HTTP tests. NewServer never uses these + // static fields after identity revisions are enabled. + if !s.cfg.OIDCEnabled && s.cfg.OIDCIssuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil { + return nil + } + webBaseURL := s.cfg.WebBaseURL + if webBaseURL == "" { + webBaseURL = s.cfg.CORSAllowedOrigin + } + var verifier oidcTokenVerifier + if s.auth != nil { + verifier = s.auth.OIDCVerifier + } + return &identityRequestRuntime{ + Revision: identity.Revision{ + State: identity.RevisionActive, Issuer: s.cfg.OIDCIssuer, LocalTenantKey: s.cfg.OIDCGatewayTenantKey, + WebBaseURL: webBaseURL, PublicBaseURL: s.cfg.PublicBaseURL, + JITEnabled: s.cfg.OIDCJITProvisioningEnabled, LegacyJWTEnabled: s.cfg.OIDCAcceptLegacyHS256, + SessionAbsoluteSeconds: s.cfg.OIDCSessionAbsoluteTTLSeconds, + }, + Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions, + SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager, + CookieSecure: s.cfg.OIDCSessionCookieSecure, + BrowserEnabled: s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled, + } +} + +func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return runtime.SecurityEvents + } + return nil +} + +func (s *Server) oidcCookieSecure() bool { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return runtime.CookieSecure + } + return false +} + +func originMatchesBaseURL(origin, baseURL string) bool { + originURL, err := url.Parse(strings.TrimSpace(origin)) + if err != nil || originURL.User != nil || originURL.Path != "" || originURL.RawQuery != "" || originURL.Fragment != "" { + return false + } + base, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return false + } + return strings.EqualFold(originURL.Scheme, base.Scheme) && strings.EqualFold(originURL.Host, base.Host) +} diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index 0b21a2d..d442980 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -40,7 +40,8 @@ const ( // @Failure 404 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/login [get] func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } @@ -53,13 +54,13 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid) return } - encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction) + encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction) if err != nil { s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err) writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable) return } - authorizationURL, err := s.oidcClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier) + authorizationURL, err := runtime.PublicClient.AuthorizationURL(r.Context(), transaction.State, transaction.Nonce, transaction.PKCEVerifier) if err != nil { s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err) writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE") @@ -68,7 +69,7 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: oidcsession.LoginTransactionCookieName, Value: encoded, Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute), - HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, + HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteLaxMode, }) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, authorizationURL, http.StatusSeeOther) @@ -84,7 +85,8 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/callback [get] func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } @@ -94,7 +96,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing) return } - transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now()) + transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now()) if err != nil { s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid) return @@ -107,22 +109,22 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing) return } - tokens, err := s.oidcClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier) + tokens, err := runtime.PublicClient.ExchangeCode(r.Context(), r.URL.Query().Get("code"), transaction.PKCEVerifier) if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed) return } - identity, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken) + identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken) if err != nil || identity == nil { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed) return } - idSubject, err := s.oidcClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) + idSubject, err := runtime.PublicClient.VerifyIDToken(r.Context(), tokens.IDToken, transaction.Nonce) if err != nil || idSubject != identity.ID { s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed) return } - projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity) + projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision) if err != nil { s.writeOIDCCallbackProjectionError(w, r, err) return @@ -131,7 +133,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user")) return } - rawSession, err := s.oidcSessions.Create(r.Context(), oidcsession.TokenBundle{ + rawSession, err := runtime.Sessions.Create(r.Context(), oidcsession.TokenBundle{ AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken, }, projection.User) if err != nil { @@ -141,12 +143,12 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { now := time.Now() http.SetCookie(w, &http.Cookie{ Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/", - MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second), - HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second), + HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode, }) w.Header().Set("Cache-Control", "no-store") s.recordOIDCSessionAudit(r, projection.User) - http.Redirect(w, r, s.oidcReturnLocation(transaction.ReturnTo), http.StatusSeeOther) + http.Redirect(w, r, oidcReturnLocation(runtime.Revision.WebBaseURL, transaction.ReturnTo), http.StatusSeeOther) } // logoutOIDCSession godoc @@ -158,13 +160,14 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/logout [post] func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { - if !s.oidcBrowserSessionReady() { + runtime := s.currentIdentityRuntime() + if !oidcRuntimeReady(runtime) { writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled) return } var bundle oidcsession.TokenBundle if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { - bundle, err = s.oidcSessions.Delete(r.Context(), cookie.Value) + bundle, err = runtime.Sessions.Delete(r.Context(), cookie.Value) if err != nil { writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) return @@ -172,14 +175,14 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { } s.clearOIDCSessionCookie(w) if bundle.RefreshToken != "" { - if err := s.oidcClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil { + if err := runtime.PublicClient.RevokeRefreshToken(r.Context(), bundle.RefreshToken); err != nil { s.logger.WarnContext(r.Context(), "revoke OIDC refresh token failed", "error", err) } } // Do not put the encrypted-at-rest ID Token into a browser-visible redirect URL. - location, err := s.oidcClient.EndSessionURL(r.Context(), "") + location, err := runtime.PublicClient.EndSessionURL(r.Context(), "") if err != nil { - location = s.cfg.OIDCPostLogoutRedirectURI + location = runtime.Revision.WebBaseURL + "/" } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, location, http.StatusSeeOther) @@ -192,9 +195,10 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) { // @Failure 503 {object} ErrorEnvelope // @Router /api/v1/auth/oidc/session [delete] func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) { - if s.oidcSessions != nil { + runtime := s.currentIdentityRuntime() + if runtime != nil && runtime.Sessions != nil { if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil { - if _, err := s.oidcSessions.Delete(r.Context(), cookie.Value); err != nil { + if _, err := runtime.Sessions.Delete(r.Context(), cookie.Value); err != nil { writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable) return } @@ -206,33 +210,40 @@ func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request } func (s *Server) oidcBrowserSessionReady() bool { - return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil && - s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil + return oidcRuntimeReady(s.currentIdentityRuntime()) +} + +func oidcRuntimeReady(runtime *identityRequestRuntime) bool { + return runtime != nil && runtime.BrowserEnabled && runtime.Verifier != nil && runtime.PublicClient != nil && runtime.Sessions != nil && runtime.SessionCipher != nil } func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(), - Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteLaxMode, + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteLaxMode, }) } func (s *Server) oidcCallbackCookiePath() string { - if parsed, err := url.Parse(strings.TrimSpace(s.cfg.OIDCRedirectURI)); err == nil && strings.HasPrefix(parsed.Path, "/") { - return parsed.Path - } return "/api/v1/auth/oidc/callback" } func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: auth.OIDCSessionCookieName, Value: "", Path: "/", - Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode, + Expires: time.Unix(1, 0), MaxAge: -1, HttpOnly: true, Secure: s.oidcCookieSecure(), SameSite: http.SameSiteStrictMode, }) } func (s *Server) oidcReturnLocation(returnTo string) string { - if base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/"); base != "" { + if runtime := s.currentIdentityRuntime(); runtime != nil { + return oidcReturnLocation(runtime.Revision.WebBaseURL, returnTo) + } + return returnTo +} + +func oidcReturnLocation(webBaseURL, returnTo string) string { + if base := strings.TrimRight(strings.TrimSpace(webBaseURL), "/"); base != "" { return base + returnTo } return returnTo @@ -255,7 +266,10 @@ func (s *Server) writeOIDCLoginTransactionError(w http.ResponseWriter, r *http.R } func (s *Server) writeOIDCCallbackErrorWithDiagnostics(w http.ResponseWriter, r *http.Request, status int, message, code, reason, diagnosticID string) { - base := strings.TrimRight(strings.TrimSpace(s.cfg.WebBaseURL), "/") + base := "" + if runtime := s.currentIdentityRuntime(); runtime != nil { + base = strings.TrimRight(strings.TrimSpace(runtime.Revision.WebBaseURL), "/") + } if parsed, err := url.Parse(base); base != "" && err == nil && parsed.Host != "" && (parsed.Scheme == "https" || parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")) { query := parsed.Query() query.Set("oidcError", code) @@ -320,9 +334,6 @@ func (s *Server) recordOIDCSessionAudit(r *http.Request, user *auth.User) { } func (s *Server) startOIDCSessionCleanup(ctx context.Context) { - if s.oidcSessions == nil { - return - } go func() { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() @@ -331,7 +342,11 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if _, err := s.oidcSessions.Cleanup(ctx); err != nil { + runtime := s.currentIdentityRuntime() + if runtime == nil || runtime.Sessions == nil { + continue + } + if _, err := runtime.Sessions.Cleanup(ctx); err != nil { s.logger.WarnContext(ctx, "cleanup expired OIDC sessions failed", "error", err) } } @@ -341,7 +356,8 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) { func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !s.cfg.OIDCEnabled || !s.cfg.OIDCBrowserSessionEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { + runtime := s.currentIdentityRuntime() + if runtime == nil || !runtime.BrowserEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { next.ServeHTTP(w, r) return } @@ -350,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return } origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" || !originAllowed(origin, s.cfg.CORSAllowedOrigin) { + if origin == "" || !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) { writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF) return } diff --git a/apps/api/internal/httpapi/oidc_user_middleware.go b/apps/api/internal/httpapi/oidc_user_middleware.go index 9203921..2ffa390 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware.go +++ b/apps/api/internal/httpapi/oidc_user_middleware.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -52,17 +53,25 @@ func (s *Server) resolveGatewayUser(next http.Handler) http.Handler { } func (s *Server) resolveOIDCUserProjection(ctx context.Context, r *http.Request, user *auth.User) (store.ResolveOrProvisionOIDCUserResult, error) { + runtime := s.currentIdentityRuntime() + if runtime == nil { + return store.ResolveOrProvisionOIDCUserResult{}, errors.New("active identity runtime is unavailable") + } + return s.resolveOIDCUserProjectionForRevision(ctx, r, user, runtime.Revision) +} + +func (s *Server) resolveOIDCUserProjectionForRevision(ctx context.Context, r *http.Request, user *auth.User, revision identity.Revision) (store.ResolveOrProvisionOIDCUserResult, error) { if s.oidcUserResolver == nil { return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable") } return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{ - Issuer: s.cfg.OIDCIssuer, + Issuer: revision.Issuer, Subject: user.ID, Username: user.Username, Roles: user.Roles, TenantID: user.TenantID, - GatewayTenantKey: s.cfg.OIDCGatewayTenantKey, - ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled, + GatewayTenantKey: revision.LocalTenantKey, + ProvisioningEnabled: revision.JITEnabled, RequestIP: limitAuditText(requestIP(r), 128), UserAgent: limitAuditText(r.UserAgent(), 512), }) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index 4e75115..b82b890 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -31,11 +31,12 @@ type securityEventConnectionResponse struct { func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) { ensureSecurityEventTraceID(w, r) w.Header().Set("Cache-Control", "no-store") - if s.securityEventManager == nil { + manager := s.currentSecurityEventManager() + if manager == nil { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) return } - connection, err := s.securityEventManager.Get(r.Context()) + connection, err := manager.Get(r.Context()) if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()}) return @@ -50,7 +51,8 @@ func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Reque func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - if s.securityEventManager == nil { + manager := s.currentSecurityEventManager() + if manager == nil { writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing") return } @@ -79,12 +81,12 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) { return } - if current, err := s.securityEventManager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { + if current, err := manager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { return } - connection, err := s.securityEventManager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) + connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) if err != nil { - s.writeSecurityEventConnectionError(w, r, "connect", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err) return } s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) @@ -92,13 +94,14 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "verify", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID) if !ok { return } - connection, err := s.securityEventManager.Verify(r.Context()) + connection, err := manager.Verify(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "verify", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err) return } s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) @@ -106,13 +109,14 @@ func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Re func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "rotate", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID) if !ok { return } - connection, err := s.securityEventManager.RotateCredential(r.Context()) + connection, err := manager.RotateCredential(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err) return } s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) @@ -120,25 +124,34 @@ func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, "disconnect", traceID) + manager := s.currentSecurityEventManager() + idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID) if !ok { return } - connection, err := s.securityEventManager.Disconnect(r.Context()) + connection, err := manager.Disconnect(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err) return } s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection) } func (s *Server) securityEventPrerequisites() map[string]any { + runtime := s.currentIdentityRuntime() + if runtime == nil { + return map[string]any{ + "oidcConfigured": false, "introspectionClientConfigured": false, + "publicBaseUrlConfigured": false, "managementClientId": "", "credentialInputSupported": false, + } + } + revision := runtime.Revision return map[string]any{ - "oidcConfigured": s.cfg.OIDCEnabled && s.cfg.OIDCIssuer != "" && s.cfg.OIDCTenantID != "", - "introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "", - "publicBaseUrlConfigured": s.cfg.PublicBaseURL != "", - "managementClientId": s.cfg.OIDCIntrospectionClientID, - "credentialInputSupported": true, + "oidcConfigured": revision.Issuer != "" && revision.TenantID != "", + "introspectionClientConfigured": revision.MachineClientID != "" && revision.MachineCredentialRef != "", + "publicBaseUrlConfigured": revision.PublicBaseURL != "", + "managementClientId": revision.MachineClientID, + "credentialInputSupported": false, } } @@ -151,8 +164,8 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s return value, true } -func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, traceID string) (string, string, bool) { - if s.securityEventManager == nil { +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) { + if manager == nil { writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") return "", "", false } @@ -164,9 +177,9 @@ func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Requ if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) { return "", "", false } - connection, err := s.securityEventManager.Get(r.Context()) + connection, err := manager.Get(r.Context()) if err != nil { - s.writeSecurityEventConnectionError(w, r, operation, traceID, err) + s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err) return "", "", false } return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) @@ -247,11 +260,11 @@ func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int return true } -func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, operation, traceID string, err error) { +func (s *Server) writeSecurityEventConnectionError(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string, err error) { status, message, code := securityEventConnectionErrorProjection(err) connection := ssfreceiver.ConnectionView{} - if s.securityEventManager != nil { - connection, _ = s.securityEventManager.Get(r.Context()) + if manager != nil { + connection, _ = manager.Get(r.Context()) } errorCategory := code if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 26e37df..fd4d7e5 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -11,6 +11,8 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime" "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" "github.com/easyai/easyai-ai-gateway/apps/api/internal/runner" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" @@ -31,6 +33,8 @@ type Server struct { geminiUploadSessions sync.Map securityEventReceiver http.Handler securityEventManager *ssfreceiver.ConnectionManager + identityRuntime *identityruntime.Manager + identityPairing *identity.PairingService } type oidcPublicClient interface { @@ -63,83 +67,43 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor runner: runner.New(cfg, db, logger), logger: logger, } - server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256 server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret securityEventMetrics := &ssfreceiver.Metrics{} - if cfg.OIDCEnabled { - secretStore, err := securityEventSecretStore(cfg) - if err != nil { - panic("invalid OIDC security event secret store: " + err.Error()) - } - manager, err := ssfreceiver.NewConnectionManager(ctx, db, secretStore, ssfreceiver.ConnectionManagerConfig{ - AppEnv: cfg.AppEnv, OIDCEnabled: cfg.OIDCEnabled, OIDCIssuer: cfg.OIDCIssuer, OIDCTenantID: cfg.OIDCTenantID, - ManagementClientID: cfg.OIDCIntrospectionClientID, ManagementClientSecret: cfg.OIDCIntrospectionClientSecret, - PublicBaseURL: cfg.PublicBaseURL, - HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, - StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, - }, securityEventMetrics) - if err != nil { - panic("initialize OIDC security event connection manager: " + err.Error()) - } - server.securityEventManager = manager - server.securityEventReceiver = manager + secretStore, err := securityEventSecretStore(cfg) + if err != nil { + panic("invalid identity SecretStore: " + err.Error()) } - if cfg.OIDCEnabled { - var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) - if server.securityEventManager != nil { - evaluator = server.securityEventManager.Evaluate + runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ + AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, + HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, + StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, + ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + }, securityEventMetrics) + server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder) + if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil { + logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed") + } + server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) { + return identity.NewOnboardingClient(baseURL, nil) + }, server.identityRuntime) + server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier { + if runtime := server.identityRuntime.Current(); runtime != nil { + return runtime.Verifier } - verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ - Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID, - RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes, - JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second, - IntrospectionEnabled: cfg.OIDCIntrospectionEnabled, - IntrospectionClientID: cfg.OIDCIntrospectionClientID, - IntrospectionClientSecret: cfg.OIDCIntrospectionClientSecret, - IntrospectionCredentialProvider: server.securityEventManager.IntrospectionCredential, - SecurityEventEvaluator: evaluator, - IntrospectionObserver: securityEventMetrics.ObserveIntrospection, - JWKSRefreshFailureObserver: func() { securityEventMetrics.ObserveJWKSRefreshFailure("oidc") }, - }) - if err != nil { - panic("invalid OIDC configuration: " + err.Error()) - } - server.auth.OIDCVerifier = verifier - if cfg.OIDCBrowserSessionEnabled { - key, err := cfg.OIDCSessionEncryptionKeyBytes() - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - cipher, err := oidcsession.NewCipher(key) - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ - Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, RedirectURI: cfg.OIDCRedirectURI, - PostLogoutRedirectURI: cfg.OIDCPostLogoutRedirectURI, - Scopes: append([]string{"openid", "profile"}, cfg.OIDCRequiredScopes...), - }) - if err != nil { - panic("invalid OIDC public client configuration: " + err.Error()) - } - sessions, err := oidcsession.NewService(db, cipher, verifier, client, oidcsession.Config{ - IdleTTL: time.Duration(cfg.OIDCSessionIdleTTLSeconds) * time.Second, - AbsoluteTTL: time.Duration(cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second, - RefreshBefore: time.Duration(cfg.OIDCSessionRefreshBeforeSeconds) * time.Second, - }) - if err != nil { - panic("invalid OIDC session configuration: " + err.Error()) - } - server.oidcClient = client - server.oidcSessions = sessions - server.oidcSessionCipher = cipher - server.auth.OIDCSessionResolver = func(ctx context.Context, sessionID string) (*auth.User, error) { - user, resolveErr := sessions.Resolve(ctx, sessionID) - return user, oidcSessionRequestError(resolveErr) - } + return nil + } + server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) { + runtime := server.identityRuntime.Current() + if runtime == nil || runtime.Sessions == nil { + return nil, auth.ErrUnauthorized } + user, resolveErr := runtime.Sessions.Resolve(ctx, sessionID) + return user, oidcSessionRequestError(resolveErr) + } + server.auth.LegacyJWTEnabledProvider = func() bool { + runtime := server.identityRuntime.Current() + return runtime == nil || runtime.Revision.LegacyJWTEnabled } server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey server.runner.StartAsyncQueueWorker(ctx) diff --git a/apps/api/internal/identityruntime/builder.go b/apps/api/internal/identityruntime/builder.go new file mode 100644 index 0000000..71374d9 --- /dev/null +++ b/apps/api/internal/identityruntime/builder.go @@ -0,0 +1,209 @@ +package identityruntime + +import ( + "context" + "errors" + "net/url" + "slices" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type RuntimeBuilderConfig struct { + AppEnv string + JWKSCacheTTL time.Duration + HeartbeatInterval time.Duration + StaleAfter time.Duration + ClockSkew time.Duration +} + +type preparedSecurityRuntime struct { + manager *securityevents.ConnectionManager + cancel context.CancelFunc +} + +type RuntimeBuilder struct { + ctx context.Context + store *store.Store + secrets PairingSecretStore + config RuntimeBuilderConfig + metrics *securityevents.Metrics + mutex sync.Mutex + prepared map[string]preparedSecurityRuntime +} + +type PairingSecretStore interface { + Put(context.Context, string, []byte) error + Get(context.Context, string) ([]byte, error) + Delete(context.Context, string) error +} + +func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSecretStore, config RuntimeBuilderConfig, metrics *securityevents.Metrics) *RuntimeBuilder { + if metrics == nil { + metrics = &securityevents.Metrics{} + } + if config.JWKSCacheTTL <= 0 { + config.JWKSCacheTTL = 5 * time.Minute + } + return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}} +} + +func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) { + if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" || + revision.Audience == "" || revision.RolePrefix == "" { + return nil, errors.New("identity runtime configuration is incomplete") + } + if exists, err := builder.store.HasActiveTenantKey(ctx, revision.LocalTenantKey); err != nil { + return nil, err + } else if !exists { + return nil, identity.ErrLocalTenantInvalid + } + runtimeCtx, cancel := context.WithCancel(builder.ctx) + runtime := &Runtime{Revision: revision, CookieSecure: secureCookieFor(revision.PublicBaseURL), close: cancel} + + var securityManager *securityevents.ConnectionManager + if revision.SessionRevocation { + prepared := builder.takePreparedSecurityRuntime(revision.ID) + securityManager = prepared.manager + if prepared.cancel != nil { + runtime.close = func() { + cancel() + prepared.cancel() + } + } + if securityManager == nil { + var err error + securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision) + if err != nil { + cancel() + return nil, err + } + } + runtime.SecurityEvents = securityManager + } + + var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) + if securityManager != nil { + evaluator = securityManager.Evaluate + } + credentialProvider := func(ctx context.Context) (string, []byte, error) { + if revision.MachineClientID == "" || revision.MachineCredentialRef == "" { + return "", nil, errors.New("managed machine credential is unavailable") + } + secret, err := builder.secrets.Get(ctx, revision.MachineCredentialRef) + return revision.MachineClientID, secret, err + } + verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ + Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID, + RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...), + JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection, + IntrospectionCredentialProvider: credentialProvider, SecurityEventEvaluator: evaluator, + IntrospectionObserver: builder.metrics.ObserveIntrospection, + JWKSRefreshFailureObserver: func() { builder.metrics.ObserveJWKSRefreshFailure("oidc") }, + }) + if err != nil { + cancel() + return nil, err + } + if err := verifier.ValidateConfiguration(ctx); err != nil { + cancel() + return nil, err + } + runtime.Verifier = verifier + + if slices.Contains(revision.Capabilities, "oidc_login") { + if revision.BrowserClientID == "" || revision.SessionEncryptionKeyRef == "" { + cancel() + return nil, errors.New("OIDC browser session configuration is incomplete") + } + key, err := builder.secrets.Get(ctx, revision.SessionEncryptionKeyRef) + if err != nil { + cancel() + return nil, err + } + cipher, err := oidcsession.NewCipher(key) + clear(key) + if err != nil { + cancel() + return nil, err + } + client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ + Issuer: revision.Issuer, ClientID: revision.BrowserClientID, + RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback", + PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...), + }) + if err != nil { + cancel() + return nil, err + } + if err := client.ValidateConfiguration(ctx); err != nil { + cancel() + return nil, err + } + sessions, err := oidcsession.NewService(builder.store, cipher, verifier, client, oidcsession.Config{ + IdleTTL: time.Duration(revision.SessionIdleSeconds) * time.Second, + AbsoluteTTL: time.Duration(revision.SessionAbsoluteSeconds) * time.Second, + RefreshBefore: time.Duration(revision.SessionRefreshSeconds) * time.Second, + }) + if err != nil { + cancel() + return nil, err + } + runtime.PublicClient, runtime.Sessions, runtime.SessionCipher = client, sessions, cipher + } + return runtime, nil +} + +func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error { + if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.MachineClientID == "" { + return errors.New("security event configuration is incomplete") + } + runtimeCtx, cancel := context.WithCancel(builder.ctx) + manager, err := builder.newSecurityEventManager(runtimeCtx, revision) + if err != nil { + cancel() + return err + } + secretCopy := append([]byte(nil), managementSecret...) + _, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID) + clear(secretCopy) + if err != nil { + cancel() + return err + } + builder.mutex.Lock() + previous := builder.prepared[revision.ID] + builder.prepared[revision.ID] = preparedSecurityRuntime{manager: manager, cancel: cancel} + builder.mutex.Unlock() + if previous.cancel != nil { + previous.cancel() + } + return nil +} + +func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision) (*securityevents.ConnectionManager, error) { + return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, securityevents.ConnectionManagerConfig{ + AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID, + ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL, + HeartbeatInterval: builder.config.HeartbeatInterval, StaleAfter: builder.config.StaleAfter, ClockSkew: builder.config.ClockSkew, + }, builder.metrics) +} + +func (builder *RuntimeBuilder) takePreparedSecurityRuntime(revisionID string) preparedSecurityRuntime { + builder.mutex.Lock() + defer builder.mutex.Unlock() + prepared := builder.prepared[revisionID] + delete(builder.prepared, revisionID) + return prepared +} + +func secureCookieFor(baseURL string) bool { + parsed, err := url.Parse(baseURL) + return err == nil && parsed.Scheme == "https" +} diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go new file mode 100644 index 0000000..0d1d2af --- /dev/null +++ b/apps/api/internal/identityruntime/manager.go @@ -0,0 +1,151 @@ +package identityruntime + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" +) + +type Repository interface { + IdentityConfigurationRevision(context.Context, string) (identity.Revision, error) + ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) + MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error) + MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error) + ActivateIdentityRevision(context.Context, string, int64) (identity.Revision, bool, error) + DisableActiveIdentityRevision(context.Context, int64) (identity.Revision, error) +} + +type Builder interface { + Build(context.Context, identity.Revision) (*Runtime, error) +} + +type Runtime struct { + Revision identity.Revision + Verifier *auth.OIDCVerifier + PublicClient *auth.OIDCPublicClient + Sessions *oidcsession.Service + SessionCipher *oidcsession.Cipher + SecurityEvents *securityevents.ConnectionManager + CookieSecure bool + close func() +} + +func (runtime *Runtime) Close() { + if runtime != nil && runtime.close != nil { + runtime.close() + } +} + +type Manager struct { + repository Repository + builder Builder + operation sync.Mutex + current atomic.Pointer[Runtime] +} + +func NewManager(repository Repository, builder Builder) *Manager { + return &Manager{repository: repository, builder: builder} +} + +func (manager *Manager) Current() *Runtime { + return manager.current.Load() +} + +func (manager *Manager) LoadActive(ctx context.Context) error { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx) + if errors.Is(err, identity.ErrRevisionNotFound) { + return nil + } + if err != nil { + return err + } + runtime, err := manager.builder.Build(ctx, revision) + if err != nil { + return err + } + runtime.Revision = revision + manager.current.Store(runtime) + return nil +} + +func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded { + return identity.Revision{}, identity.ErrRevisionConflict + } + candidate, buildErr := manager.builder.Build(ctx, revision) + if buildErr != nil { + _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + return identity.Revision{}, buildErr + } + candidate.Close() + return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) +} + +func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if revision.Version != expectedVersion || revision.State != identity.RevisionValidated { + return identity.Revision{}, identity.ErrRevisionConflict + } + candidate, err := manager.builder.Build(ctx, revision) + if err != nil { + return identity.Revision{}, err + } + activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + candidate.Revision = activated + old := manager.current.Swap(candidate) + retireRuntime(old) + return activated, nil +} + +func (manager *Manager) Disable(ctx context.Context, expectedVersion int64) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion) + if err != nil { + return identity.Revision{}, err + } + old := manager.current.Swap(nil) + retireRuntime(old) + return disabled, nil +} + +func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error { + preparer, ok := manager.builder.(interface { + PrepareSecurityEvents(context.Context, identity.Revision, []byte) error + }) + if !ok { + return errors.New("security event runtime preparation is unavailable") + } + return preparer.PrepareSecurityEvents(ctx, revision, secret) +} + +func retireRuntime(runtime *Runtime) { + if runtime == nil || runtime.close == nil { + return + } + time.AfterFunc(30*time.Second, runtime.Close) +} diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go new file mode 100644 index 0000000..3494d70 --- /dev/null +++ b/apps/api/internal/identityruntime/manager_test.go @@ -0,0 +1,120 @@ +package identityruntime + +import ( + "context" + "errors" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +type runtimeRepositoryFake struct { + revisions map[string]identity.Revision + active identity.Revision + activateCalled bool +} + +func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) { + revision, ok := f.revisions[id] + if !ok { + return identity.Revision{}, identity.ErrRevisionNotFound + } + return revision, nil +} +func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) { + if f.active.ID == "" { + return identity.Revision{}, identity.ErrRevisionNotFound + } + return f.active, nil +} +func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.State, revision.Version = identity.RevisionValidated, revision.Version+1 + f.revisions[id] = revision + return revision, nil +} +func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.State, revision.Version, revision.LastErrorCategory = identity.RevisionFailed, revision.Version+1, category + f.revisions[id] = revision + return revision, nil +} +func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64) (identity.Revision, bool, error) { + f.activateCalled = true + revision := f.revisions[id] + if revision.Version != expected || revision.State != identity.RevisionValidated { + return identity.Revision{}, false, identity.ErrRevisionConflict + } + if f.active.ID != "" { + old := f.active + old.State = identity.RevisionSuperseded + f.revisions[old.ID] = old + } + revision.State, revision.Version = identity.RevisionActive, revision.Version+1 + f.active, f.revisions[id] = revision, revision + return revision, true, nil +} +func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64) (identity.Revision, error) { + if f.active.Version != expected { + return identity.Revision{}, identity.ErrRevisionConflict + } + disabled := f.active + disabled.State, disabled.Version = identity.RevisionSuperseded, disabled.Version+1 + f.revisions[disabled.ID] = disabled + f.active = identity.Revision{} + return disabled, nil +} + +type runtimeBuilderFake struct { + err error + builtIDs []string +} + +func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) { + f.builtIDs = append(f.builtIDs, revision.ID) + if f.err != nil { + return nil, f.err + } + return &Runtime{Revision: revision}, nil +} + +func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active, "draft": draft}, active: active} + builder := &runtimeBuilderFake{err: errors.New("discovery failed")} + manager := NewManager(repository, builder) + manager.current.Store(&Runtime{Revision: active}) + + if _, err := manager.Validate(context.Background(), draft.ID, draft.Version, "trace", "audit"); err == nil { + t.Fatal("validation failure was ignored") + } + if manager.Current().Revision.ID != active.ID || repository.activateCalled { + t.Fatal("validation failure changed current runtime or activated the draft") + } + if repository.revisions[draft.ID].State != identity.RevisionFailed { + t.Fatal("failed draft was not marked failed") + } +} + +func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { + active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"old": active, "new": candidate}, active: active} + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active}) + + activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version) + if err != nil { + t.Fatal(err) + } + if !repository.activateCalled || activated.State != identity.RevisionActive || manager.Current().Revision.ID != candidate.ID { + t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current()) + } +} From 1fce3a535d95fb4f66efffbd0eb62a35e086d616 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:10:44 +0800 Subject: [PATCH 13/20] =?UTF-8?q?feat(identity):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E7=AE=A1=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提供接入码配对、配置查询、本地策略修改、验证、激活、回滚、禁用和公开运行时状态接口。写操作强制 Idempotency-Key 与 If-Match,后台 Exchange 支持重启恢复和过期收敛,并记录脱敏 Trace 与审计。\n\n验证:go test 相关 identity、store、identityruntime、httpapi 包;go vet ./apps/api/... --- .../identity_configuration_handlers.go | 491 ++++++++++++++++++ .../identity_configuration_handlers_test.go | 33 ++ apps/api/internal/httpapi/server.go | 52 +- apps/api/internal/identity/configuration.go | 21 + apps/api/internal/identity/pairing_service.go | 9 + .../internal/identity/pairing_service_test.go | 23 + apps/api/internal/identityruntime/manager.go | 42 +- .../internal/identityruntime/manager_test.go | 28 +- .../internal/store/identity_configurations.go | 73 ++- apps/api/internal/store/identity_pairing.go | 28 + 10 files changed, 767 insertions(+), 33 deletions(-) create mode 100644 apps/api/internal/httpapi/identity_configuration_handlers.go create mode 100644 apps/api/internal/httpapi/identity_configuration_handlers_test.go diff --git a/apps/api/internal/httpapi/identity_configuration_handlers.go b/apps/api/internal/httpapi/identity_configuration_handlers.go new file mode 100644 index 0000000..aedf60a --- /dev/null +++ b/apps/api/internal/httpapi/identity_configuration_handlers.go @@ -0,0 +1,491 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type identityConfigurationView struct { + Active *identity.Revision `json:"active"` + Draft *identity.Revision `json:"draft"` + Previous *identity.Revision `json:"previous"` + Pairing *identity.PairingExchange `json:"pairing,omitempty"` + Runtime identityRuntimeStatus `json:"runtime"` +} + +type identityRuntimeStatus struct { + Enabled bool `json:"enabled"` + RevisionID string `json:"revisionId,omitempty"` + Login string `json:"login"` + JIT string `json:"jit"` + TokenIntrospection string `json:"tokenIntrospection"` + SessionRevocation string `json:"sessionRevocation"` + LastTraceID string `json:"lastTraceId,omitempty"` + LastAuditID string `json:"lastAuditId,omitempty"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` +} + +type publicIdentityConfiguration struct { + Enabled bool `json:"enabled"` + OIDCLogin bool `json:"oidcLogin"` + LoginURL string `json:"loginUrl,omitempty"` + LogoutURL string `json:"logoutUrl,omitempty"` + Status string `json:"status"` +} + +type identityPolicyPatch struct { + LocalTenantKey *string `json:"localTenantKey"` + RolePrefix *string `json:"rolePrefix"` + JITEnabled *bool `json:"jitEnabled"` + LegacyJWTEnabled *bool `json:"legacyJwtEnabled"` + SessionIdleSeconds *int `json:"sessionIdleSeconds"` + SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"` + SessionRefreshSeconds *int `json:"sessionRefreshSeconds"` +} + +type storedIdentityResponse struct { + Status int `json:"status"` + Body json.RawMessage `json:"body"` + ETag string `json:"etag,omitempty"` + AuditID string `json:"auditId,omitempty"` +} + +type identityWriteOperation struct { + operation, key, requestHash string + release func() + once sync.Once +} + +func (operation *identityWriteOperation) close() { + if operation != nil { + operation.once.Do(operation.release) + } +} + +func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Cache-Control", "no-store") + runtime := s.currentIdentityRuntime() + if runtime == nil || runtime.Revision.State != identity.RevisionActive { + writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"}) + return + } + view := publicIdentityConfiguration{Enabled: true, Status: "active"} + if oidcRuntimeReady(runtime) { + view.OIDCLogin = true + view.LoginURL = "/api/v1/auth/oidc/login" + view.LogoutURL = "/api/v1/auth/oidc/logout" + } + writeJSON(w, http.StatusOK, view) +} + +func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)} + if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil { + view.Active = &revision + } else if !errors.Is(err, identity.ErrRevisionNotFound) { + writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") + return + } + if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil { + view.Draft = &revision + if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil { + view.Pairing = &pairing + } + } else if !errors.Is(err, identity.ErrRevisionNotFound) { + writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") + return + } + if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil { + view.Previous = &revision + } else if !errors.Is(err, identity.ErrRevisionNotFound) { + writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE") + return + } + writeJSON(w, http.StatusOK, view) +} + +func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) { + var input identity.PairingInput + if !decodeIdentityRequest(w, r, &input) { + return + } + operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input) + if !ok { + return + } + defer operation.close() + traceID := ensureIdentityTraceID(w, r) + pairing, err := s.identityPairing.Start(r.Context(), input, traceID) + if err != nil { + s.writeIdentityError(w, r, "pairing.start", "", traceID, err) + return + } + auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "") + pairing.AuthCenterAuditID = "" + s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID) + s.startIdentityPairingWorker(pairing.ID) +} + +func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID")) + if err != nil { + s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err) + return + } + w.Header().Set("ETag", identityETag(pairing.Version)) + writeJSON(w, http.StatusOK, pairing) +} + +func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) { + var patch identityPolicyPatch + if !decodeIdentityRequest(w, r, &patch) { + return + } + expectedVersion, ok := requiredIdentityVersion(w, r) + if !ok { + return + } + operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch) + if !ok { + return + } + defer operation.close() + revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID")) + if err != nil || revision.Version != expectedVersion { + s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict)) + return + } + policy := identity.RevisionPolicy{ + LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled, + LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds, + SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds, + } + applyIdentityPolicyPatch(&policy, patch) + updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy) + if err != nil { + s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err) + return + } + traceID := ensureIdentityTraceID(w, r) + auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "") + s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID) +} + +func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) { + s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) { + return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) + }) +} + +func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) { + s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) { + return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) + }) +} + +func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) { + s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) { + return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) + }) +} + +func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) { + expectedVersion, ok := requiredIdentityVersion(w, r) + if !ok { + return + } + operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{}) + if !ok { + return + } + defer operation.close() + traceID := ensureIdentityTraceID(w, r) + auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "") + disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID) + if err != nil { + s.writeIdentityError(w, r, "revision.disable", "active", traceID, err) + return + } + s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID) +} + +func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) { + expectedVersion, ok := requiredIdentityVersion(w, r) + if !ok { + return + } + operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{}) + if !ok { + return + } + defer operation.close() + traceID := ensureIdentityTraceID(w, r) + auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "") + revision, err := run(expectedVersion, traceID, auditID) + if err != nil { + s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err) + return + } + s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID) +} + +func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus { + runtime := s.currentIdentityRuntime() + if runtime == nil { + return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"} + } + revision := runtime.Revision + status := identityRuntimeStatus{ + Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil), + JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection), + SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID, + LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory, + } + if revision.SessionRevocation && runtime.SecurityEvents != nil { + if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" { + status.SessionRevocation = connection.HealthMode + } + } + return status +} + +func capabilityHealth(enabled bool) string { + if enabled { + return "healthy" + } + return "disabled" +} + +func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST") + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST") + return false + } + return true +} + +func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) { + if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version { + if ok { + writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT") + } + return nil, false + } + return s.beginIdentityWriteWithVersion(w, r, operation, version, request) +} + +func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) { + key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if key == "" || len(key) > 255 { + writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED") + return nil, false + } + encoded, _ := json.Marshal(request) + digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...)) + requestHash := fmt.Sprintf("%x", digest[:]) + s.identityManagementMu.Lock() + write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock} + if s.store == nil { + return write, true + } + recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key) + if errors.Is(err, store.ErrIdentityManagementRequestNotFound) { + return write, true + } + if err != nil { + write.close() + writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE") + return nil, false + } + if recorded.RequestHash != requestHash { + write.close() + writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED") + return nil, false + } + var response storedIdentityResponse + if json.Unmarshal(recorded.Response, &response) != nil { + write.close() + writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE") + return nil, false + } + write.close() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Idempotent-Replayed", "true") + if response.ETag != "" { + w.Header().Set("ETag", response.ETag) + } + if response.AuditID != "" { + w.Header().Set("X-Audit-Id", response.AuditID) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.Status) + _, _ = w.Write(response.Body) + return nil, false +} + +func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) { + body, _ := json.Marshal(payload) + stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID}) + if s.store != nil { + if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{ + Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored, + }); err != nil && s.logger != nil { + s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed") + } + } + operation.close() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", identityETag(version)) + if auditID != "" { + w.Header().Set("X-Audit-Id", auditID) + } + writeJSON(w, status, payload) +} + +func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { + value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`) + version, err := strconv.ParseInt(value, 10, 64) + if err != nil || version < 0 { + writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED") + return 0, false + } + return version, true +} + +func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) } + +func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) { + if patch.LocalTenantKey != nil { + policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey) + } + if patch.RolePrefix != nil { + policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix) + } + if patch.JITEnabled != nil { + policy.JITEnabled = *patch.JITEnabled + } + if patch.LegacyJWTEnabled != nil { + policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled + } + if patch.SessionIdleSeconds != nil { + policy.SessionIdleSeconds = *patch.SessionIdleSeconds + } + if patch.SessionAbsoluteSeconds != nil { + policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds + } + if patch.SessionRefreshSeconds != nil { + policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds + } +} + +func firstIdentityError(actual, fallback error) error { + if actual != nil { + return actual + } + return fallback +} + +func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string { + return ensureSecurityEventTraceID(w, r) +} + +func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) { + status, message, code := identityErrorProjection(err) + s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code) + writeError(w, status, message, code) +} + +func identityErrorProjection(err error) (int, string, string) { + switch { + case errors.Is(err, identity.ErrRevisionNotFound): + return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND" + case errors.Is(err, identity.ErrRevisionConflict): + return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT" + case errors.Is(err, identity.ErrBreakGlassRequired): + return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED" + case errors.Is(err, identity.ErrLocalTenantInvalid): + return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID" + case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")): + return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID" + default: + return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE" + } +} + +func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string { + if s.store == nil { + return "" + } + actor, _ := auth.UserFromContext(r.Context()) + input := store.AuditLogInput{ + Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision", + TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128), + UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{ + "outcome": outcome, "traceId": traceID, "errorCategory": errorCategory, + }, + } + if actor != nil { + input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID)) + input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles + } + audit, err := s.store.RecordAuditLog(r.Context(), input) + if err != nil { + if s.logger != nil { + s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID) + } + return "" + } + return audit.ID +} + +func (s *Server) startIdentityPairingWorker(pairingID string) { + if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded { + return + } + go func() { + defer s.identityPairingWorkers.Delete(pairingID) + delay := time.Second + for { + pairing, err := s.identityPairing.Continue(s.ctx, pairingID) + if err == nil { + if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired { + return + } + delay = time.Second + } else { + if s.logger != nil { + s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed") + } + if delay < 15*time.Second { + delay *= 2 + } + } + select { + case <-s.ctx.Done(): + return + case <-time.After(delay): + } + } + }() +} diff --git a/apps/api/internal/httpapi/identity_configuration_handlers_test.go b/apps/api/internal/httpapi/identity_configuration_handlers_test.go new file mode 100644 index 0000000..4dc8a23 --- /dev/null +++ b/apps/api/internal/httpapi/identity_configuration_handlers_test.go @@ -0,0 +1,33 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequiredIdentityWriteHeaders(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/identity", nil) + recorder := httptest.NewRecorder() + if _, ok := requiredIdentityVersion(recorder, request); ok || recorder.Code != http.StatusPreconditionRequired { + t.Fatalf("missing If-Match status=%d", recorder.Code) + } + + request.Header.Set("If-Match", `W/"7"`) + recorder = httptest.NewRecorder() + version, ok := requiredIdentityVersion(recorder, request) + if !ok || version != 7 { + t.Fatalf("weak ETag version=%d ok=%v", version, ok) + } +} + +func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) { + status, message, code := identityErrorProjection(assertionError("upstream response contained secret-token")) + if status != http.StatusBadGateway || code != "IDENTITY_SERVICE_UNAVAILABLE" || message == "upstream response contained secret-token" { + t.Fatalf("unsafe error projection status=%d code=%q message=%q", status, code, message) + } +} + +type assertionError string + +func (err assertionError) Error() string { return string(err) } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index fd4d7e5..24d4389 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -20,21 +20,23 @@ import ( ) type Server struct { - ctx context.Context - cfg config.Config - store *store.Store - oidcUserResolver oidcUserResolver - auth *auth.Authenticator - oidcClient oidcPublicClient - oidcSessions oidcSessionManager - oidcSessionCipher *oidcsession.Cipher - runner *runner.Service - logger *slog.Logger - geminiUploadSessions sync.Map - securityEventReceiver http.Handler - securityEventManager *ssfreceiver.ConnectionManager - identityRuntime *identityruntime.Manager - identityPairing *identity.PairingService + ctx context.Context + cfg config.Config + store *store.Store + oidcUserResolver oidcUserResolver + auth *auth.Authenticator + oidcClient oidcPublicClient + oidcSessions oidcSessionManager + oidcSessionCipher *oidcsession.Cipher + runner *runner.Service + logger *slog.Logger + geminiUploadSessions sync.Map + securityEventReceiver http.Handler + securityEventManager *ssfreceiver.ConnectionManager + identityRuntime *identityruntime.Manager + identityPairing *identity.PairingService + identityManagementMu sync.Mutex + identityPairingWorkers sync.Map } type oidcPublicClient interface { @@ -77,8 +79,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, - StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, + ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, }, securityEventMetrics) server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder) if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil { @@ -87,6 +89,13 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) { return identity.NewOnboardingClient(baseURL, nil) }, server.identityRuntime) + if pending, pendingErr := db.PendingIdentityPairingExchanges(ctx); pendingErr == nil { + for _, pairing := range pending { + server.startIdentityPairingWorker(pairing.ID) + } + } else if logger != nil { + logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed") + } server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier { if runtime := server.identityRuntime.Current(); runtime != nil { return runtime.Verifier @@ -124,6 +133,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin) mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession) mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession) + mux.HandleFunc("GET /api/v1/public/identity", server.getPublicIdentityConfiguration) mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent) mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me))) mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders))) @@ -194,6 +204,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings))) mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings))) mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings))) + mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration))) + mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing))) + mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing))) + mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy))) + mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision))) + mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision))) + mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/rollback", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rollbackIdentityRevision))) + mux.Handle("POST /api/admin/system/identity/disable", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.disableIdentityConfiguration))) mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection))) mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection))) mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection))) diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index a43f78e..f3eb4ad 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -17,6 +17,27 @@ var ( ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid") ) +type RevisionPolicy struct { + LocalTenantKey string `json:"localTenantKey"` + RolePrefix string `json:"rolePrefix"` + JITEnabled bool `json:"jitEnabled"` + LegacyJWTEnabled bool `json:"legacyJwtEnabled"` + SessionIdleSeconds int `json:"sessionIdleSeconds"` + SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"` + SessionRefreshSeconds int `json:"sessionRefreshSeconds"` +} + +func (policy RevisionPolicy) Validate() error { + if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" { + return errors.New("local tenant mapping and role prefix are required") + } + if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds || + policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds { + return errors.New("identity session policy is invalid") + } + return nil +} + type RevisionState string const ( diff --git a/apps/api/internal/identity/pairing_service.go b/apps/api/internal/identity/pairing_service.go index 7a63c73..7ddc79c 100644 --- a/apps/api/internal/identity/pairing_service.go +++ b/apps/api/internal/identity/pairing_service.go @@ -129,6 +129,15 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired { return pairing, nil } + if !pairing.ExpiresAt.After(time.Now()) { + expired, updateErr := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ + Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired", + }) + if updateErr == nil { + _ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef) + } + return expired, updateErr + } revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) if err != nil { return PairingExchange{}, err diff --git a/apps/api/internal/identity/pairing_service_test.go b/apps/api/internal/identity/pairing_service_test.go index f8823c8..dd73124 100644 --- a/apps/api/internal/identity/pairing_service_test.go +++ b/apps/api/internal/identity/pairing_service_test.go @@ -154,3 +154,26 @@ func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision) } } + +func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) { + repository := &pairingRepositoryFake{exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing", + Status: PairingPreparing, RemoteVersion: 2, ExpiresAt: time.Now().Add(-time.Minute), Version: 4, + }} + secrets := &secretStoreFake{values: map[string][]byte{"identity-exchange-pairing": []byte("temporary-token")}} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { + t.Fatal("expired exchange must not call the remote service") + return nil, nil + }, nil) + + expired, err := service.Continue(context.Background(), "pairing") + if err != nil { + t.Fatal(err) + } + if expired.Status != PairingExpired || expired.LastErrorCategory != "exchange_expired" { + t.Fatalf("expired pairing was not persisted: %#v", expired) + } + if _, exists := secrets.values["identity-exchange-pairing"]; exists { + t.Fatal("expired exchange token remained in SecretStore") + } +} diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go index 0d1d2af..5a9cb46 100644 --- a/apps/api/internal/identityruntime/manager.go +++ b/apps/api/internal/identityruntime/manager.go @@ -18,8 +18,8 @@ type Repository interface { ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error) MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error) - ActivateIdentityRevision(context.Context, string, int64) (identity.Revision, bool, error) - DisableActiveIdentityRevision(context.Context, int64) (identity.Revision, error) + ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error) + DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error) } type Builder interface { @@ -96,7 +96,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) } -func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64) (identity.Revision, error) { +func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { manager.operation.Lock() defer manager.operation.Unlock() revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) @@ -110,7 +110,7 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion if err != nil { return identity.Revision{}, err } - activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion) + activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID) if err != nil { candidate.Close() return identity.Revision{}, err @@ -121,10 +121,40 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion return activated, nil } -func (manager *Manager) Disable(ctx context.Context, expectedVersion int64) (identity.Revision, error) { +func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { manager.operation.Lock() defer manager.operation.Unlock() - disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion) + revision, err := manager.repository.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded { + return identity.Revision{}, identity.ErrRevisionConflict + } + candidate, err := manager.builder.Build(ctx, revision) + if err != nil { + return identity.Revision{}, err + } + validated, err := manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, validated.Version, traceID, auditID) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + candidate.Revision = activated + old := manager.current.Swap(candidate) + retireRuntime(old) + return activated, nil +} + +func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + manager.operation.Lock() + defer manager.operation.Unlock() + disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID) if err != nil { return identity.Revision{}, err } diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go index 3494d70..4d1f9c0 100644 --- a/apps/api/internal/identityruntime/manager_test.go +++ b/apps/api/internal/identityruntime/manager_test.go @@ -45,7 +45,7 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id f.revisions[id] = revision return revision, nil } -func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64) (identity.Revision, bool, error) { +func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) { f.activateCalled = true revision := f.revisions[id] if revision.Version != expected || revision.State != identity.RevisionValidated { @@ -56,16 +56,16 @@ func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id s old.State = identity.RevisionSuperseded f.revisions[old.ID] = old } - revision.State, revision.Version = identity.RevisionActive, revision.Version+1 + revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID f.active, f.revisions[id] = revision, revision return revision, true, nil } -func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64) (identity.Revision, error) { +func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) { if f.active.Version != expected { return identity.Revision{}, identity.ErrRevisionConflict } disabled := f.active - disabled.State, disabled.Version = identity.RevisionSuperseded, disabled.Version+1 + disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID f.revisions[disabled.ID] = disabled f.active = identity.Revision{} return disabled, nil @@ -110,7 +110,7 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { manager := NewManager(repository, &runtimeBuilderFake{}) manager.current.Store(&Runtime{Revision: active}) - activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version) + activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit") if err != nil { t.Fatal(err) } @@ -118,3 +118,21 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current()) } } + +func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) { + active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5} + previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3} + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active, + } + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active}) + + rolledBack, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit") + if err != nil { + t.Fatal(err) + } + if rolledBack.State != identity.RevisionActive || manager.Current().Revision.ID != previous.ID || repository.active.ID != previous.ID { + t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current()) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 1dcf285..0c66665 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -4,11 +4,22 @@ import ( "context" "encoding/json" "errors" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/jackc/pgx/v5" ) +var ErrIdentityManagementRequestNotFound = errors.New("identity management request not found") + +type IdentityManagementRequest struct { + Operation string + Key string + RequestHash string + Response []byte + CreatedAt time.Time +} + const identityRevisionColumns = ` id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, @@ -45,6 +56,57 @@ FROM gateway_identity_configuration_revisions WHERE state='active'`)) return revision, normalizeIdentityRevisionError(err) } +func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE state IN ('draft','validated','failed') ORDER BY created_at DESC LIMIT 1`)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) LatestSupersededIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE state='superseded' ORDER BY superseded_at DESC NULLS LAST LIMIT 1`)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) UpdateIdentityRevisionPolicy(ctx context.Context, id string, expectedVersion int64, policy identity.RevisionPolicy) (identity.Revision, error) { + if err := policy.Validate(); err != nil { + return identity.Revision{}, err + } + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET local_tenant_key=$3,role_prefix=$4,jit_enabled=$5, +legacy_jwt_enabled=$6,session_idle_seconds=$7,session_absolute_seconds=$8,session_refresh_seconds=$9, +version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='draft' +RETURNING `+identityRevisionColumns, id, expectedVersion, policy.LocalTenantKey, policy.RolePrefix, policy.JITEnabled, + policy.LegacyJWTEnabled, policy.SessionIdleSeconds, policy.SessionAbsoluteSeconds, policy.SessionRefreshSeconds)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) IdentityManagementRequest(ctx context.Context, operation, key string) (IdentityManagementRequest, error) { + var request IdentityManagementRequest + err := s.pool.QueryRow(ctx, `SELECT operation,idempotency_key,request_hash,response,created_at +FROM gateway_identity_management_requests WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan( + &request.Operation, &request.Key, &request.RequestHash, &request.Response, &request.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return IdentityManagementRequest{}, ErrIdentityManagementRequestNotFound + } + return request, err +} + +func (s *Store) RecordIdentityManagementRequest(ctx context.Context, request IdentityManagementRequest) error { + if !json.Valid(request.Response) { + return errors.New("identity management response is invalid") + } + _, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_management_requests(operation,idempotency_key,request_hash,response) +VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, + request.Operation, request.Key, request.RequestHash, request.Response) + return err +} + func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) { current, err := s.IdentityConfigurationRevision(ctx, id) if err != nil { @@ -103,7 +165,7 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, aud return revision, err } -func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64) (identity.Revision, bool, error) { +func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) { tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { return identity.Revision{}, false, err @@ -143,8 +205,8 @@ version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previo } } revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active', -activated_at=now(),superseded_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='validated' -RETURNING `+identityRevisionColumns, id, expectedVersion)) +activated_at=now(),superseded_at=NULL,last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) if err != nil { return identity.Revision{}, false, err } @@ -160,7 +222,7 @@ RETURNING `+identityRevisionColumns, id, expectedVersion)) return revision, identityChanged, nil } -func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64) (identity.Revision, error) { +func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { return identity.Revision{}, err @@ -172,7 +234,8 @@ func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersi return identity.Revision{}, identity.ErrBreakGlassRequired } revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded', -superseded_at=now(),version=version+1,updated_at=now() WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion)) +superseded_at=now(),last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),version=version+1,updated_at=now() +WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } diff --git a/apps/api/internal/store/identity_pairing.go b/apps/api/internal/store/identity_pairing.go index e37a215..6930ebc 100644 --- a/apps/api/internal/store/identity_pairing.go +++ b/apps/api/internal/store/identity_pairing.go @@ -32,6 +32,34 @@ FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id)) return exchange, err } +func (s *Store) IdentityPairingExchangeForRevision(ctx context.Context, revisionID string) (identity.PairingExchange, error) { + exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revisionID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionNotFound + } + return exchange, err +} + +func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) { + rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges +WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + exchanges := make([]identity.PairingExchange, 0) + for rows.Next() { + exchange, scanErr := scanIdentityPairing(rows) + if scanErr != nil { + return nil, scanErr + } + exchanges = append(exchanges, exchange) + } + return exchanges, rows.Err() +} + func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) { exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''), From b175d545ff6768020b694ce947db64c4598c02dd Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:12:26 +0800 Subject: [PATCH 14/20] =?UTF-8?q?fix(identity):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=94=9F=E6=95=88=E9=85=8D=E7=BD=AE=E5=AE=89=E5=85=A8=E9=87=8D?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Active Revision 重验证成功后更新验证元数据并原子替换同配置 Runtime;验证失败时不修改数据库状态或当前运行时。\n\n验证:go test ./apps/api/internal/identityruntime ./apps/api/internal/store ./apps/api/internal/httpapi --- apps/api/internal/identityruntime/manager.go | 18 +++++++++++-- .../internal/identityruntime/manager_test.go | 26 +++++++++++++++++++ .../internal/store/identity_configurations.go | 12 +++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go index 5a9cb46..e43e10a 100644 --- a/apps/api/internal/identityruntime/manager.go +++ b/apps/api/internal/identityruntime/manager.go @@ -17,6 +17,7 @@ type Repository interface { IdentityConfigurationRevision(context.Context, string) (identity.Revision, error) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error) + RevalidateActiveIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, error) MarkIdentityRevisionFailed(context.Context, string, int64, string, string, string) (identity.Revision, error) ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error) DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error) @@ -84,14 +85,27 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion if err != nil { return identity.Revision{}, err } - if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded { + if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded && revision.State != identity.RevisionActive { return identity.Revision{}, identity.ErrRevisionConflict } candidate, buildErr := manager.builder.Build(ctx, revision) if buildErr != nil { - _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + if revision.State != identity.RevisionActive { + _, _ = manager.repository.MarkIdentityRevisionFailed(ctx, id, expectedVersion, "validation_failed", traceID, auditID) + } return identity.Revision{}, buildErr } + if revision.State == identity.RevisionActive { + revalidated, err := manager.repository.RevalidateActiveIdentityRevision(ctx, id, expectedVersion, traceID, auditID) + if err != nil { + candidate.Close() + return identity.Revision{}, err + } + candidate.Revision = revalidated + old := manager.current.Swap(candidate) + retireRuntime(old) + return revalidated, nil + } candidate.Close() return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) } diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go index 4d1f9c0..2d03e99 100644 --- a/apps/api/internal/identityruntime/manager_test.go +++ b/apps/api/internal/identityruntime/manager_test.go @@ -36,6 +36,15 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, f.revisions[id] = revision return revision, nil } +func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) { + revision := f.revisions[id] + if revision.Version != expected || revision.State != identity.RevisionActive { + return identity.Revision{}, identity.ErrRevisionConflict + } + revision.Version, revision.LastTraceID, revision.LastAuditID = revision.Version+1, traceID, auditID + f.revisions[id], f.active = revision, revision + return revision, nil +} func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id string, expected int64, category, _, _ string) (identity.Revision, error) { revision := f.revisions[id] if revision.Version != expected { @@ -136,3 +145,20 @@ func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) { t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current()) } } + +func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + original := &Runtime{Revision: active} + manager.current.Store(original) + + revalidated, err := manager.Validate(context.Background(), active.ID, active.Version, "trace-new", "audit-new") + if err != nil { + t.Fatal(err) + } + if revalidated.State != identity.RevisionActive || revalidated.Version != 5 || manager.Current() == original || manager.Current().Revision.LastAuditID != "audit-new" { + t.Fatalf("active runtime was not safely revalidated: %#v", revalidated) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index 0c66665..ec52884 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -153,6 +153,18 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) return revision, err } +func (s *Store) RevalidateActiveIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET validated_at=now(),last_error_category=NULL, +last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='active' +RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''), From e0a356ee0ce5cbd921fecf693c65bc7a758127e8 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:18:26 +0800 Subject: [PATCH 15/20] =?UTF-8?q?feat(web):=20=E5=A2=9E=E5=8A=A0=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=AE=A4=E8=AF=81=E6=8E=A5=E5=85=A5=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E7=BB=B4=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统设置新增统一认证配对、验证、激活、重验证、回滚与禁用流程,安全事件改为统一能力状态。前端登录入口运行时读取公开配置,不再依赖 VITE_OIDC 构建变量。\n\n验证:web 31 项测试;web typecheck;pnpm lint;web production build --- apps/web/src/App.tsx | 24 +- apps/web/src/api.test.ts | 47 +++ apps/web/src/api.ts | 70 +++- apps/web/src/lib/oidc.test.ts | 13 +- apps/web/src/lib/oidc.ts | 62 +++- apps/web/src/pages/AdminPage.tsx | 8 +- .../src/pages/admin/SystemSettingsPanel.tsx | 166 +--------- .../pages/admin/UnifiedIdentityPanel.test.tsx | 17 + .../src/pages/admin/UnifiedIdentityPanel.tsx | 310 ++++++++++++++++++ apps/web/src/styles/pages.css | 38 +++ packages/contracts/src/index.ts | 111 +++++++ 11 files changed, 675 insertions(+), 191 deletions(-) create mode 100644 apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx create mode 100644 apps/web/src/pages/admin/UnifiedIdentityPanel.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7a4ecff..0749ae7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -125,8 +125,7 @@ import { import { restoreOIDCBrowserSession } from './lib/oidc-browser-session'; import { consumeOIDCCallbackError, - oidcBrowserSessionEnabled, - oidcLoginEnabled, + loadOIDCRuntimeConfiguration, startOIDCLogin, startOIDCLogout, } from './lib/oidc'; @@ -261,6 +260,7 @@ export function App() { const [state, setState] = useState('idle'); const [error, setError] = useState(''); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); + const [oidcEnabled, setOIDCEnabled] = useState(false); const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); @@ -294,10 +294,11 @@ export function App() { useEffect(() => { let cancelled = false; - void (async () => { - if (oidcCallbackError) return; - if (!oidcBrowserSessionEnabled()) return; - if (readStoredAccessToken() || !oidcLoginEnabled()) return; + const loadIdentityRuntime = async (force = false) => { + const configuration = await loadOIDCRuntimeConfiguration(force); + if (cancelled) return; + setOIDCEnabled(configuration.enabled && configuration.oidcLogin); + if (oidcCallbackError || readStoredAccessToken() || !configuration.enabled || !configuration.oidcLogin) return; const restored = await restoreOIDCBrowserSession(); if (!restored || cancelled) return; setCurrentUser(restored.user); @@ -305,13 +306,17 @@ export function App() { setToken(restored.credential); setState('idle'); setError(''); - })().catch((err) => { + }; + const runtimeChanged = () => { void loadIdentityRuntime(true); }; + window.addEventListener('identity-runtime-changed', runtimeChanged); + void loadIdentityRuntime().catch((err) => { if (cancelled) return; setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); }); return () => { cancelled = true; + window.removeEventListener('identity-runtime-changed', runtimeChanged); }; }, [oidcCallbackError]); useEffect(() => { @@ -1354,7 +1359,7 @@ export function App() { onSubmitExternalToken={submitExternalToken} onSubmitLogin={submitLogin} onSubmitRegister={submitRegister} - oidcEnabled={oidcLoginEnabled()} + oidcEnabled={oidcEnabled} onOIDCLogin={loginWithOIDC} /> ) @@ -1362,6 +1367,7 @@ export function App() { {activePage === 'admin' && ( isAuthenticated ? ( ) diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index d0cbc8e..a49aa75 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -6,6 +6,8 @@ import { gatewayErrorMessage, getCurrentUser, OIDC_BROWSER_SESSION_CREDENTIAL, + startIdentityPairing, + validateIdentityRevision, } from './api'; describe('Gateway provisioning errors', () => { @@ -35,6 +37,51 @@ describe('Gateway provisioning errors', () => { }); }); +describe('identity configuration transport', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps the one-time onboarding code in the request body and enforces write headers', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'pairing', version: 1 }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await startIdentityPairing('manager-token', { + authCenterUrl: 'https://auth.example.com', + onboardingCode: 'one-time-code-must-stay-in-body', + publicBaseUrl: 'https://api.gateway.example.com', + webBaseUrl: 'https://gateway.example.com', + localTenantKey: 'default', + legacyJwtEnabled: false, + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).not.toContain('one-time-code-must-stay-in-body'); + expect(JSON.parse(String(init.body)).onboardingCode).toBe('one-time-code-must-stay-in-body'); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"0"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-pair-'); + }); + + it('sends the revision version when validating a draft or active runtime', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'revision', version: 8 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await validateIdentityRevision('manager-token', 'revision', 7); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(init.credentials).toBe('include'); + }); +}); + describe('security event connection transport', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9232fe4..8fb2644 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -20,6 +20,11 @@ import type { GatewayAuditLog, GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, + IdentityConfigurationRevision, + IdentityConfigurationView, + IdentityPairingExchange, + IdentityPairingInput, + IdentityRevisionPolicyUpdate, GatewayTenant, GatewayTenantUpsertRequest, GatewayNetworkProxyConfig, @@ -52,7 +57,6 @@ import type { WalletSummaryResponse, } from '@easyai-ai-gateway/contracts'; import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; -import { oidcBrowserSessionEnabled } from './lib/oidc'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__'; @@ -644,7 +648,7 @@ export async function* streamChatCompletionText( ...authorizationHeader(token), 'Content-Type': 'application/json', }, - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', method: 'POST', signal, }); @@ -845,7 +849,7 @@ export async function uploadFileToStorage( const response = await fetch(`${API_BASE}/v1/files/upload`, { body: form, - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', headers: authorizationHeader(token), method: 'POST', }); @@ -1001,6 +1005,64 @@ export async function updateClientCustomizationSettings( }); } +const identityConfigurationPath = '/api/admin/system/identity'; + +export async function getIdentityConfiguration(token: string): Promise { + return request(`${identityConfigurationPath}/configuration`, { token }); +} + +export async function startIdentityPairing(token: string, input: IdentityPairingInput): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings`, 'POST', 0, input, 'pair'); +} + +export async function getIdentityPairing(token: string, pairingId: string): Promise { + return request(`${identityConfigurationPath}/pairings/${pairingId}`, { token }); +} + +export async function updateIdentityRevisionPolicy( + token: string, + revisionId: string, + version: number, + input: IdentityRevisionPolicyUpdate, +): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}`, 'PATCH', version, input, 'policy'); +} + +export async function validateIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/validate`, 'POST', version, undefined, 'validate'); +} + +export async function activateIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate'); +} + +export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback'); +} + +export async function disableIdentityConfiguration(token: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable'); +} + +function identityConfigurationWrite( + token: string, + path: string, + method: string, + version: number, + body: unknown, + action: string, +): Promise { + return request(path, { + body, + method, + token, + headers: { + 'Idempotency-Key': `identity-${action}-${crypto.randomUUID()}`, + 'If-Match': `W/"${version}"`, + }, + }); +} + const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection'; export async function getSecurityEventConnection(token: string): Promise { @@ -1096,7 +1158,7 @@ async function request( method: options.method ?? 'GET', headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), - credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin', + credentials: 'include', }); if (!response.ok) { const body = await response.text(); diff --git a/apps/web/src/lib/oidc.test.ts b/apps/web/src/lib/oidc.test.ts index f1ea3c2..8a04a9c 100644 --- a/apps/web/src/lib/oidc.test.ts +++ b/apps/web/src/lib/oidc.test.ts @@ -8,8 +8,11 @@ describe('OIDC BFF navigation', () => { }); it('sends only returnTo to the Gateway login endpoint', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), + })); const assign = vi.fn(); vi.stubGlobal('window', { location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign }, @@ -24,8 +27,11 @@ describe('OIDC BFF navigation', () => { }); it('submits logout as a top-level POST without exposing tokens', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }), + })); const submit = vi.fn(); const form = { method: '', action: '', style: { display: '' }, submit }; const appendChild = vi.fn(); @@ -39,7 +45,6 @@ describe('OIDC BFF navigation', () => { }); it('turns an invalid login transaction callback into an explicit retry action', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' }, @@ -56,7 +61,6 @@ describe('OIDC BFF navigation', () => { }); it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); const replaceState = vi.fn(); vi.stubGlobal('window', { location: { @@ -79,7 +83,6 @@ describe('OIDC BFF navigation', () => { }); it('does not offer transaction retry for a token exchange failure', async () => { - vi.stubEnv('VITE_OIDC_ENABLED', 'true'); vi.stubGlobal('window', { location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' }, history: { replaceState: vi.fn() }, diff --git a/apps/web/src/lib/oidc.ts b/apps/web/src/lib/oidc.ts index a7bb277..5c39cd0 100644 --- a/apps/web/src/lib/oidc.ts +++ b/apps/web/src/lib/oidc.ts @@ -1,7 +1,18 @@ -const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true'; -const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false'; const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, ''); +export type OIDCRuntimeConfiguration = { + enabled: boolean; + oidcLogin: boolean; + loginUrl?: string; + logoutUrl?: string; + status: string; +}; + +const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' }; +let runtimeConfiguration = disabledRuntime; +let runtimeLoaded = false; +let runtimeRequest: Promise | null = null; + export type OIDCCallbackError = { code: string; reason?: string; @@ -27,32 +38,67 @@ const loginTransactionFailureMessages: Record = { }; export function oidcLoginEnabled() { - return enabled; + return runtimeConfiguration.enabled && runtimeConfiguration.oidcLogin; } export function oidcBrowserSessionEnabled() { - return browserSessionEnabled; + return oidcLoginEnabled(); +} + +export async function loadOIDCRuntimeConfiguration(force = false): Promise { + if (!force && runtimeLoaded) return runtimeConfiguration; + if (!force && runtimeRequest) return runtimeRequest; + runtimeRequest = fetch(`${gatewayAPIBase}/api/v1/public/identity`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }).then(async (response) => { + if (!response.ok) return disabledRuntime; + const value = await response.json() as Partial; + if (typeof value.enabled !== 'boolean' || typeof value.oidcLogin !== 'boolean' || typeof value.status !== 'string') { + return disabledRuntime; + } + return { + enabled: value.enabled, + oidcLogin: value.oidcLogin, + status: value.status, + ...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}), + ...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}), + }; + }).catch(() => disabledRuntime).then((configuration) => { + runtimeConfiguration = configuration; + runtimeLoaded = true; + runtimeRequest = null; + return configuration; + }); + return runtimeRequest; } export async function startOIDCLogin() { - if (!oidcLoginEnabled()) throw new Error('统一认证未配置'); + const configuration = await loadOIDCRuntimeConfiguration(true); + if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置'); const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/'; - const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`); + const loginURL = new URL(identityEndpointURL(configuration.loginUrl)); loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/'); window.location.assign(loginURL.toString()); } export async function startOIDCLogout() { - if (!oidcLoginEnabled()) return false; + const configuration = await loadOIDCRuntimeConfiguration(true); + if (!configuration.enabled || !configuration.oidcLogin || !configuration.logoutUrl) return false; const form = document.createElement('form'); form.method = 'POST'; - form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`; + form.action = identityEndpointURL(configuration.logoutUrl); form.style.display = 'none'; document.body.appendChild(form); form.submit(); return true; } +function identityEndpointURL(path: string) { + if (/^https:\/\//i.test(path) || /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/i.test(path)) return path; + return `${gatewayAPIBase}/${path.replace(/^\//, '')}`; +} + export function consumeOIDCCallbackError() { const params = new URLSearchParams(window.location.search); const code = params.get('oidcError') ?? ''; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index e406a37..59404bd 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -51,6 +51,7 @@ const tabs = [ ] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>; export function AdminPage(props: { + token: string; data: ConsoleData; operationMessage: string; section: AdminSection; @@ -191,21 +192,16 @@ export function AdminPage(props: { {props.section === 'auditLogs' && } {props.section === 'systemSettings' && ( )}
diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 94dccb6..83822a2 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, type FormEvent } from 'react'; -import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react'; -import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts'; +import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react'; +import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui'; import type { LoadState } from '../../types'; +import { UnifiedIdentityPanel } from './UnifiedIdentityPanel'; -type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents'; +type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity'; type ClientCustomizationForm = { clientEnglishName: string; @@ -54,21 +55,16 @@ const resultUploadPolicyOptions = [ ]; export function SystemSettingsPanel(props: { + token: string; channels: FileStorageChannel[]; clientCustomizationSettings: ClientCustomizationSettings | null; message: string; settings: FileStorageSettings | null; - securityEventConnection: SecurityEventConnectionResponse | null; state: LoadState; onDeleteFileStorageChannel: (channelId: string) => Promise; onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise; onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise; onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise; - onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise; - onDisconnectSecurityEvents: () => Promise; - onRefreshSecurityEvents: () => Promise; - onRotateSecurityEventsCredential: () => Promise; - onVerifySecurityEvents: () => Promise; }) { const [activeTab, setActiveTab] = useState('fileStorage'); const [dialogOpen, setDialogOpen] = useState(false); @@ -78,8 +74,6 @@ export function SystemSettingsPanel(props: { const [clientCustomizationForm, setClientCustomizationForm] = useState(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings)); const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); const [localError, setLocalError] = useState(''); - const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf'); - const [disconnectOpen, setDisconnectOpen] = useState(false); useEffect(() => { setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy)); @@ -89,13 +83,6 @@ export function SystemSettingsPanel(props: { setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings)); }, [props.clientCustomizationSettings]); - useEffect(() => { - const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus; - if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined; - const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000); - return () => window.clearInterval(timer); - }, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]); - function openCreateDialog() { setEditingChannel(null); setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`)); @@ -177,7 +164,7 @@ export function SystemSettingsPanel(props: { tabs={[ { value: 'fileStorage', label: '文件存储', icon: }, { value: 'clientCustomization', label: '客户端自定义', icon: }, - { value: 'securityEvents', label: '认证中心安全事件', icon: }, + { value: 'identity', label: '统一认证', icon: }, ]} onValueChange={setActiveTab} /> @@ -291,19 +278,7 @@ export function SystemSettingsPanel(props: { )} - {activeTab === 'securityEvents' && ( - setDisconnectOpen(true)} - onRefresh={props.onRefreshSecurityEvents} - onRotate={props.onRotateSecurityEventsCredential} - onVerify={props.onVerifySecurityEvents} - /> - )} + {activeTab === 'identity' && } setPendingDeleteChannel(null)} onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined} /> - setDisconnectOpen(false)} - onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }} - /> ); } -function SecurityEventConnectionPanel(props: { - connection: SecurityEventConnectionResponse | null; - issuer: string; - loading: boolean; - onIssuerChange(value: string): void; - onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise; - onDisconnect(): void; - onRefresh(): Promise; - onRotate(): Promise; - onVerify(): Promise; -}) { - const connection = props.connection?.connection; - const prerequisites = props.connection?.prerequisites; - const [managementClientId, setManagementClientId] = useState(''); - const [managementClientSecret, setManagementClientSecret] = useState(''); - useEffect(() => { - if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId); - }, [managementClientId, prerequisites?.managementClientId]); - const missing = [ - !prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '', - !prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '', - ].filter(Boolean); - return ( -
-
-
- SSF / CAEP 实时会话撤销 - 只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。 -
- - {securityEventLifecycleLabel(connection?.lifecycleStatus)} - - -
- {!connection && ( - - -
- 连接认证中心 -

请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。

-
- {missing.length > 0 &&
{missing.join(';')}
} - - - - -
-
- )} - {connection && ( - - -
- 机器 Client: {connection.managementClientId} - Receiver Endpoint: {connection.receiverEndpoint} - Issuer: {connection.transmitterIssuer} - Audience: {connection.audience ?? '正在获取'} - Stream ID: {connection.streamId ?? '正在创建'} - 健康模式: {securityEventHealthLabel(connection.healthMode)} - 最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'} - {props.connection?.traceId && 最近操作 Trace ID: {props.connection.traceId}} - {props.connection?.auditId && 最近操作 Audit ID: {props.connection.auditId}} - {connection.lastErrorCategory && 最近错误: {connection.lastErrorCategory}} -
- {(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') &&
-
认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。
- - - -
} -
- {connection.lifecycleStatus === 'error' && ( - - )} - - - -
-
-
- )} -
- ); -} - -function securityEventLifecycleLabel(status?: string) { - return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record)[status ?? ''] ?? '未连接'; -} - -function securityEventHealthLabel(mode: string) { - return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record)[mode] ?? mode; -} - function defaultClientCustomizationForm(): ClientCustomizationForm { return { clientEnglishName: 'EasyAI AI Gateway', diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx new file mode 100644 index 0000000..100af13 --- /dev/null +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { UnifiedIdentityPanel } from './UnifiedIdentityPanel'; + +describe('UnifiedIdentityPanel', () => { + it('asks only for the standard pairing inputs and does not expose a machine secret field', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('Auth Center 地址'); + expect(html).toContain('一次性接入码'); + expect(html).toContain('Gateway API 公网地址'); + expect(html).toContain('Gateway Web 地址'); + expect(html).toContain('Gateway 本地租户映射'); + expect(html).not.toContain('Machine Client Secret'); + expect(html).not.toContain('SSF Transmitter Issuer'); + }); +}); diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx new file mode 100644 index 0000000..1ee7083 --- /dev/null +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -0,0 +1,310 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react'; +import type { + IdentityConfigurationRevision, + IdentityConfigurationView, + IdentityPairingInput, +} from '@easyai-ai-gateway/contracts'; +import { + activateIdentityRevision, + disableIdentityConfiguration, + getIdentityConfiguration, + rollbackIdentityRevision, + startIdentityPairing, + updateIdentityRevisionPolicy, + validateIdentityRevision, +} from '../../api'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui'; + +const terminalPairingStatuses = new Set(['completed', 'failed', 'expired']); + +export function UnifiedIdentityPanel(props: { token: string }) { + const [configuration, setConfiguration] = useState(null); + const [form, setForm] = useState(defaultPairingInput); + const [showPairingForm, setShowPairingForm] = useState(false); + const [localTenantKey, setLocalTenantKey] = useState('default'); + const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null); + + async function refresh() { + const next = await getIdentityConfiguration(props.token); + setConfiguration(next); + if (next.draft) { + setLocalTenantKey(next.draft.localTenantKey); + setLegacyJwtEnabled(next.draft.legacyJwtEnabled); + } + return next; + } + + useEffect(() => { + void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败'))); + }, [props.token]); + + useEffect(() => { + const status = configuration?.pairing?.status; + if (!status || terminalPairingStatuses.has(status)) return undefined; + const timer = window.setInterval(() => { + void refresh().catch(() => undefined); + }, 2000); + return () => window.clearInterval(timer); + }, [configuration?.pairing?.status, props.token]); + + async function run(action: () => Promise, success: string, runtimeChanged = false) { + setLoading(true); + setError(''); + setMessage(''); + try { + await action(); + await refresh(); + setMessage(success); + if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed')); + return true; + } catch (caught) { + setError(errorMessage(caught, '统一认证操作失败')); + return false; + } finally { + setLoading(false); + } + } + + async function submitPairing(event: FormEvent) { + event.preventDefault(); + const input = { ...form }; + setForm((current) => ({ ...current, onboardingCode: '' })); + await run(async () => { + await startIdentityPairing(props.token, input); + setShowPairingForm(false); + }, '接入码已领取,正在由 Gateway 后端准备并保存配置。'); + } + + async function saveDraftPolicy() { + const draft = configuration?.draft; + if (!draft) return; + await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, { + localTenantKey: localTenantKey.trim(), + legacyJwtEnabled, + }), 'Gateway 本地认证策略已保存。'); + } + + const active = configuration?.active; + const draft = configuration?.draft; + const previous = configuration?.previous; + const pairing = configuration?.pairing; + const runtime = configuration?.runtime; + const shouldShowPairing = showPairingForm || (!active && !pairing); + + return ( +
+
+
+ 统一认证 + 使用认证中心生成的一次性接入码完成标准应用配对;Gateway 自动配置 OIDC、Introspection 与可选 SSF。 +
+
+ {active ? '已启用' : '未启用'} + +
+
+ + {(message || error) &&
{error || message}
} + + {active && runtime && ( + + +
+ 当前生效配置 +

Revision {shortID(active.id)} · {active.issuer}

+
+ Active +
+ +
+ + + + +
+ +
+ + + {previous && ( + + )} + +
+
+
+ )} + + {pairing && !terminalPairingStatuses.has(pairing.status) && ( + + +
+
正在配对认证中心{pairingStatusLabel(pairing.status)}
+ {pairing.status} +
+
+ Exchange: {shortID(pairing.remoteExchangeId)} + 有效期至: {new Date(pairing.expiresAt).toLocaleString()} + {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} + {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} +
+
+
+ )} + + {draft && pairing?.status === 'completed' && ( + + +
待启用配置

机器凭据已写入 SecretStore,页面不会显示或保存明文。

+ {draft.state} +
+ + + {draft.state === 'draft' && ( +
+ + +
+ )} +
+ {draft.state === 'draft' && <> + + + } + {draft.state === 'validated' && ( + + )} + {draft.state === 'failed' && 验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。} +
+
+
+ )} + + {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && ( +
+ 配对{pairing.status === 'expired' ? '已过期' : '失败'}:{pairing.lastErrorCategory || pairing.status}。请在认证中心生成新的接入码。 + +
+ )} + + {shouldShowPairing && ( + + +
{active ? '重新配对认证中心' : '接入认证中心'}

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

+ 接入码 10 分钟有效 +
+ +
void submitPairing(event)}> + + + + + + +
+ + {active && } +
+
+
+
+ )} + + setConfirmAction(null)} + onConfirm={async () => { + let succeeded = false; + if (confirmAction === 'rollback' && previous) { + succeeded = await run(() => rollbackIdentityRevision(props.token, previous.id, previous.version), '统一认证已回滚,用户需要重新登录。', true); + } else if (confirmAction === 'disable' && active) { + succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true); + } + if (succeeded) setConfirmAction(null); + }} + /> +
+ ); +} + +function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) { + return ( +
+ Issuer: {revision.issuer || '等待 Manifest'} + Audience: {revision.audience || '等待 Manifest'} + Tenant: {revision.tenantId || '等待 Manifest'} + 本地租户: {revision.localTenantKey} + 登录 Client: {revision.browserClientId || '未启用'} + 服务 Client: {revision.machineClientId || '未启用'} + 能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'} + Scope: {revision.scopes.join(', ') || '无'} + {revision.lastTraceId && Trace ID: {revision.lastTraceId}} + {revision.lastAuditId && Audit ID: {revision.lastAuditId}} +
+ ); +} + +function IdentityHealth({ label, value }: { label: string; value: string }) { + const healthy = value === 'healthy' || value === 'push_healthy'; + return
{label}{healthLabel(value)}
; +} + +function defaultPairingInput(): IdentityPairingInput { + const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin; + return { + authCenterUrl: 'https://auth.51easyai.com', + onboardingCode: '', + publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''), + webBaseUrl, + localTenantKey: 'default', + legacyJwtEnabled: false, + }; +} + +function pairingStatusLabel(status: string) { + return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record)[status] ?? status; +} + +function capabilityLabel(value: string) { + return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record)[value] ?? value; +} + +function healthLabel(value: string) { + return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record)[value] ?? value; +} + +function shortID(value: string) { return value ? `${value.slice(0, 8)}…` : '-'; } + +function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; } diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index df8eb40..fead9df 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -1995,6 +1995,44 @@ line-height: 1.45; } +.identityHealthGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.identityHealthItem { + align-items: center; + background: var(--color-surface-subtle, #f8fafc); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 10px; + display: flex; + justify-content: space-between; + min-height: 52px; + padding: 10px 12px; +} + +.identityCheckbox { + align-items: center; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 10px; + display: flex; + gap: 10px; + min-height: 66px; + padding: 10px 12px; +} + +.identityCheckbox span, +.identityCheckbox small { + display: block; +} + +@media (max-width: 900px) { + .identityHealthGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .fileStorageGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 00faee8..fbf66db 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -988,6 +988,117 @@ export interface SecurityEventConnectionResponse { prerequisites?: SecurityEventConnectionPrerequisites; } +export type IdentityRevisionState = 'draft' | 'validated' | 'active' | 'superseded' | 'failed'; + +export interface IdentityConfigurationRevision { + id: string; + state: IdentityRevisionState; + schemaVersion: number; + authCenterUrl: string; + issuer?: string; + tenantId?: string; + applicationId?: string; + audience?: string; + browserClientId?: string; + machineClientId?: string; + scopes: string[]; + capabilities: string[]; + rolePrefix: string; + localTenantKey: string; + publicBaseUrl: string; + webBaseUrl: string; + jitEnabled: boolean; + legacyJwtEnabled: boolean; + tokenIntrospection: boolean; + sessionRevocation: boolean; + securityEventIssuer?: string; + securityEventConfigurationUrl?: string; + securityEventAudience?: string; + sessionIdleSeconds: number; + sessionAbsoluteSeconds: number; + sessionRefreshSeconds: number; + version: number; + lastErrorCategory?: string; + lastTraceId?: string; + lastAuditId?: string; + validatedAt?: string; + activatedAt?: string; + supersededAt?: string; + createdAt: string; + updatedAt: string; +} + +export type IdentityPairingStatus = + | 'metadata_pending' + | 'preparing' + | 'ready' + | 'credentials_saved' + | 'completed' + | 'failed' + | 'expired'; + +export interface IdentityPairingExchange { + id: string; + revisionId: string; + remoteExchangeId: string; + status: IdentityPairingStatus; + remoteVersion: number; + expiresAt: string; + version: number; + lastErrorCategory?: string; + authCenterAuditId?: string; + lastTraceId?: string; + createdAt: string; + updatedAt: string; +} + +export interface IdentityRuntimeStatus { + enabled: boolean; + revisionId?: string; + login: string; + jit: string; + tokenIntrospection: string; + sessionRevocation: string; + lastTraceId?: string; + lastAuditId?: string; + lastErrorCategory?: string; +} + +export interface IdentityConfigurationView { + active: IdentityConfigurationRevision | null; + draft: IdentityConfigurationRevision | null; + previous: IdentityConfigurationRevision | null; + pairing?: IdentityPairingExchange; + runtime: IdentityRuntimeStatus; +} + +export interface PublicIdentityConfiguration { + enabled: boolean; + oidcLogin: boolean; + loginUrl?: string; + logoutUrl?: string; + status: string; +} + +export interface IdentityPairingInput { + authCenterUrl: string; + onboardingCode: string; + publicBaseUrl: string; + webBaseUrl: string; + localTenantKey: string; + legacyJwtEnabled: boolean; +} + +export interface IdentityRevisionPolicyUpdate { + localTenantKey?: string; + rolePrefix?: string; + jitEnabled?: boolean; + legacyJwtEnabled?: boolean; + sessionIdleSeconds?: number; + sessionAbsoluteSeconds?: number; + sessionRefreshSeconds?: number; +} + export interface GatewayTask { id: string; kind: string; From a767dc42c0feedba06565d60a26365099e69f493 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:31:00 +0800 Subject: [PATCH 16/20] =?UTF-8?q?refactor(identity):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E8=AE=A4=E8=AF=81=E9=85=8D=E7=BD=AE=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除旧 OIDC_* 与 VITE_OIDC_* 业务配置读取,统一使用数据库 Revision 和 SecretStore 引用构建运行时。同步更新 Compose、示例配置、接入文档及集成测试,并保留数据库、SecretStore 和安全事件健康窗口等部署级参数。\n\n验证:go test ./...;go test -race ./...;go vet ./...;pnpm test;pnpm lint;pnpm build;docker compose config -q --- .env.example | 55 +--- Dockerfile | 6 +- README.md | 26 +- apps/api/internal/config/config.go | 252 +++++------------- apps/api/internal/config/config_test.go | 189 +++---------- apps/api/internal/httpapi/identity_runtime.go | 29 +- .../httpapi/oidc_jit_integration_test.go | 161 ++++++++--- .../api/internal/httpapi/oidc_session_test.go | 20 +- .../httpapi/oidc_user_middleware_test.go | 14 +- ...security_event_connection_handlers_test.go | 8 +- apps/api/internal/httpapi/server.go | 61 +++-- .../pages/admin/UnifiedIdentityPanel.test.tsx | 1 + .../src/pages/admin/UnifiedIdentityPanel.tsx | 3 +- docker-compose.yml | 34 +-- docs/oidc-jit-provisioning.md | 29 +- docs/security/ssf-session-revocation.md | 27 +- ...standard-identity-runtime-configuration.md | 16 +- scripts/dev.sh | 3 - 18 files changed, 355 insertions(+), 579 deletions(-) diff --git a/.env.example b/.env.example index b2591c7..6439feb 100644 --- a/.env.example +++ b/.env.example @@ -23,52 +23,19 @@ CONFIG_JWT_SECRET=this is a very secret secret # - hybrid: both sources are accepted and separated by gateway_users.source. IDENTITY_MODE=hybrid -# Auth Center stable OIDC verification. Keep legacy HS256 during the staged rollout. -OIDC_ENABLED=false -OIDC_ISSUER=https://auth.51easyai.com/issuer/shared -OIDC_AUDIENCE=https://api.51easyai.com/gateway -OIDC_TENANT_ID= -OIDC_ROLE_PREFIX=gateway. -OIDC_REQUIRED_SCOPES=gateway.access -OIDC_JWKS_CACHE_TTL_SECONDS=300 -OIDC_ACCEPT_LEGACY_HS256=true -OIDC_INTROSPECTION_ENABLED=false -# Legacy/static fallback only. New SSF connections receive the machine -# credential once in the web flow and persist it in the configured SecretStore. -OIDC_INTROSPECTION_CLIENT_ID= -OIDC_INTROSPECTION_CLIENT_SECRET= -# SSF/CAEP is activated by the durable connection created in System Settings; -# there is no enable flag and no Push Bearer in environment variables. +# Unified identity business settings are managed in System Settings > Unified +# Identity. Deployment only supplies the SecretStore and infrastructure timing. AI_GATEWAY_PUBLIC_BASE_URL=http://localhost:8088 -OIDC_SECURITY_EVENTS_SECRET_STORE=file -OIDC_SECURITY_EVENTS_SECRET_DIR=.local-secrets/ssf +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity # Kubernetes uses one pre-provisioned empty Secret and narrowly scoped RBAC. -# OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes -# OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai -# OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events -# OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER=https://kubernetes.default.svc -OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 -OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 -OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 -# Controlled JIT is opt-in. When enabled, bind the validated Auth Center tid to -# one existing active Gateway tenant; tokens never create Gateway tenants. -OIDC_JIT_PROVISIONING_ENABLED=false -OIDC_GATEWAY_TENANT_KEY= -OIDC_BROWSER_SESSION_ENABLED=true -# Staging/production must use true. Local HTTP development may use false. -OIDC_SESSION_COOKIE_SECURE=false -# Public Client ID generated by Auth Center Control Plane. No Client Secret is used. -OIDC_CLIENT_ID= -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -# Generate a dedicated value with: openssl rand -base64 32 -# Keep the real value only in a Git-ignored local file or Secret Manager. -OIDC_SESSION_ENCRYPTION_KEY= -OIDC_SESSION_IDLE_TTL_SECONDS=1800 -OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 -OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 -VITE_OIDC_BROWSER_SESSION_ENABLED=true -VITE_OIDC_ENABLED=false +# IDENTITY_SECRET_STORE=kubernetes +# IDENTITY_KUBERNETES_NAMESPACE=easyai +# IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity +# IDENTITY_KUBERNETES_API_SERVER=https://kubernetes.default.svc +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 AI_GATEWAY_WEB_BASE_URL=http://localhost:5178 AI_GATEWAY_WEB_BASE_PATH=/ AI_GATEWAY_GO_BUILD_IMAGE=golang:1.26.3-alpine diff --git a/Dockerfile b/Dockerfile index 0bd8467..dfe6ed3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,13 +71,9 @@ COPY packages packages COPY apps/web apps/web ARG VITE_GATEWAY_API_BASE_URL=/gateway-api -ARG VITE_OIDC_ENABLED=false -ARG VITE_OIDC_BROWSER_SESSION_ENABLED=true ARG VITE_BASE_PATH=/ ENV VITE_GATEWAY_API_BASE_URL=$VITE_GATEWAY_API_BASE_URL -ENV VITE_OIDC_ENABLED=$VITE_OIDC_ENABLED \ - VITE_OIDC_BROWSER_SESSION_ENABLED=$VITE_OIDC_BROWSER_SESSION_ENABLED \ - VITE_BASE_PATH=$VITE_BASE_PATH +ENV VITE_BASE_PATH=$VITE_BASE_PATH RUN pnpm --filter @easyai-ai-gateway/web build FROM ${WEB_RUNTIME_IMAGE} AS web diff --git a/README.md b/README.md index 5e33e10..60fa346 100644 --- a/README.md +++ b/README.md @@ -36,27 +36,21 @@ pnpm dev - PostgreSQL: 目标版本 18,默认使用宿主机 `localhost:5432` 上的 `easyai-pgvector` 实例,并使用独立库 `easyai_ai_gateway` - 身份模式: 默认 `IDENTITY_MODE=hybrid`,可同时测试 Gateway 本地账号注册登录、可选邀请码和 `server-main` JWT / API Key 对接。 -### Auth Center OIDC 受控 JIT +### Auth Center 统一认证 -OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后,Gateway 可在首个受保护请求前创建本地业务投影。该投影只用于 Gateway 的用户组、钱包、API Key、任务归属和审计,不修改 Auth Center Claims,也不迁移或合并历史账号。 +统一认证不再通过 `OIDC_*` 或 `VITE_OIDC_*` 业务环境变量配置。管理员先在 Auth Center 的通用“应用接入”向导选择 OIDC 登录、API 验证、机器调用、Token Introspection 和 SSF 会话撤销等标准能力,再到 Gateway 的“系统设置 → 统一认证”填写 Auth Center 地址、一次性接入码、API/Web 公网地址和本地租户映射。Gateway 自动领取标准 Manifest 与一次性机器凭据,验证通过后热切换,无需重启。 + +部署环境只提供 SecretStore 等启动级配置: ```dotenv -OIDC_ENABLED=true -OIDC_JIT_PROVISIONING_ENABLED=true -OIDC_GATEWAY_TENANT_KEY=default -OIDC_BROWSER_SESSION_ENABLED=true -OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true -OIDC_CLIENT_ID=<公共 Client ID> -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -OIDC_SESSION_ENCRYPTION_KEY= +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 ``` -`OIDC_GATEWAY_TENANT_KEY` 必须指向已有且启用的 Gateway 租户及其默认用户组;JIT 不会从 Token 自动创建租户。开关默认关闭,关闭后不再创建新用户,但仍可解析此前创建的 `source=oidc` 映射。 - -Web Console 使用公共 Client + PKCE + Gateway BFF Session:Gateway 服务端换码并加密保存 Access/Refresh/ID Token,浏览器 JavaScript 只持有不可读的 HttpOnly 随机 Session Cookie。Access Token 仍为 5 分钟并由业务请求按需刷新;Session 闲置 30 分钟、最长 8 小时。Gateway 不使用 Client Secret,也不二次签发 JWT。完整行为、错误码和回滚方式见 [OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)。 - -可选的 SSF/CAEP 实时撤销接收器提供 `POST /api/v1/security-events/ssf`:收到并验证 `session-revoked` SET 后,以本地 PostgreSQL 水位立即拒绝旧 OIDC Token,并删除同一租户 Subject 的 BFF Session。功能由“系统设置”中的持久连接启用,不使用环境变量开关;Gateway 后端自动生成和托管 Push Bearer,并复用现有 RFC 7662 机器 Client。推送不健康时自动切换 RFC 7662,内省也不可用时 OIDC Fail Closed;API Key 与 Legacy JWT 行为不变。配置、轮换、监控和回滚见 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 +OIDC 用户通过签名、Issuer、Audience、`tid`、Scope 和应用角色校验后,Gateway 可按当前 Active Revision 的策略创建本地业务投影。Web Console 使用公共 Client + PKCE + Gateway BFF Session;浏览器 JavaScript 只持有 HttpOnly 随机 Session Cookie。可选 SSF/CAEP 能力自动复用同一机器凭据,推送不健康时降级到 RFC 7662,内省也不可用时 OIDC Fail Closed。完整行为见 [统一认证运行时配置](docs/standard-identity-runtime-configuration.md)、[OIDC JIT 接入说明](docs/oidc-jit-provisioning.md)和 [SSF 会话撤销运行手册](docs/security/ssf-session-revocation.md)。 `pnpm dev` 会先创建数据库并执行 migration,然后并行启动: diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 310291d..f373963 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -1,7 +1,6 @@ package config import ( - "encoding/base64" "errors" "log/slog" "net/url" @@ -16,68 +15,43 @@ const ( ) type Config struct { - AppEnv string - HTTPAddr string - DatabaseURL string - IdentityMode string - JWTSecret string - ServerMainBaseURL string - ServerMainInternalToken string - ServerMainInternalKey string - ServerMainInternalSecret string - OIDCEnabled bool - OIDCIssuer string - OIDCAudience string - OIDCTenantID string - OIDCRolePrefix string - OIDCRequiredScopes []string - OIDCJWKSCacheTTLSeconds int - OIDCAcceptLegacyHS256 bool - OIDCIntrospectionEnabled bool - OIDCIntrospectionClientID string - OIDCIntrospectionClientSecret string - OIDCSecurityEventsSecretStore string - OIDCSecurityEventsSecretDir string - OIDCSecurityEventsKubernetesNamespace string - OIDCSecurityEventsKubernetesSecretName string - OIDCSecurityEventsKubernetesAPIServer string - OIDCSecurityEventsKubernetesTokenFile string - OIDCSecurityEventsKubernetesCAFile string - OIDCSecurityEventsHeartbeatIntervalSeconds int - OIDCSecurityEventsStaleAfterSeconds int - OIDCSecurityEventsClockSkewSeconds int - OIDCJITProvisioningEnabled bool - OIDCGatewayTenantKey string - OIDCBrowserSessionEnabled bool - OIDCSessionCookieSecure bool - OIDCClientID string - OIDCRedirectURI string - OIDCPostLogoutRedirectURI string - OIDCSessionEncryptionKey string - OIDCSessionIdleTTLSeconds int - OIDCSessionAbsoluteTTLSeconds int - OIDCSessionRefreshBeforeSeconds int - PublicBaseURL string - WebBaseURL string - LocalGeneratedStorageDir string - LocalUploadedStorageDir string - LocalTempAssetTTLHours int - TaskProgressCallbackEnabled bool - TaskProgressCallbackURL string - TaskProgressCallbackTimeoutMS string - TaskProgressCallbackMaxAttempts string - CORSAllowedOrigin string - GlobalHTTPProxy string - GlobalHTTPProxySource string - LogLevel slog.Level + AppEnv string + HTTPAddr string + DatabaseURL string + IdentityMode string + JWTSecret string + ServerMainBaseURL string + ServerMainInternalToken string + ServerMainInternalKey string + ServerMainInternalSecret string + IdentitySecretStore string + IdentitySecretDir string + IdentityKubernetesNamespace string + IdentityKubernetesSecretName string + IdentityKubernetesAPIServer string + IdentityKubernetesTokenFile string + IdentityKubernetesCAFile string + IdentitySecurityEventHeartbeatIntervalSeconds int + IdentitySecurityEventStaleAfterSeconds int + IdentitySecurityEventClockSkewSeconds int + PublicBaseURL string + WebBaseURL string + LocalGeneratedStorageDir string + LocalUploadedStorageDir string + LocalTempAssetTTLHours int + TaskProgressCallbackEnabled bool + TaskProgressCallbackURL string + TaskProgressCallbackTimeoutMS string + TaskProgressCallbackMaxAttempts string + CORSAllowedOrigin string + GlobalHTTPProxy string + GlobalHTTPProxySource string + LogLevel slog.Level } func Load() Config { globalProxy := LoadGlobalHTTPProxyStatus() appEnv := env("APP_ENV", "development") - oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/") - introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "") - introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "") return Config{ AppEnv: appEnv, HTTPAddr: env("HTTP_ADDR", ":8088"), @@ -88,49 +62,25 @@ func Load() Config { env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), - ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), - ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), - ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), - OIDCEnabled: env("OIDC_ENABLED", "false") == "true", - OIDCIssuer: oidcIssuer, - OIDCAudience: env("OIDC_AUDIENCE", ""), - OIDCTenantID: env("OIDC_TENANT_ID", ""), - OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), - OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), - OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), - OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", - OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", - OIDCIntrospectionClientID: introspectionClientID, - OIDCIntrospectionClientSecret: introspectionClientSecret, - OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"), - OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"), - OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), - OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"), - OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), - OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), - OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), - OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), - OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), - OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), - OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true", - OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""), - OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true", - OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE", - strconv.FormatBool(!isLocalEnvironment(appEnv)), - ) == "true", - OIDCClientID: env("OIDC_CLIENT_ID", ""), - OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""), - OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""), - OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""), - OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800), - OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800), - OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60), - PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), - WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), - LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), - LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), - LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), - TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", + ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), + ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), + IdentitySecretStore: env("IDENTITY_SECRET_STORE", "file"), + IdentitySecretDir: env("IDENTITY_SECRET_DIR", ".local-secrets/identity"), + IdentityKubernetesNamespace: env("IDENTITY_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), + IdentityKubernetesSecretName: env("IDENTITY_KUBERNETES_SECRET_NAME", "easyai-gateway-identity"), + IdentityKubernetesAPIServer: env("IDENTITY_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), + IdentityKubernetesTokenFile: env("IDENTITY_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), + IdentityKubernetesCAFile: env("IDENTITY_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), + IdentitySecurityEventHeartbeatIntervalSeconds: envInt("IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), + IdentitySecurityEventStaleAfterSeconds: envInt("IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), + IdentitySecurityEventClockSkewSeconds: envInt("IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), + PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), + WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), + LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), + LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), + LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), + TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", ), @@ -144,96 +94,29 @@ func Load() Config { } func (c Config) Validate() error { - switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) { + switch strings.ToLower(strings.TrimSpace(c.IdentitySecretStore)) { case "": case "file": - if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" { - return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore") + if strings.TrimSpace(c.IdentitySecretDir) == "" { + return errors.New("IDENTITY_SECRET_DIR is required for the file SecretStore") } case "kubernetes": - if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" { - return errors.New("Kubernetes security event SecretStore requires namespace and Secret name") + if strings.TrimSpace(c.IdentityKubernetesNamespace) == "" || strings.TrimSpace(c.IdentityKubernetesSecretName) == "" { + return errors.New("Kubernetes identity SecretStore requires namespace and Secret name") } default: - return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes") + return errors.New("IDENTITY_SECRET_STORE must be file or kubernetes") } - if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 { - if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || - c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || - c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { - return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") - } - } - if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" { - return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true") - } - if c.OIDCEnabled && c.OIDCBrowserSessionEnabled { - for _, scope := range c.OIDCRequiredScopes { - if strings.EqualFold(strings.TrimSpace(scope), "offline_access") { - return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions") - } - } - if strings.TrimSpace(c.OIDCClientID) == "" { - return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled") - } - if !validPublicRedirectURL(c.OIDCRedirectURI) { - return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") - } - if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) { - return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") - } - if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil { - return err - } - if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds { - return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL") - } - if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds { - return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL") - } - if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure { - return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests") - } - for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") { - if strings.TrimSpace(origin) == "*" { - return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled") - } + if c.IdentitySecurityEventHeartbeatIntervalSeconds != 0 || c.IdentitySecurityEventStaleAfterSeconds != 0 || c.IdentitySecurityEventClockSkewSeconds != 0 { + if c.IdentitySecurityEventHeartbeatIntervalSeconds <= 0 || + c.IdentitySecurityEventStaleAfterSeconds < 2*c.IdentitySecurityEventHeartbeatIntervalSeconds || + c.IdentitySecurityEventClockSkewSeconds < 0 || c.IdentitySecurityEventClockSkewSeconds > 300 { + return errors.New("identity security event heartbeat, stale threshold, or clock skew is invalid") } } return nil } -func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) { - raw := strings.TrimSpace(c.OIDCSessionEncryptionKey) - for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} { - decoded, err := encoding.DecodeString(raw) - if err == nil && len(decoded) == 32 { - return decoded, nil - } - } - return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key") -} - -func validPublicRedirectURL(raw string) bool { - parsed, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return false - } - if parsed.Scheme == "https" { - return true - } - return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") -} - -func isLocalEnvironment(value string) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "development", "dev", "local", "test": - return true - default: - return false - } -} - type GlobalHTTPProxyStatus struct { HTTPProxy string Source string @@ -288,17 +171,6 @@ func normalizePostgresURL(raw string) string { return parsed.String() } -func splitCSV(value string) []string { - items := strings.Split(value, ",") - result := make([]string, 0, len(items)) - for _, item := range items { - if trimmed := strings.TrimSpace(item); trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - func withDatabase(raw string, databaseName string) string { parsed, err := url.Parse(raw) if err != nil || databaseName == "" { diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 989d054..1e3f7b9 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -1,182 +1,59 @@ package config import ( - "bytes" - "encoding/base64" + "fmt" "strings" "testing" ) -func TestLoadOIDCJITProvisioningDefaultsToDisabled(t *testing.T) { - t.Setenv("OIDC_JIT_PROVISIONING_ENABLED", "") - t.Setenv("OIDC_GATEWAY_TENANT_KEY", "") +func TestLoadIdentitySecretStoreUsesNewEnvironmentNamesAndIgnoresLegacyBusinessValues(t *testing.T) { + t.Setenv("IDENTITY_SECRET_STORE", "file") + t.Setenv("IDENTITY_SECRET_DIR", ".test-secrets/identity") + t.Setenv("OIDC_ISSUER", "https://legacy-sensitive.example/issuer") + t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "legacy-sensitive-secret") + t.Setenv("OIDC_SESSION_ENCRYPTION_KEY", "legacy-sensitive-session-key") cfg := Load() - if cfg.OIDCJITProvisioningEnabled { - t.Fatal("OIDC JIT provisioning must be disabled by default") + if cfg.IdentitySecretStore != "file" || cfg.IdentitySecretDir != ".test-secrets/identity" { + t.Fatalf("identity SecretStore = %q %q", cfg.IdentitySecretStore, cfg.IdentitySecretDir) } - if cfg.OIDCGatewayTenantKey != "" { - t.Fatalf("unexpected gateway tenant key: %q", cfg.OIDCGatewayTenantKey) + printed := fmt.Sprintf("%+v", cfg) + for _, legacyValue := range []string{"legacy-sensitive.example", "legacy-sensitive-secret", "legacy-sensitive-session-key"} { + if strings.Contains(printed, legacyValue) { + t.Fatalf("legacy identity business setting was loaded into Config: %q", legacyValue) + } } } -func TestValidateRequiresGatewayTenantKeyWhenOIDCJITIsEnabled(t *testing.T) { - cfg := Config{ - OIDCEnabled: true, - OIDCJITProvisioningEnabled: true, +func TestValidateIdentityFileSecretStoreRequiresDirectory(t *testing.T) { + cfg := Config{IdentitySecretStore: "file"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "IDENTITY_SECRET_DIR") { + t.Fatalf("Validate() error = %v, want missing identity secret directory", err) } - - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "OIDC_GATEWAY_TENANT_KEY") { - t.Fatalf("Validate() error = %v, want missing OIDC_GATEWAY_TENANT_KEY", err) - } - - cfg.OIDCGatewayTenantKey = "default" + cfg.IdentitySecretDir = ".test-secrets/identity" if err := cfg.Validate(); err != nil { - t.Fatalf("Validate() with tenant key: %v", err) + t.Fatalf("valid file SecretStore was rejected: %v", err) } } -func TestLoadOIDCBrowserSessionUsesSafeEnvironmentDefaults(t *testing.T) { - t.Setenv("APP_ENV", "development") - t.Setenv("OIDC_BROWSER_SESSION_ENABLED", "") - t.Setenv("OIDC_SESSION_COOKIE_SECURE", "") - - cfg := Load() - if !cfg.OIDCBrowserSessionEnabled { - t.Fatal("OIDC browser session should be enabled by default") +func TestValidateIdentityKubernetesSecretStore(t *testing.T) { + cfg := Config{IdentitySecretStore: "kubernetes", IdentityKubernetesSecretName: "easyai-gateway-identity"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") { + t.Fatalf("Validate() error = %v, want missing namespace", err) } - if cfg.OIDCSessionCookieSecure { - t.Fatal("development cookie should allow localhost HTTP by default") - } - - t.Setenv("APP_ENV", "production") - cfg = Load() - if !cfg.OIDCSessionCookieSecure { - t.Fatal("production OIDC session cookie must default to Secure") - } - - t.Setenv("APP_ENV", "staging") - cfg = Load() - if !cfg.OIDCSessionCookieSecure { - t.Fatal("staging OIDC session cookie must default to Secure") - } -} - -func TestValidateRejectsInsecureNonLocalOIDCBrowserSession(t *testing.T) { - cfg := Config{ - AppEnv: "staging", - OIDCEnabled: true, - OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, - CORSAllowedOrigin: "https://gateway.example.com", - OIDCClientID: "gateway-public", - OIDCRedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback", - OIDCPostLogoutRedirectURI: "https://gateway.example.com/", - OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), - OIDCSessionIdleTTLSeconds: 1800, - OIDCSessionAbsoluteTTLSeconds: 28800, - OIDCSessionRefreshBeforeSeconds: 60, - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_COOKIE_SECURE") { - t.Fatalf("Validate() error = %v, want insecure non-local cookie rejection", err) - } - - cfg.OIDCSessionCookieSecure = true - cfg.CORSAllowedOrigin = "*" - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "CORS_ALLOWED_ORIGIN") { - t.Fatalf("Validate() error = %v, want wildcard credentialed CORS rejection", err) - } -} - -func TestValidateRequiresCompletePublicClientSessionConfiguration(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, CORSAllowedOrigin: "http://localhost:5178", - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") { - t.Fatalf("Validate() error = %v, want incomplete public client rejection", err) - } - - cfg.OIDCClientID = "gateway-public" - cfg.OIDCRedirectURI = "http://localhost:8088/api/v1/auth/oidc/callback" - cfg.OIDCPostLogoutRedirectURI = "http://localhost:5178/" - cfg.OIDCSessionEncryptionKey = base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)) - cfg.OIDCSessionIdleTTLSeconds = 1800 - cfg.OIDCSessionAbsoluteTTLSeconds = 28800 - cfg.OIDCSessionRefreshBeforeSeconds = 60 + cfg.IdentityKubernetesNamespace = "easyai" if err := cfg.Validate(); err != nil { - t.Fatalf("Validate() valid public session config: %v", err) - } - key, err := cfg.OIDCSessionEncryptionKeyBytes() - if err != nil || len(key) != 32 { - t.Fatalf("decoded encryption key len=%d err=%v", len(key), err) - } -} - -func TestValidateRejectsPlaintextOrWrongLengthSessionKey(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCClientID: "gateway-public", OIDCRedirectURI: "http://localhost:8088/callback", - OIDCPostLogoutRedirectURI: "http://localhost:5178/", OIDCSessionEncryptionKey: strings.Repeat("x", 32), - OIDCSessionIdleTTLSeconds: 1800, OIDCSessionAbsoluteTTLSeconds: 28800, OIDCSessionRefreshBeforeSeconds: 60, - CORSAllowedOrigin: "http://localhost:5178", - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "OIDC_SESSION_ENCRYPTION_KEY") { - t.Fatalf("Validate() error = %v, want encoded AES-256 key rejection", err) - } -} - -func TestValidateRejectsOfflineAccessForBrowserSession(t *testing.T) { - cfg := Config{ - AppEnv: "test", OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - OIDCRequiredScopes: []string{"gateway.access", "offline_access"}, - } - if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "offline_access") { - t.Fatalf("Validate() error = %v, want offline_access rejection", err) - } -} - -func TestSecurityEventsUseDurableConnectionInsteadOfAnEnableFlag(t *testing.T) { - t.Setenv("OIDC_SECURITY_EVENTS_ENABLED", "true") - t.Setenv("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", "https://untrusted.example/ssf") - t.Setenv("OIDC_SECURITY_EVENTS_BEARER_SECRET", "must-not-be-loaded") - t.Setenv("OIDC_SECURITY_EVENTS_SECRET_STORE", "file") - t.Setenv("OIDC_SECURITY_EVENTS_SECRET_DIR", ".test-secrets/ssf") - - cfg := Load() - if cfg.OIDCSecurityEventsSecretStore != "file" || cfg.OIDCSecurityEventsSecretDir != ".test-secrets/ssf" { - t.Fatalf("secret directory=%q", cfg.OIDCSecurityEventsSecretDir) - } - if err := (Config{}).Validate(); err != nil { - t.Fatalf("an absent security event connection changed existing config: %v", err) - } -} - -func TestValidateKubernetesSecurityEventSecretStore(t *testing.T) { - config := Config{OIDCSecurityEventsSecretStore: "kubernetes", OIDCSecurityEventsKubernetesSecretName: "easyai-gateway-security-events"} - if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "namespace") { - t.Fatalf("Validate() error=%v, want missing namespace", err) - } - config.OIDCSecurityEventsKubernetesNamespace = "easyai" - if err := config.Validate(); err != nil { t.Fatalf("valid Kubernetes SecretStore was rejected: %v", err) } } -func TestValidateSecurityEventTiming(t *testing.T) { - config := Config{OIDCSecurityEventsHeartbeatIntervalSeconds: 60, OIDCSecurityEventsStaleAfterSeconds: 60, OIDCSecurityEventsClockSkewSeconds: 60} - if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") { - t.Fatalf("Validate() error=%v, want invalid stale threshold", err) - } -} - -func TestLoadSecurityEventsReusesOnlyTheRFC7662MachineClient(t *testing.T) { - t.Setenv("OIDC_INTROSPECTION_CLIENT_ID", "gateway-service") - t.Setenv("OIDC_INTROSPECTION_CLIENT_SECRET", "service-secret") - - cfg := Load() - if cfg.OIDCIntrospectionClientID != "gateway-service" || cfg.OIDCIntrospectionClientSecret != "service-secret" { - t.Fatal("RFC 7662 machine credentials were not preserved") +func TestValidateIdentitySecurityEventTiming(t *testing.T) { + cfg := Config{ + IdentitySecurityEventHeartbeatIntervalSeconds: 60, + IdentitySecurityEventStaleAfterSeconds: 60, + IdentitySecurityEventClockSkewSeconds: 60, + } + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "heartbeat") { + t.Fatalf("Validate() error = %v, want invalid stale threshold", err) } } diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go index cea5c6c..192a9ef 100644 --- a/apps/api/internal/httpapi/identity_runtime.go +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -44,28 +44,37 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime { // Compatibility path for focused HTTP tests. NewServer never uses these // static fields after identity revisions are enabled. - if !s.cfg.OIDCEnabled && s.cfg.OIDCIssuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil { + if s.identityTestRevision.ID == "" && s.identityTestRevision.Issuer == "" && s.oidcClient == nil && s.oidcSessions == nil && s.oidcSessionCipher == nil && s.securityEventManager == nil && !s.identityTestBrowserEnabled { return nil } - webBaseURL := s.cfg.WebBaseURL + revision := s.identityTestRevision + if revision.State == "" { + revision.State = identity.RevisionActive + } + webBaseURL := revision.WebBaseURL + if webBaseURL == "" { + webBaseURL = s.cfg.WebBaseURL + } if webBaseURL == "" { webBaseURL = s.cfg.CORSAllowedOrigin } + revision.WebBaseURL = webBaseURL + if revision.PublicBaseURL == "" { + revision.PublicBaseURL = s.cfg.PublicBaseURL + } + if revision.SessionAbsoluteSeconds <= 0 { + revision.SessionAbsoluteSeconds = 28800 + } var verifier oidcTokenVerifier if s.auth != nil { verifier = s.auth.OIDCVerifier } return &identityRequestRuntime{ - Revision: identity.Revision{ - State: identity.RevisionActive, Issuer: s.cfg.OIDCIssuer, LocalTenantKey: s.cfg.OIDCGatewayTenantKey, - WebBaseURL: webBaseURL, PublicBaseURL: s.cfg.PublicBaseURL, - JITEnabled: s.cfg.OIDCJITProvisioningEnabled, LegacyJWTEnabled: s.cfg.OIDCAcceptLegacyHS256, - SessionAbsoluteSeconds: s.cfg.OIDCSessionAbsoluteTTLSeconds, - }, + Revision: revision, Verifier: verifier, PublicClient: s.oidcClient, Sessions: s.oidcSessions, SessionCipher: s.oidcSessionCipher, SecurityEvents: s.securityEventManager, - CookieSecure: s.cfg.OIDCSessionCookieSecure, - BrowserEnabled: s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled, + CookieSecure: s.identityTestCookieSecure || strings.HasPrefix(strings.ToLower(revision.PublicBaseURL), "https://"), + BrowserEnabled: s.identityTestBrowserEnabled || s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil, } } diff --git a/apps/api/internal/httpapi/oidc_jit_integration_test.go b/apps/api/internal/httpapi/oidc_jit_integration_test.go index 2aa7b37..238d477 100644 --- a/apps/api/internal/httpapi/oidc_jit_integration_test.go +++ b/apps/api/internal/httpapi/oidc_jit_integration_test.go @@ -23,10 +23,14 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) +const oidcJITTenantID = "6e6d0a0f-8b08-41ca-bda6-f1a58f065bc3" + func TestOIDCJITFullGatewayUserFlowAndSecurityBoundary(t *testing.T) { databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) if databaseURL == "" { @@ -120,35 +124,32 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t }) baseConfig := config.Config{ - AppEnv: "test", - HTTPAddr: ":0", - DatabaseURL: databaseURL, - IdentityMode: "hybrid", - JWTSecret: "test-only-jwt-secret", - OIDCEnabled: true, - OIDCIssuer: issuer, - OIDCAudience: "gateway-api", - OIDCTenantID: "auth-center-test-tenant", - OIDCRolePrefix: "gateway.", - OIDCRequiredScopes: []string{"gateway.access"}, - OIDCJWKSCacheTTLSeconds: 60, - OIDCAcceptLegacyHS256: true, - OIDCJITProvisioningEnabled: true, - OIDCGatewayTenantKey: "default", - OIDCBrowserSessionEnabled: true, - OIDCSessionCookieSecure: false, - OIDCClientID: "gateway-public-test", - OIDCRedirectURI: "http://localhost/api/v1/auth/oidc/callback", - OIDCPostLogoutRedirectURI: "http://localhost:5178/", - OIDCSessionEncryptionKey: base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32)), - OIDCSessionIdleTTLSeconds: 1800, - OIDCSessionAbsoluteTTLSeconds: 28800, - OIDCSessionRefreshBeforeSeconds: 60, - LocalGeneratedStorageDir: t.TempDir(), - LocalUploadedStorageDir: t.TempDir(), - LocalTempAssetTTLHours: 1, - CORSAllowedOrigin: "http://localhost:5178", - TaskProgressCallbackEnabled: false, + AppEnv: "test", + HTTPAddr: ":0", + DatabaseURL: databaseURL, + IdentityMode: "hybrid", + JWTSecret: "test-only-jwt-secret", + IdentitySecretStore: "file", + IdentitySecretDir: t.TempDir(), + LocalGeneratedStorageDir: t.TempDir(), + LocalUploadedStorageDir: t.TempDir(), + LocalTempAssetTTLHours: 1, + CORSAllowedOrigin: "http://localhost:5178", + TaskProgressCallbackEnabled: false, + } + previous, previousErr := db.ActiveIdentityConfigurationRevision(ctx) + if previousErr != nil && !errors.Is(previousErr, identity.ErrRevisionNotFound) { + t.Fatalf("read previous active identity revision: %v", previousErr) + } + testRevisionIDs := make([]string, 0, 3) + t.Cleanup(func() { + restoreOIDCJITIdentityRevision(t, context.Background(), db, previous, previousErr == nil, testRevisionIDs) + }) + activeRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", true) + testRevisionIDs = append(testRevisionIDs, activeRevision.ID) + activeRevision, _, err = db.ActivateIdentityRevision(ctx, activeRevision.ID, activeRevision.Version, "oidc-jit-test", "oidc-jit-test") + if err != nil { + t.Fatalf("activate OIDC JIT identity revision: %v", err) } server := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) defer server.Close() @@ -246,17 +247,21 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t t.Fatalf("rejected OIDC tokens created %d local users", rejectedWrites) } - disabledJITConfig := baseConfig - disabledJITConfig.OIDCJITProvisioningEnabled = false - disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, disabledJITConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + disabledJITRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "default", false) + testRevisionIDs = append(testRevisionIDs, disabledJITRevision.ID) + disabledJITRevision, _, err = db.ActivateIdentityRevision(ctx, disabledJITRevision.ID, disabledJITRevision.Version, "oidc-jit-disabled", "oidc-jit-disabled") + if err != nil { + t.Fatalf("activate disabled-JIT revision: %v", err) + } + disabledJITServer := httptest.NewServer(NewServerWithContext(ctx, baseConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) defer disabledJITServer.Close() assertOIDCJITError(t, disabledJITServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[3], nil), http.StatusForbidden, errorCodeGatewayUserNotProvisioned) - missingTenantConfig := baseConfig - missingTenantConfig.OIDCGatewayTenantKey = "missing-tenant-" + suffix - missingTenantServer := httptest.NewServer(NewServerWithContext(ctx, missingTenantConfig, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) - defer missingTenantServer.Close() - assertOIDCJITError(t, missingTenantServer.URL, signedOIDCJITToken(t, key, issuer, rejectedSubjects[4], nil), http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable) + missingTenantRevision := prepareOIDCJITRevision(t, ctx, db, baseConfig, issuer, "missing-tenant-"+suffix, true) + testRevisionIDs = append(testRevisionIDs, missingTenantRevision.ID) + if _, _, activateErr := db.ActivateIdentityRevision(ctx, missingTenantRevision.ID, missingTenantRevision.Version, "oidc-jit-missing-tenant", "oidc-jit-missing-tenant"); !errors.Is(activateErr, identity.ErrLocalTenantInvalid) { + t.Fatalf("missing local tenant activation error = %v, want %v", activateErr, identity.ErrLocalTenantInvalid) + } if _, err := db.Pool().Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, me.GatewayUserID); err != nil { t.Fatalf("disable projected user: %v", err) @@ -271,6 +276,86 @@ DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = ANY($1::t assertOIDCJITError(t, server.URL, validToken, http.StatusForbidden, errorCodeGatewayUserDisabled) } +func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, cfg config.Config, issuer, localTenantKey string, jitEnabled bool) identity.Revision { + t.Helper() + draft, err := identity.NewDraft(identity.PairingInput{ + AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178", + LocalTenantKey: localTenantKey, LegacyJWTEnabled: true, + }) + if err != nil { + t.Fatalf("create OIDC JIT draft: %v", err) + } + draft.JITEnabled = jitEnabled + draft, err = db.CreateIdentityConfigurationRevision(ctx, draft) + if err != nil { + t.Fatalf("persist OIDC JIT draft: %v", err) + } + secrets, err := identitySecretStore(cfg) + if err != nil { + t.Fatalf("create identity SecretStore: %v", err) + } + sessionReference := "oidc-jit-session-" + draft.ID + if err := secrets.Put(ctx, sessionReference, bytes.Repeat([]byte{8}, 32)); err != nil { + t.Fatalf("store OIDC JIT session key: %v", err) + } + draft, err = db.ApplyIdentityManifest(ctx, draft.ID, draft.Version, identity.ManifestApplication{ + Manifest: identity.ManifestV1{ + SchemaVersion: 1, Issuer: issuer, TenantID: oidcJITTenantID, ApplicationID: uuid.NewString(), + Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"}, + Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}}, + }, + SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", + }) + if err != nil { + t.Fatalf("apply OIDC JIT manifest: %v", err) + } + draft, err = db.MarkIdentityRevisionValidated(ctx, draft.ID, draft.Version, "oidc-jit-test", "oidc-jit-test") + if err != nil { + t.Fatalf("validate OIDC JIT revision: %v", err) + } + return draft +} + +func restoreOIDCJITIdentityRevision(t *testing.T, ctx context.Context, db *store.Store, previous identity.Revision, hadPrevious bool, testRevisionIDs []string) { + t.Helper() + isTestRevision := func(id string) bool { + for _, testID := range testRevisionIDs { + if id == testID { + return true + } + } + return false + } + if active, err := db.ActiveIdentityConfigurationRevision(ctx); err == nil && isTestRevision(active.ID) { + if _, disableErr := db.DisableActiveIdentityRevision(ctx, active.Version, "oidc-jit-cleanup", "oidc-jit-cleanup"); disableErr != nil { + t.Errorf("disable test identity revision during cleanup: %v", disableErr) + return + } + } + if hadPrevious { + refreshed, err := db.IdentityConfigurationRevision(ctx, previous.ID) + if err != nil { + t.Errorf("reload previous identity revision during cleanup: %v", err) + return + } + if refreshed.State == identity.RevisionSuperseded { + refreshed, err = db.MarkIdentityRevisionValidated(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore") + if err == nil { + _, _, err = db.ActivateIdentityRevision(ctx, refreshed.ID, refreshed.Version, "oidc-jit-restore", "oidc-jit-restore") + } + if err != nil { + t.Errorf("restore previous identity revision: %v", err) + return + } + } + } + if len(testRevisionIDs) > 0 { + if _, err := db.Pool().Exec(ctx, `DELETE FROM gateway_identity_configuration_revisions WHERE id = ANY($1::uuid[])`, testRevisionIDs); err != nil { + t.Errorf("delete test identity revisions: %v", err) + } + } +} + var currentOIDCTestNonce string func createOIDCBFFSessionCookie(t *testing.T, baseURL string) *http.Cookie { @@ -364,7 +449,7 @@ func signedOIDCJITToken(t *testing.T, key *ecdsa.PrivateKey, issuer string, subj t.Helper() now := time.Now() claims := jwt.MapClaims{ - "iss": issuer, "aud": "gateway-api", "sub": subject, "tid": "auth-center-test-tenant", + "iss": issuer, "aud": "gateway-api", "sub": subject, "tid": oidcJITTenantID, "preferred_username": "oidc-jit-acceptance", "roles": []string{"gateway.admin"}, "scope": "openid gateway.access", "iat": now.Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Hour).Unix(), diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index 9c3d442..0c67a9a 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -14,6 +14,7 @@ import ( "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/oidcsession" ) @@ -21,10 +22,9 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) client := &fakeOIDCClient{authorizationURL: "https://auth.example.com/authorize?request=redacted"} server := &Server{ - cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, OIDCSessionCookieSecure: true}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: client, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + identityTestCookieSecure: true, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oidc/login?returnTo=%2Fworkspace%3Ftab%3Dwallet", nil) recorder := httptest.NewRecorder() @@ -50,7 +50,6 @@ func TestStartOIDCLoginSetsEncryptedLaxTransactionAndRedirectsWithPKCE(t *testin func TestStartOIDCLoginRejectsOpenRedirect(t *testing.T) { cipher, _ := oidcsession.NewCipher(bytes.Repeat([]byte{3}, 32)) server := &Server{ - cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } @@ -86,10 +85,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { t.Run(test.name, func(t *testing.T) { var logs bytes.Buffer server := &Server{ - cfg: config.Config{ - OIDCEnabled: true, OIDCBrowserSessionEnabled: true, - WebBaseURL: "http://localhost:5178", - }, + cfg: config.Config{WebBaseURL: "http://localhost:5178"}, auth: &auth.Authenticator{OIDCVerifier: &auth.OIDCVerifier{}}, oidcClient: &fakeOIDCClient{}, oidcSessions: &fakeOIDCSessions{}, oidcSessionCipher: cipher, logger: slog.New(slog.NewJSONHandler(&logs, nil)), @@ -134,7 +130,7 @@ func TestCompleteOIDCLoginReportsSafeTransactionFailureReason(t *testing.T) { func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { sessions := &fakeOIDCSessions{} - server := &Server{cfg: config.Config{OIDCSessionCookieSecure: true}, oidcSessions: sessions} + server := &Server{oidcSessions: sessions, identityTestCookieSecure: true} request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/oidc/session", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "opaque-session"}) recorder := httptest.NewRecorder() @@ -151,7 +147,11 @@ func TestDeleteOIDCBrowserSessionIsIdempotentAndExpiresCookie(t *testing.T) { } func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { - server := &Server{cfg: config.Config{OIDCEnabled: true, OIDCBrowserSessionEnabled: true, CORSAllowedOrigin: "https://gateway.example.com"}} + server := &Server{ + cfg: config.Config{CORSAllowedOrigin: "https://gateway.example.com"}, + identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"}, + identityTestBrowserEnabled: true, + } next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) handler := server.protectOIDCSessionCookie(next) for _, test := range []struct { @@ -182,7 +182,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { } func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { - server := &Server{cfg: config.Config{OIDCEnabled: false, OIDCBrowserSessionEnabled: true}} + server := &Server{} request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "irrelevant-cookie"}) recorder := httptest.NewRecorder() diff --git a/apps/api/internal/httpapi/oidc_user_middleware_test.go b/apps/api/internal/httpapi/oidc_user_middleware_test.go index 3cf80b8..35ad1a0 100644 --- a/apps/api/internal/httpapi/oidc_user_middleware_test.go +++ b/apps/api/internal/httpapi/oidc_user_middleware_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" - "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -41,10 +41,8 @@ func TestResolveGatewayUserAddsLocalOIDCContext(t *testing.T) { UserGroupID: "6dcf86f2-8eaf-4b43-8e69-181315db24f0", }}} server := &Server{ - cfg: config.Config{ - OIDCIssuer: "https://auth.test.example/realms/easyai", - OIDCGatewayTenantKey: "default", - OIDCJITProvisioningEnabled: true, + identityTestRevision: identity.Revision{ + Issuer: "https://auth.test.example/issuer", LocalTenantKey: "default", JITEnabled: true, }, oidcUserResolver: resolver, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), @@ -116,9 +114,9 @@ func TestResolveGatewayUserReturnsStructuredErrors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := &Server{ - cfg: config.Config{OIDCIssuer: "https://auth.test.example", OIDCGatewayTenantKey: "default"}, - oidcUserResolver: &fakeOIDCUserResolver{err: test.err}, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + identityTestRevision: identity.Revision{Issuer: "https://auth.test.example", LocalTenantKey: "default"}, + oidcUserResolver: &fakeOIDCUserResolver{err: test.err}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) request = request.WithContext(auth.WithUser(request.Context(), &auth.User{ diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go index f8b6a8c..22e11d9 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers_test.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" - "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" ) @@ -63,9 +63,9 @@ func TestSecurityEventConnectionWriteHeaders(t *testing.T) { } func TestSecurityEventPrerequisitesNeverExposeSecrets(t *testing.T) { - server := &Server{cfg: config.Config{ - OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer/shared", OIDCTenantID: "stable-tenant", - OIDCIntrospectionClientID: "gateway-machine", OIDCIntrospectionClientSecret: "must-not-escape", + server := &Server{identityTestRevision: identity.Revision{ + Issuer: "https://auth.example/issuer/shared", TenantID: "stable-tenant", + MachineClientID: "gateway-machine", MachineCredentialRef: "identity-machine-test", PublicBaseURL: "https://gateway.example", }} payload, err := json.Marshal(server.securityEventPrerequisites()) diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 24d4389..b6050f7 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -20,23 +20,26 @@ import ( ) type Server struct { - ctx context.Context - cfg config.Config - store *store.Store - oidcUserResolver oidcUserResolver - auth *auth.Authenticator - oidcClient oidcPublicClient - oidcSessions oidcSessionManager - oidcSessionCipher *oidcsession.Cipher - runner *runner.Service - logger *slog.Logger - geminiUploadSessions sync.Map - securityEventReceiver http.Handler - securityEventManager *ssfreceiver.ConnectionManager - identityRuntime *identityruntime.Manager - identityPairing *identity.PairingService - identityManagementMu sync.Mutex - identityPairingWorkers sync.Map + ctx context.Context + cfg config.Config + store *store.Store + oidcUserResolver oidcUserResolver + auth *auth.Authenticator + oidcClient oidcPublicClient + oidcSessions oidcSessionManager + oidcSessionCipher *oidcsession.Cipher + runner *runner.Service + logger *slog.Logger + geminiUploadSessions sync.Map + securityEventReceiver http.Handler + securityEventManager *ssfreceiver.ConnectionManager + identityRuntime *identityruntime.Manager + identityPairing *identity.PairingService + identityManagementMu sync.Mutex + identityPairingWorkers sync.Map + identityTestRevision identity.Revision + identityTestCookieSecure bool + identityTestBrowserEnabled bool } type oidcPublicClient interface { @@ -72,15 +75,15 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret securityEventMetrics := &ssfreceiver.Metrics{} - secretStore, err := securityEventSecretStore(cfg) + secretStore, err := identitySecretStore(cfg) if err != nil { panic("invalid identity SecretStore: " + err.Error()) } runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, - HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second, - StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second, - ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second, + HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second, + StaleAfter: time.Duration(cfg.IdentitySecurityEventStaleAfterSeconds) * time.Second, + ClockSkew: time.Duration(cfg.IdentitySecurityEventClockSkewSeconds) * time.Second, }, securityEventMetrics) server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder) if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil { @@ -294,22 +297,22 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor return server.recover(server.cors(server.protectOIDCSessionCookie(mux))) } -func securityEventSecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) { - switch strings.ToLower(strings.TrimSpace(cfg.OIDCSecurityEventsSecretStore)) { +func identitySecretStore(cfg config.Config) (ssfreceiver.SecretStore, error) { + switch strings.ToLower(strings.TrimSpace(cfg.IdentitySecretStore)) { case "", "file": - directory := cfg.OIDCSecurityEventsSecretDir + directory := cfg.IdentitySecretDir if directory == "" { - directory = ".local-secrets/ssf" + directory = ".local-secrets/identity" } return ssfreceiver.NewFileSecretStore(directory) case "kubernetes": return ssfreceiver.NewKubernetesSecretStore(ssfreceiver.KubernetesSecretStoreConfig{ - Namespace: cfg.OIDCSecurityEventsKubernetesNamespace, SecretName: cfg.OIDCSecurityEventsKubernetesSecretName, - APIServer: cfg.OIDCSecurityEventsKubernetesAPIServer, TokenFile: cfg.OIDCSecurityEventsKubernetesTokenFile, - CAFile: cfg.OIDCSecurityEventsKubernetesCAFile, + Namespace: cfg.IdentityKubernetesNamespace, SecretName: cfg.IdentityKubernetesSecretName, + APIServer: cfg.IdentityKubernetesAPIServer, TokenFile: cfg.IdentityKubernetesTokenFile, + CAFile: cfg.IdentityKubernetesCAFile, }) default: - return nil, errors.New("unsupported OIDC security event SecretStore") + return nil, errors.New("unsupported identity SecretStore") } } diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx index 100af13..8cda435 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.test.tsx @@ -9,6 +9,7 @@ describe('UnifiedIdentityPanel', () => { expect(html).toContain('Auth Center 地址'); expect(html).toContain('一次性接入码'); expect(html).toContain('Gateway API 公网地址'); + expect(html).not.toContain('value="/gateway-api"'); expect(html).toContain('Gateway Web 地址'); expect(html).toContain('Gateway 本地租户映射'); expect(html).not.toContain('Machine Client Secret'); diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx index 1ee7083..3ae4700 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -283,10 +283,11 @@ function IdentityHealth({ label, value }: { label: string; value: string }) { function defaultPairingInput(): IdentityPairingInput { const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin; + const configuredApiBaseUrl = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''); return { authCenterUrl: 'https://auth.51easyai.com', onboardingCode: '', - publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''), + publicBaseUrl: /^https?:\/\//i.test(configuredApiBaseUrl) ? configuredApiBaseUrl : '', webBaseUrl, localTenantKey: 'default', legacyJwtEnabled: false, diff --git a/docker-compose.yml b/docker-compose.yml index 3e0852f..7422408 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,33 +6,11 @@ x-api-environment: &api-environment AI_GATEWAY_DATABASE_URL: ${AI_GATEWAY_COMPOSE_DATABASE_URL:-postgresql://easyai:easyai2025@postgres:5432/easyai_ai_gateway?sslmode=disable} CONFIG_JWT_SECRET: ${CONFIG_JWT_SECRET:-this is a very secret secret} IDENTITY_MODE: ${AI_GATEWAY_COMPOSE_IDENTITY_MODE:-hybrid} - OIDC_ENABLED: ${OIDC_ENABLED:-false} - OIDC_ISSUER: ${OIDC_ISSUER:-} - OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} - OIDC_TENANT_ID: ${OIDC_TENANT_ID:-} - OIDC_ROLE_PREFIX: ${OIDC_ROLE_PREFIX:-gateway.} - OIDC_REQUIRED_SCOPES: ${OIDC_REQUIRED_SCOPES:-gateway.access} - OIDC_JWKS_CACHE_TTL_SECONDS: ${OIDC_JWKS_CACHE_TTL_SECONDS:-300} - OIDC_ACCEPT_LEGACY_HS256: ${OIDC_ACCEPT_LEGACY_HS256:-true} - OIDC_INTROSPECTION_ENABLED: ${OIDC_INTROSPECTION_ENABLED:-false} - OIDC_INTROSPECTION_CLIENT_ID: ${OIDC_INTROSPECTION_CLIENT_ID:-} - OIDC_INTROSPECTION_CLIENT_SECRET: ${OIDC_INTROSPECTION_CLIENT_SECRET:-} - OIDC_SECURITY_EVENTS_SECRET_STORE: file - OIDC_SECURITY_EVENTS_SECRET_DIR: /app/data/security-events/secrets - OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} - OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} - OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} - OIDC_JIT_PROVISIONING_ENABLED: ${OIDC_JIT_PROVISIONING_ENABLED:-false} - OIDC_GATEWAY_TENANT_KEY: ${OIDC_GATEWAY_TENANT_KEY:-} - OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} - OIDC_SESSION_COOKIE_SECURE: ${OIDC_SESSION_COOKIE_SECURE:-} - OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} - OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} - OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI:-} - OIDC_SESSION_ENCRYPTION_KEY: ${OIDC_SESSION_ENCRYPTION_KEY:-} - OIDC_SESSION_IDLE_TTL_SECONDS: ${OIDC_SESSION_IDLE_TTL_SECONDS:-1800} - OIDC_SESSION_ABSOLUTE_TTL_SECONDS: ${OIDC_SESSION_ABSOLUTE_TTL_SECONDS:-28800} - OIDC_SESSION_REFRESH_BEFORE_SECONDS: ${OIDC_SESSION_REFRESH_BEFORE_SECONDS:-60} + IDENTITY_SECRET_STORE: file + IDENTITY_SECRET_DIR: /app/data/identity/secrets + IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS: ${IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS:-60} + IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS: ${IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS:-180} + IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS: ${IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS:-60} SERVER_MAIN_BASE_URL: ${AI_GATEWAY_COMPOSE_SERVER_MAIN_BASE_URL:-http://host.docker.internal:3000} SERVER_MAIN_INTERNAL_TOKEN: ${SERVER_MAIN_INTERNAL_TOKEN:-change-me} SERVER_MAIN_INTERNAL_KEY: ${SERVER_MAIN_INTERNAL_KEY:-gateway} @@ -127,8 +105,6 @@ services: VITE_GATEWAY_API_BASE_URL: ${AI_GATEWAY_WEB_API_BASE_URL:-/gateway-api} NODE_BUILD_IMAGE: ${AI_GATEWAY_NODE_BUILD_IMAGE:-node:22-alpine} WEB_RUNTIME_IMAGE: ${AI_GATEWAY_WEB_RUNTIME_IMAGE:-nginx:1.27-alpine} - VITE_OIDC_ENABLED: ${OIDC_ENABLED:-false} - VITE_OIDC_BROWSER_SESSION_ENABLED: ${OIDC_BROWSER_SESSION_ENABLED:-true} VITE_BASE_PATH: ${AI_GATEWAY_WEB_BASE_PATH:-/} ports: - "${AI_GATEWAY_WEB_PORT:-5178}:80" diff --git a/docs/oidc-jit-provisioning.md b/docs/oidc-jit-provisioning.md index bf2dea5..770d1c2 100644 --- a/docs/oidc-jit-provisioning.md +++ b/docs/oidc-jit-provisioning.md @@ -4,28 +4,15 @@ Gateway 只在 OIDC Token 已通过签名、Issuer、Audience、有效期、`tid`、Scope 和应用角色校验后执行 JIT。认证中心的稳定 `sub` 是外部用户标识;Gateway 不读取、不保存或公开 Keycloak 内部 ID,也不向 Token 增加 `gatewayUserId`。 -本次保持单 Gateway 租户:部署方用 `OIDC_GATEWAY_TENANT_KEY` 把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。 +本次保持单 Gateway 租户:管理员在统一认证 Draft 中把 Auth Center 的已验证 `tid` 显式绑定到一个已存在、启用且配置了启用中默认用户组的 Gateway 租户。Token 不能创建租户。 ## 配置 -```dotenv -OIDC_ENABLED=true -OIDC_JIT_PROVISIONING_ENABLED=true -OIDC_GATEWAY_TENANT_KEY=default -OIDC_BROWSER_SESSION_ENABLED=true -OIDC_SESSION_COOKIE_SECURE=false # 本地 HTTP;生产必须为 true -OIDC_CLIENT_ID= -OIDC_REDIRECT_URI=http://localhost:8088/api/v1/auth/oidc/callback -OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5178/ -OIDC_SESSION_ENCRYPTION_KEY=<独立的 32 字节随机密钥,base64 编码> -OIDC_SESSION_IDLE_TTL_SECONDS=1800 -OIDC_SESSION_ABSOLUTE_TTL_SECONDS=28800 -OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 -``` +OIDC、JIT 和浏览器会话不读取业务环境变量。管理员在 Auth Center 的通用“应用接入”向导选择 `OIDC 登录` 与 `API 验证`,生成一次性接入码;再到 Gateway“系统设置 → 统一认证”提交认证中心地址、接入码、Gateway API/Web 地址和本地租户映射。系统自动推导回调及退出地址,生成 Session 加密密钥并保存到部署级 SecretStore。 -- `OIDC_JIT_PROVISIONING_ENABLED` 默认 `false`。关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。 -- JIT 开启时 `OIDC_GATEWAY_TENANT_KEY` 必填,缺失会使 Gateway 启动失败。 -- 本地与 Staging 验收环境应在各自 Git 忽略或 Secret 管理的环境文件中显式开启;生产启用需独立变更审批。 +- JIT、Legacy JWT 兼容和 Session 时限属于 Revision 策略,可在 Draft 中修改;默认闲置 30 分钟、绝对 8 小时、提前 60 秒刷新。 +- JIT 关闭时停止创建新用户,但已有 `source=oidc + external_user_id=sub` 映射仍会解析和同步登录时间。 +- 本地租户不存在、公共 Client 不可用或 Discovery/Issuer/JWKS 校验失败时,Revision 不能激活,当前 Active Runtime 不受影响。 ## Web Console 浏览器会话 @@ -35,9 +22,9 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 - Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。 - Session 闲置 30 分钟、绝对最长 8 小时。闲置 5~30 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。 - 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin,否则返回结构化 403。 -- Staging、生产及其他非本地环境启动时强制 `OIDC_SESSION_COOKIE_SECURE=true`,并拒绝带 `*` 的凭据型 CORS 配置。本地 HTTP 开发和自动化测试可以显式设为 `false`。 +- Cookie 的 `Secure` 属性由 Revision 中的 Gateway API 公网地址推导:生产地址只允许 HTTPS;本地开发允许 localhost HTTP。可信 Origin 由 Gateway Web 地址精确推导,不接受 `*`。 - `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token,并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口。 -- `OIDC_SESSION_ENCRYPTION_KEY` 必须来自 Git 忽略的本地文件、Kubernetes Secret 或 Secret Manager,不得复用 JWT Secret。关闭 `OIDC_BROWSER_SESSION_ENABLED` 可停止新会话;回滚镜像后已创建记录作为惰性数据保留。 +- Session Encryption Key 由 Gateway 服务端生成,只以 Secret 引用进入 Revision,不得复用 JWT Secret,也不会进入数据库、响应、日志或浏览器。禁用统一认证会清理现有 BFF Session;回滚后用户需要重新登录。 ## 数据和事务语义 @@ -70,4 +57,4 @@ OIDC_SESSION_REFRESH_BEFORE_SECONDS=60 自动化门禁通过后,依次验证本地真实 OIDC 和 `auth.51easyai.com` Staging 专用测试租户。证据应包含脱敏 Claims、网络截图、本地 Gateway 用户记录、Trace ID、Gateway/Auth Center 审计 ID及 200/401/403/503 负向证据;不得保存 Token、授权码、密码或 Secret。 -JIT 回滚时关闭 `OIDC_JIT_PROVISIONING_ENABLED` 并回退 Gateway 镜像。浏览器 Cookie 会话可通过后端和 Web 两侧的独立开关关闭。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 +JIT 策略变更通过创建并验证新 Revision 后激活;身份配置回滚使用“系统设置 → 统一认证”的回滚操作。激活、回滚和禁用均要求本地 Break-glass Manager 可用,并清理受影响的 BFF Session。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index 7133aa3..f832f8d 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -1,36 +1,35 @@ # SSF/CAEP 实时会话撤销运行手册 -Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。功能由数据库中的连接资源控制:没有连接时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为;不再使用 `OIDC_SECURITY_EVENTS_ENABLED`。 +Gateway 可选接收 Auth Center 通过 RFC 8935 Push 投递的 RFC 8417 Security Event Token,并处理 OpenID CAEP `session-revoked`。能力由 Active Identity Revision 控制;没有选择会话撤销时保持原有 OIDC、BFF、API Key 和 Legacy JWT 行为。 ## 用户接入流程 -用户只执行两项操作: +首次接入只执行两端各一次操作: -1. 在认证中心 Application 的“安全事件流”页面选择现有 RFC 7662 机器 Client,点击“准备 Gateway 接入”。认证中心自动合并 SSF 管理 Scope;不会创建第二个 Client。 -2. Receiver 就绪后点击“生成一次性连接凭据”,立即复制 machine Client ID/Secret。 -3. 在 Gateway“系统设置 → 认证中心安全事件”中填写 SSF Issuer 和这组一次性凭据,点击“连接认证中心”。 +1. 在认证中心 Application 的通用“应用接入”向导选择“SSF 会话撤销”。能力依赖会自动包含 Token Introspection 和机器调用;预览确认后创建或复用同用途服务客户端。 +2. 在 Gateway“系统设置 → 统一认证”填写一次性接入码和 Gateway 地址。Gateway 自动领取 Manifest 与机器凭据,并在验证候选 Runtime 时建立 SSF Stream。 -Gateway 后端先把 machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。浏览器只在两端连接表单的短暂操作期间接触 machine Secret;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 +Gateway 后端先把 Machine Secret 写入 SecretStore,随后自动读取 Discovery、生成并托管 256 bit Push Bearer、创建 paused Stream、完成 Verification、首次启用 Stream,并进入 360 秒 RFC 7662 bootstrap。接入码和 Machine Secret 只从浏览器提交到 Gateway 服务端;Push Bearer 和 Secret 引用始终不可见,数据库、响应和日志不保存明文。管理员不编辑 Secret 文件,也不需要重启 Gateway。 -环境前置配置只保留 OIDC 公共参数和 Gateway 公共地址: +环境前置配置只保留 SecretStore 与运行时基础设施参数: -- `OIDC_ISSUER`、`OIDC_TENANT_ID`; -- `AI_GATEWAY_PUBLIC_BASE_URL`,生产必须是认证中心可访问的 HTTPS 地址。 +- `IDENTITY_SECRET_STORE`、`IDENTITY_SECRET_DIR`,或对应 Kubernetes SecretStore 参数; +- `IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS`、`IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS` 和 `IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS`。 -`OIDC_INTROSPECTION_CLIENT_ID/SECRET` 仅作为旧部署兼容回退,不再是新连接必填项。新连接把 machine 凭据动态托管到 SecretStore,OIDC fallback、SSF 管理 Token 和 Verification 共用这一份凭据,重启后继续生效。 +Issuer、Tenant、Gateway 公网地址和机器 Client 均来自 Active Revision,不读取旧 OIDC 环境变量。OIDC fallback、SSF 管理 Token 和 Verification 共用一份托管机器凭据,重启后继续生效。 第一版一个 Gateway 部署只允许一个认证中心连接。下游业务服务无需接入 SSF。 ## SecretStore -本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`;Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 `ssf-delivery-*` 文件。 +本地和 Docker 使用 `file` 驱动。服务自动创建目录并强制目录 `0700`、文件 `0600`;Docker Compose 将目录放在持久化 `api_data` 卷中。用户不写入任何 Secret 文件。 Kubernetes 使用 `kubernetes` 驱动和一个部署时预创建的空 Secret: ```text -OIDC_SECURITY_EVENTS_SECRET_STORE=kubernetes -OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE=easyai -OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME=easyai-gateway-security-events +IDENTITY_SECRET_STORE=kubernetes +IDENTITY_KUBERNETES_NAMESPACE=easyai +IDENTITY_KUBERNETES_SECRET_NAME=easyai-gateway-identity ``` 参考清单见 `deploy/kubernetes/security-events-secret-rbac.yaml`。ServiceAccount 只能对这个固定 Secret 执行 `get/update/patch`,不能创建、删除或读取其他 Secret。数据库、API、浏览器、日志和审计只保存或展示非敏感连接元数据。 diff --git a/docs/standard-identity-runtime-configuration.md b/docs/standard-identity-runtime-configuration.md index 45865d2..0638059 100644 --- a/docs/standard-identity-runtime-configuration.md +++ b/docs/standard-identity-runtime-configuration.md @@ -8,7 +8,7 @@ Accepted,2026-07-17。 Gateway 是标准应用接入协议的首个消费方。认证中心保持消费方无关;“统一认证”页面、Gateway 本地租户映射、Legacy JWT 兼容和 Session 策略均属于 Gateway 自己的管理边界。 -OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/数据库为唯一来源。数据库、SecretStore、Session 加密根、Bootstrap 管理凭据、TLS 与 RBAC 仍由部署环境提供。开发阶段不读取或迁移旧 `OIDC_*`、`VITE_OIDC_*` 业务变量,升级后由管理员重新配对。 +OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/数据库为唯一来源。数据库、SecretStore、Bootstrap 管理凭据、TLS 与 RBAC 仍由部署环境提供;Machine Credential 和 Session Encryption Key 由服务端生成或领取,只存入 SecretStore,Revision 仅保存引用。开发阶段不读取或迁移旧 `OIDC_*`、`VITE_OIDC_*` 业务变量,升级后由管理员重新配对。 ## Revision 状态机 @@ -50,3 +50,17 @@ active -> superseded (回滚、替换或禁用) ## 前端运行时配置 Web 前端启动后读取公开只读统一认证状态,不使用 `VITE_OIDC_*` 构建变量。安全事件作为统一认证能力状态显示,原独立页面只保留运行运维动作。 + +## 部署级配置 + +允许继续由部署环境提供的统一认证相关参数仅限 SecretStore 与基础设施健康窗口: + +```dotenv +IDENTITY_SECRET_STORE=file +IDENTITY_SECRET_DIR=.local-secrets/identity +IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 +IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 +IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 +``` + +Kubernetes 部署改用 `IDENTITY_KUBERNETES_NAMESPACE`、`IDENTITY_KUBERNETES_SECRET_NAME`、`IDENTITY_KUBERNETES_API_SERVER`、`IDENTITY_KUBERNETES_TOKEN_FILE` 和 `IDENTITY_KUBERNETES_CA_FILE`。这些参数不包含 Issuer、Audience、Scope、Client ID、Machine Secret 或业务开关。 diff --git a/scripts/dev.sh b/scripts/dev.sh index 8c2e173..c64c96e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -20,9 +20,6 @@ load_local_env() { export "$key=$value" done < "$env_file" done - - export VITE_OIDC_ENABLED="${VITE_OIDC_ENABLED:-${OIDC_ENABLED:-false}}" - export VITE_OIDC_BROWSER_SESSION_ENABLED="${VITE_OIDC_BROWSER_SESSION_ENABLED:-${OIDC_BROWSER_SESSION_ENABLED:-true}}" } load_local_env From cdfca613044f40b8280fa1996e046a6a14e6cfa1 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 12:31:11 +0800 Subject: [PATCH 17/20] =?UTF-8?q?docs(identity):=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E7=AE=A1=E7=90=86=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为配对、策略、验证、激活、回滚、禁用和公开运行时接口补充 OpenAPI 注解,并将通用 Identity 类型加入生成范围。生成契约不包含 Exchange Token、Machine Secret 或 Secret 引用。\n\n验证:pnpm openapi;OpenAPI 敏感字段扫描通过 --- apps/api/docs/swagger.json | 905 ++++++++++++++++++ apps/api/docs/swagger.yaml | 596 ++++++++++++ .../identity_configuration_handlers.go | 124 +++ apps/api/project.json | 2 +- 4 files changed, 1626 insertions(+), 1 deletion(-) diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 5515870..22c082c 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -2868,6 +2868,575 @@ } } }, + "/api/admin/system/identity/configuration": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "获取统一认证配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.identityConfigurationView" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/disable": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "禁用统一认证", + "parameters": [ + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Active Revision ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.Revision" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/pairings": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "启动统一认证配对", + "parameters": [ + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "首次配对固定为 W/\\", + "name": "If-Match", + "in": "header", + "required": true + }, + { + "description": "配对参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/identity.PairingInput" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/identity.PairingExchange" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/pairings/{pairingID}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "查询统一认证配对状态", + "parameters": [ + { + "type": "string", + "description": "配对 ID", + "name": "pairingID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.PairingExchange" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/revisions/{revisionID}": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "修改统一认证 Draft 策略", + "parameters": [ + { + "type": "string", + "description": "Revision ID", + "name": "revisionID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Revision ETag", + "name": "If-Match", + "in": "header", + "required": true + }, + { + "description": "Gateway 本地策略", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.identityPolicyPatch" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.Revision" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/revisions/{revisionID}/activate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "激活统一认证 Revision", + "parameters": [ + { + "type": "string", + "description": "Revision ID", + "name": "revisionID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Revision ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.Revision" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/revisions/{revisionID}/rollback": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "回滚统一认证 Revision", + "parameters": [ + { + "type": "string", + "description": "Revision ID", + "name": "revisionID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Revision ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.Revision" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/revisions/{revisionID}/validate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "验证统一认证 Revision", + "parameters": [ + { + "type": "string", + "description": "Revision ID", + "name": "revisionID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Revision ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/identity.Revision" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/admin/tenants": { "get": { "security": [ @@ -5320,6 +5889,26 @@ } } }, + "/api/v1/public/identity": { + "get": { + "description": "返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "获取公开统一认证状态", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.publicIdentityConfiguration" + } + } + } + } + }, "/api/v1/reranks": { "post": { "security": [ @@ -10742,6 +11331,104 @@ } } }, + "httpapi.identityConfigurationView": { + "type": "object", + "properties": { + "active": { + "$ref": "#/definitions/identity.Revision" + }, + "draft": { + "$ref": "#/definitions/identity.Revision" + }, + "pairing": { + "$ref": "#/definitions/identity.PairingExchange" + }, + "previous": { + "$ref": "#/definitions/identity.Revision" + }, + "runtime": { + "$ref": "#/definitions/httpapi.identityRuntimeStatus" + } + } + }, + "httpapi.identityPolicyPatch": { + "type": "object", + "properties": { + "jitEnabled": { + "type": "boolean" + }, + "legacyJwtEnabled": { + "type": "boolean" + }, + "localTenantKey": { + "type": "string" + }, + "rolePrefix": { + "type": "string" + }, + "sessionAbsoluteSeconds": { + "type": "integer" + }, + "sessionIdleSeconds": { + "type": "integer" + }, + "sessionRefreshSeconds": { + "type": "integer" + } + } + }, + "httpapi.identityRuntimeStatus": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "jit": { + "type": "string" + }, + "lastAuditId": { + "type": "string" + }, + "lastErrorCategory": { + "type": "string" + }, + "lastTraceId": { + "type": "string" + }, + "login": { + "type": "string" + }, + "revisionId": { + "type": "string" + }, + "sessionRevocation": { + "type": "string" + }, + "tokenIntrospection": { + "type": "string" + } + } + }, + "httpapi.publicIdentityConfiguration": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "loginUrl": { + "type": "string" + }, + "logoutUrl": { + "type": "string" + }, + "oidcLogin": { + "type": "boolean" + }, + "status": { + "type": "string" + } + } + }, "httpapi.updatePlatformDynamicPriorityRequest": { "type": "object", "properties": { @@ -10805,6 +11492,224 @@ } } }, + "identity.PairingExchange": { + "type": "object", + "properties": { + "authCenterAuditId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastErrorCategory": { + "type": "string" + }, + "lastTraceId": { + "type": "string" + }, + "remoteExchangeId": { + "type": "string" + }, + "remoteVersion": { + "type": "integer" + }, + "revisionId": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/identity.PairingStatus" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, + "identity.PairingInput": { + "type": "object", + "properties": { + "authCenterUrl": { + "type": "string" + }, + "legacyJwtEnabled": { + "type": "boolean" + }, + "localTenantKey": { + "type": "string" + }, + "onboardingCode": { + "type": "string" + }, + "publicBaseUrl": { + "type": "string" + }, + "webBaseUrl": { + "type": "string" + } + } + }, + "identity.PairingStatus": { + "type": "string", + "enum": [ + "metadata_pending", + "preparing", + "ready", + "credentials_saved", + "completed", + "failed", + "expired" + ], + "x-enum-varnames": [ + "PairingMetadataPending", + "PairingPreparing", + "PairingReady", + "PairingCredentialsSaved", + "PairingCompleted", + "PairingFailed", + "PairingExpired" + ] + }, + "identity.Revision": { + "type": "object", + "properties": { + "activatedAt": { + "type": "string" + }, + "applicationId": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "authCenterUrl": { + "type": "string" + }, + "browserClientId": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "jitEnabled": { + "type": "boolean" + }, + "lastAuditId": { + "type": "string" + }, + "lastErrorCategory": { + "type": "string" + }, + "lastTraceId": { + "type": "string" + }, + "legacyJwtEnabled": { + "type": "boolean" + }, + "localTenantKey": { + "type": "string" + }, + "machineClientId": { + "type": "string" + }, + "publicBaseUrl": { + "type": "string" + }, + "rolePrefix": { + "type": "string" + }, + "schemaVersion": { + "type": "integer" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "securityEventAudience": { + "type": "string" + }, + "securityEventConfigurationUrl": { + "type": "string" + }, + "securityEventIssuer": { + "type": "string" + }, + "sessionAbsoluteSeconds": { + "type": "integer" + }, + "sessionIdleSeconds": { + "type": "integer" + }, + "sessionRefreshSeconds": { + "type": "integer" + }, + "sessionRevocation": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/identity.RevisionState" + }, + "supersededAt": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "tokenIntrospection": { + "type": "boolean" + }, + "updatedAt": { + "type": "string" + }, + "validatedAt": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "webBaseUrl": { + "type": "string" + } + } + }, + "identity.RevisionState": { + "type": "string", + "enum": [ + "draft", + "validated", + "active", + "superseded", + "failed" + ], + "x-enum-varnames": [ + "RevisionDraft", + "RevisionValidated", + "RevisionActive", + "RevisionSuperseded", + "RevisionFailed" + ] + }, "store.APIKey": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 1db16ed..9ba8cf4 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -823,6 +823,70 @@ definitions: billing: $ref: '#/definitions/httpapi.desktopBillingConfigResponse' type: object + httpapi.identityConfigurationView: + properties: + active: + $ref: '#/definitions/identity.Revision' + draft: + $ref: '#/definitions/identity.Revision' + pairing: + $ref: '#/definitions/identity.PairingExchange' + previous: + $ref: '#/definitions/identity.Revision' + runtime: + $ref: '#/definitions/httpapi.identityRuntimeStatus' + type: object + httpapi.identityPolicyPatch: + properties: + jitEnabled: + type: boolean + legacyJwtEnabled: + type: boolean + localTenantKey: + type: string + rolePrefix: + type: string + sessionAbsoluteSeconds: + type: integer + sessionIdleSeconds: + type: integer + sessionRefreshSeconds: + type: integer + type: object + httpapi.identityRuntimeStatus: + properties: + enabled: + type: boolean + jit: + type: string + lastAuditId: + type: string + lastErrorCategory: + type: string + lastTraceId: + type: string + login: + type: string + revisionId: + type: string + sessionRevocation: + type: string + tokenIntrospection: + type: string + type: object + httpapi.publicIdentityConfiguration: + properties: + enabled: + type: boolean + loginUrl: + type: string + logoutUrl: + type: string + oidcLogin: + type: boolean + status: + type: string + type: object httpapi.updatePlatformDynamicPriorityRequest: properties: dynamicPriority: @@ -868,6 +932,157 @@ definitions: example: manual recharge type: string type: object + identity.PairingExchange: + properties: + authCenterAuditId: + type: string + createdAt: + type: string + expiresAt: + type: string + id: + type: string + lastErrorCategory: + type: string + lastTraceId: + type: string + remoteExchangeId: + type: string + remoteVersion: + type: integer + revisionId: + type: string + status: + $ref: '#/definitions/identity.PairingStatus' + updatedAt: + type: string + version: + type: integer + type: object + identity.PairingInput: + properties: + authCenterUrl: + type: string + legacyJwtEnabled: + type: boolean + localTenantKey: + type: string + onboardingCode: + type: string + publicBaseUrl: + type: string + webBaseUrl: + type: string + type: object + identity.PairingStatus: + enum: + - metadata_pending + - preparing + - ready + - credentials_saved + - completed + - failed + - expired + type: string + x-enum-varnames: + - PairingMetadataPending + - PairingPreparing + - PairingReady + - PairingCredentialsSaved + - PairingCompleted + - PairingFailed + - PairingExpired + identity.Revision: + properties: + activatedAt: + type: string + applicationId: + type: string + audience: + type: string + authCenterUrl: + type: string + browserClientId: + type: string + capabilities: + items: + type: string + type: array + createdAt: + type: string + id: + type: string + issuer: + type: string + jitEnabled: + type: boolean + lastAuditId: + type: string + lastErrorCategory: + type: string + lastTraceId: + type: string + legacyJwtEnabled: + type: boolean + localTenantKey: + type: string + machineClientId: + type: string + publicBaseUrl: + type: string + rolePrefix: + type: string + schemaVersion: + type: integer + scopes: + items: + type: string + type: array + securityEventAudience: + type: string + securityEventConfigurationUrl: + type: string + securityEventIssuer: + type: string + sessionAbsoluteSeconds: + type: integer + sessionIdleSeconds: + type: integer + sessionRefreshSeconds: + type: integer + sessionRevocation: + type: boolean + state: + $ref: '#/definitions/identity.RevisionState' + supersededAt: + type: string + tenantId: + type: string + tokenIntrospection: + type: boolean + updatedAt: + type: string + validatedAt: + type: string + version: + type: integer + webBaseUrl: + type: string + type: object + identity.RevisionState: + enum: + - draft + - validated + - active + - superseded + - failed + type: string + x-enum-varnames: + - RevisionDraft + - RevisionValidated + - RevisionActive + - RevisionSuperseded + - RevisionFailed store.APIKey: properties: createdAt: @@ -4372,6 +4587,374 @@ paths: summary: 更新文件存储设置 tags: - system + /api/admin/system/identity/configuration: + get: + description: 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.identityConfigurationView' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 获取统一认证配置 + tags: + - identity + /api/admin/system/identity/disable: + post: + description: 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。 + parameters: + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Active Revision ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.Revision' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 禁用统一认证 + tags: + - identity + /api/admin/system/identity/pairings: + post: + consumes: + - application/json + description: 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。 + parameters: + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 首次配对固定为 W/\ + in: header + name: If-Match + required: true + type: string + - description: 配对参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/identity.PairingInput' + produces: + - application/json + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/identity.PairingExchange' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 启动统一认证配对 + tags: + - identity + /api/admin/system/identity/pairings/{pairingID}: + get: + parameters: + - description: 配对 ID + in: path + name: pairingID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.PairingExchange' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 查询统一认证配对状态 + tags: + - identity + /api/admin/system/identity/revisions/{revisionID}: + patch: + consumes: + - application/json + parameters: + - description: Revision ID + in: path + name: revisionID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Revision ETag + in: header + name: If-Match + required: true + type: string + - description: Gateway 本地策略 + in: body + name: body + required: true + schema: + $ref: '#/definitions/httpapi.identityPolicyPatch' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.Revision' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 修改统一认证 Draft 策略 + tags: + - identity + /api/admin/system/identity/revisions/{revisionID}/activate: + post: + description: 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。 + parameters: + - description: Revision ID + in: path + name: revisionID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Revision ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.Revision' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 激活统一认证 Revision + tags: + - identity + /api/admin/system/identity/revisions/{revisionID}/rollback: + post: + parameters: + - description: Revision ID + in: path + name: revisionID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Revision ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.Revision' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 回滚统一认证 Revision + tags: + - identity + /api/admin/system/identity/revisions/{revisionID}/validate: + post: + parameters: + - description: Revision ID + in: path + name: revisionID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Revision ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/identity.Revision' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 验证统一认证 Revision + tags: + - identity /api/admin/tenants: get: description: 管理端返回网关租户列表。 @@ -5951,6 +6534,19 @@ paths: summary: 获取公开客户端自定义设置 tags: - system + /api/v1/public/identity: + get: + description: 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.publicIdentityConfiguration' + summary: 获取公开统一认证状态 + tags: + - identity /api/v1/reranks: post: consumes: diff --git a/apps/api/internal/httpapi/identity_configuration_handlers.go b/apps/api/internal/httpapi/identity_configuration_handlers.go index aedf60a..06d96ae 100644 --- a/apps/api/internal/httpapi/identity_configuration_handlers.go +++ b/apps/api/internal/httpapi/identity_configuration_handlers.go @@ -74,6 +74,13 @@ func (operation *identityWriteOperation) close() { } } +// getPublicIdentityConfiguration godoc +// @Summary 获取公开统一认证状态 +// @Description 返回 Web 运行时需要的启用状态和非敏感登录、退出入口,不返回 Client、Secret 或内部标识。 +// @Tags identity +// @Produce json +// @Success 200 {object} publicIdentityConfiguration +// @Router /api/v1/public/identity [get] func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Cache-Control", "no-store") runtime := s.currentIdentityRuntime() @@ -90,6 +97,17 @@ func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.R writeJSON(w, http.StatusOK, view) } +// getIdentityConfiguration godoc +// @Summary 获取统一认证配置 +// @Description 返回 Active、Draft、Previous Revision、配对进度和脱敏运行时健康状态。 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Success 200 {object} identityConfigurationView +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 503 {object} ErrorEnvelope +// @Router /api/admin/system/identity/configuration [get] func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)} @@ -117,6 +135,23 @@ func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, view) } +// startIdentityPairing godoc +// @Summary 启动统一认证配对 +// @Description 使用一次性接入码创建 Draft 和异步 Exchange。接入码仅允许出现在请求 Body。 +// @Tags identity +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "首次配对固定为 W/\"0\"" +// @Param body body identity.PairingInput true "配对参数" +// @Success 202 {object} identity.PairingExchange +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/pairings [post] func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) { var input identity.PairingInput if !decodeIdentityRequest(w, r, &input) { @@ -139,6 +174,17 @@ func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) { s.startIdentityPairingWorker(pairing.ID) } +// getIdentityPairing godoc +// @Summary 查询统一认证配对状态 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param pairingID path string true "配对 ID" +// @Success 200 {object} identity.PairingExchange +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /api/admin/system/identity/pairings/{pairingID} [get] func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID")) @@ -150,6 +196,23 @@ func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, pairing) } +// updateIdentityDraftPolicy godoc +// @Summary 修改统一认证 Draft 策略 +// @Tags identity +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param revisionID path string true "Revision ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Revision ETag" +// @Param body body identityPolicyPatch true "Gateway 本地策略" +// @Success 200 {object} identity.Revision +// @Failure 400 {object} ErrorEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/revisions/{revisionID} [patch] func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) { var patch identityPolicyPatch if !decodeIdentityRequest(w, r, &patch) { @@ -185,24 +248,85 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID) } +// validateIdentityRevision godoc +// @Summary 验证统一认证 Revision +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param revisionID path string true "Revision ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Revision ETag" +// @Success 200 {object} identity.Revision +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Failure 502 {object} ErrorEnvelope +// @Router /api/admin/system/identity/revisions/{revisionID}/validate [post] func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } +// activateIdentityRevision godoc +// @Summary 激活统一认证 Revision +// @Description 在完整候选 Runtime 验证成功且本地 Break-glass Manager 可用后原子热切换。 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param revisionID path string true "Revision ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Revision ETag" +// @Success 200 {object} identity.Revision +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/revisions/{revisionID}/activate [post] func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } +// rollbackIdentityRevision godoc +// @Summary 回滚统一认证 Revision +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param revisionID path string true "Revision ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Revision ETag" +// @Success 200 {object} identity.Revision +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/revisions/{revisionID}/rollback [post] func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) { s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) { return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID) }) } +// disableIdentityConfiguration godoc +// @Summary 禁用统一认证 +// @Description 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Active Revision ETag" +// @Success 200 {object} identity.Revision +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/disable [post] func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) { expectedVersion, ok := requiredIdentityVersion(w, r) if !ok { diff --git a/apps/api/project.json b/apps/api/project.json index 204346b..2eb2550 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -24,7 +24,7 @@ "outputs": ["{projectRoot}/docs/swagger.json", "{projectRoot}/docs/swagger.yaml"], "options": { "cwd": "apps/api", - "command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth -g main.go -o docs --outputTypes json,yaml" + "command": "go run github.com/swaggo/swag/cmd/swag@v1.16.4 init --parseInternal -d ./cmd/gateway,./internal/httpapi,./internal/store,./internal/auth,./internal/identity -g main.go -o docs --outputTypes json,yaml" } }, "test": { From a312ad880d6dada48cf628b798a0d8f38de1fc89 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 18:31:12 +0800 Subject: [PATCH 18/20] =?UTF-8?q?fix(identity):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E9=85=8D=E5=AF=B9=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E5=AE=89=E5=85=A8=E9=80=80=E5=BD=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。 验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。 --- ..._reservation_migration_integration_test.go | 254 ++++ apps/api/cmd/migrate/main_test.go | 281 ++++ apps/api/docs/swagger.json | 203 ++- apps/api/docs/swagger.yaml | 137 +- apps/api/internal/auth/auth.go | 61 +- apps/api/internal/auth/oidc.go | 30 +- apps/api/internal/auth/oidc_client.go | 9 +- apps/api/internal/auth/oidc_client_test.go | 45 + .../internal/auth/oidc_session_cookie_test.go | 84 ++ apps/api/internal/auth/oidc_test.go | 70 + .../httpapi/core_flow_integration_test.go | 28 + apps/api/internal/httpapi/handlers.go | 25 +- .../identity_configuration_handlers.go | 241 +++- .../identity_configuration_handlers_test.go | 43 + .../httpapi/identity_pairing_coordinator.go | 126 ++ .../identity_pairing_coordinator_test.go | 150 ++ apps/api/internal/httpapi/identity_runtime.go | 3 + .../httpapi/local_login_policy_test.go | 26 + .../httpapi/oidc_jit_integration_test.go | 4 +- apps/api/internal/httpapi/oidc_session.go | 16 +- .../api/internal/httpapi/oidc_session_test.go | 89 ++ .../security_event_connection_handlers.go | 72 +- ...security_event_connection_handlers_test.go | 10 + apps/api/internal/httpapi/security_events.go | 8 +- .../internal/httpapi/security_events_test.go | 56 + apps/api/internal/httpapi/server.go | 38 +- apps/api/internal/identity/configuration.go | 79 +- .../internal/identity/configuration_test.go | 44 +- .../internal/identity/onboarding_client.go | 28 +- .../identity/onboarding_client_test.go | 68 +- apps/api/internal/identity/pairing_service.go | 418 +++++- .../internal/identity/pairing_service_test.go | 711 ++++++++- apps/api/internal/identityruntime/builder.go | 252 +++- .../builder_security_events_test.go | 81 ++ apps/api/internal/identityruntime/manager.go | 375 ++++- .../internal/identityruntime/manager_test.go | 523 ++++++- .../securityevents/connection_manager.go | 487 ++++++- .../securityevents/connection_manager_test.go | 1274 ++++++++++++++++- .../identity_secret_cleanup_worker.go | 52 + .../securityevents/retirement_worker.go | 79 + .../securityevents/retirement_worker_test.go | 178 +++ ...uration_secret_cleanup_integration_test.go | 141 ++ .../internal/store/identity_configurations.go | 163 ++- apps/api/internal/store/identity_pairing.go | 336 ++++- .../identity_pairing_integration_test.go | 715 +++++++++ ...cret_cleanup_migration_integration_test.go | 206 +++ .../store/security_event_connections.go | 384 ++++- ...y_event_secret_cleanup_integration_test.go | 665 +++++++++ .../0069_identity_pairing_cancellation.sql | 69 + .../0070_identity_secret_cleanup_queue.sql | 19 + ...071_identity_pairing_start_reservation.sql | 29 + ...dentity_secret_cleanup_claim_lifecycle.sql | 34 + ...tity_pairing_start_reservation_upgrade.sql | 89 ++ ...0074_identity_pairing_error_categories.sql | 32 + packages/contracts/src/index.ts | 8 +- 55 files changed, 9227 insertions(+), 421 deletions(-) create mode 100644 apps/api/cmd/migrate/identity_pairing_start_reservation_migration_integration_test.go create mode 100644 apps/api/internal/httpapi/identity_pairing_coordinator.go create mode 100644 apps/api/internal/httpapi/identity_pairing_coordinator_test.go create mode 100644 apps/api/internal/httpapi/local_login_policy_test.go create mode 100644 apps/api/internal/httpapi/security_events_test.go create mode 100644 apps/api/internal/identityruntime/builder_security_events_test.go create mode 100644 apps/api/internal/securityevents/identity_secret_cleanup_worker.go create mode 100644 apps/api/internal/securityevents/retirement_worker.go create mode 100644 apps/api/internal/securityevents/retirement_worker_test.go create mode 100644 apps/api/internal/store/identity_configuration_secret_cleanup_integration_test.go create mode 100644 apps/api/internal/store/identity_pairing_integration_test.go create mode 100644 apps/api/internal/store/identity_secret_cleanup_migration_integration_test.go create mode 100644 apps/api/internal/store/security_event_secret_cleanup_integration_test.go create mode 100644 apps/api/migrations/0069_identity_pairing_cancellation.sql create mode 100644 apps/api/migrations/0070_identity_secret_cleanup_queue.sql create mode 100644 apps/api/migrations/0071_identity_pairing_start_reservation.sql create mode 100644 apps/api/migrations/0072_identity_secret_cleanup_claim_lifecycle.sql create mode 100644 apps/api/migrations/0073_identity_pairing_start_reservation_upgrade.sql create mode 100644 apps/api/migrations/0074_identity_pairing_error_categories.sql diff --git a/apps/api/cmd/migrate/identity_pairing_start_reservation_migration_integration_test.go b/apps/api/cmd/migrate/identity_pairing_start_reservation_migration_integration_test.go new file mode 100644 index 0000000..7f68de3 --- /dev/null +++ b/apps/api/cmd/migrate/identity_pairing_start_reservation_migration_integration_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +const identityPairingStartReservationUpgradeMigration = "../../migrations/0073_identity_pairing_start_reservation_upgrade.sql" + +func TestIdentityPairingStartReservationUpgradeMigrationDefinesCurrentLifecycle(t *testing.T) { + payload, err := os.ReadFile(identityPairingStartReservationUpgradeMigration) + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "add column if not exists state text", + "add column if not exists revision_id uuid", + "add column if not exists updated_at timestamptz", + "alter column state set not null", + "gateway_identity_pairing_start_state_check", + "foreign key (revision_id)", + "idx_gateway_identity_pairing_start_revision_unique", + "idx_gateway_identity_pairing_start_expiry", + "canonical_pairing", + "on conflict (singleton) do update", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity pairing start reservation upgrade migration is missing %q", required) + } + } + for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret", "secret_value"} { + if strings.Contains(content, forbidden) { + t.Fatalf("identity pairing start reservation upgrade migration stores forbidden value field %q", forbidden) + } + } +} + +func TestIdentityPairingStartReservationUpgradeMigratesLegacyShapeAndIsIdempotent(t *testing.T) { + pool := newIdentityMigrationPostgresTestSchema(t) + ctx := context.Background() + applyIdentityPairingStartReservationPrerequisites(t, ctx, pool) + + revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool) + legacyAttemptID := uuid.NewString() + if _, err := pool.Exec(ctx, ` +CREATE TABLE gateway_identity_pairing_start_reservation ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + attempt_id uuid NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +)`); err != nil { + t.Fatalf("create legacy pairing start reservation: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at) +VALUES(true,$1::uuid,now()+interval '2 minutes')`, legacyAttemptID); err != nil { + t.Fatalf("seed legacy pairing start reservation: %v", err) + } + + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt) + assertIdentityPairingStartReservationSchema(t, ctx, pool) + + // Migration runners apply each version once, but a repeat execution proves + // that recovery from an interrupted/manual rollout does not duplicate state. + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt) + assertIdentityPairingStartReservationSchema(t, ctx, pool) +} + +func TestIdentityPairingStartReservationUpgradePreservesLegacyStartingLease(t *testing.T) { + pool := newIdentityMigrationPostgresTestSchema(t) + ctx := context.Background() + applyIdentityPairingStartReservationPrerequisites(t, ctx, pool) + + attemptID := uuid.NewString() + expiresAt := time.Now().UTC().Add(2 * time.Minute).Truncate(time.Microsecond) + if _, err := pool.Exec(ctx, ` +CREATE TABLE gateway_identity_pairing_start_reservation ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + attempt_id uuid NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +)`); err != nil { + t.Fatalf("create legacy pairing start reservation: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,expires_at) +VALUES(true,$1::uuid,$2)`, attemptID, expiresAt); err != nil { + t.Fatalf("seed legacy starting reservation: %v", err) + } + + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + + var gotAttemptID, state string + var revisionID *string + var gotExpiresAt, updatedAt time.Time + if err := pool.QueryRow(ctx, ` +SELECT attempt_id::text,state,revision_id::text,expires_at,updated_at +FROM gateway_identity_pairing_start_reservation`). + Scan(&gotAttemptID, &state, &revisionID, &gotExpiresAt, &updatedAt); err != nil { + t.Fatalf("read upgraded legacy starting reservation: %v", err) + } + if gotAttemptID != attemptID || state != "starting" || revisionID != nil || + !gotExpiresAt.Equal(expiresAt) || updatedAt.IsZero() { + t.Fatalf("unexpected upgraded starting reservation attempt=%q state=%q revision=%v expires=%v updated=%v", + gotAttemptID, state, revisionID, gotExpiresAt, updatedAt) + } + + _, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='paired'`) + requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "paired reservation without revision") +} + +func TestIdentityPairingStartReservationUpgradeAcceptsFresh0071Schema(t *testing.T) { + pool := newIdentityMigrationPostgresTestSchema(t) + ctx := context.Background() + applyIdentityPairingStartReservationPrerequisites(t, ctx, pool) + + revisionID, pairingID, pairingExpiresAt := seedOutstandingIdentityPairingForReservationMigration(t, ctx, pool) + applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0071_identity_pairing_start_reservation.sql") + assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt) + + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + applyIdentityMigrationTestFile(t, ctx, pool, identityPairingStartReservationUpgradeMigration) + assertCurrentIdentityPairingStartReservation(t, ctx, pool, pairingID, revisionID, pairingExpiresAt) + assertIdentityPairingStartReservationSchema(t, ctx, pool) +} + +func applyIdentityPairingStartReservationPrerequisites(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + for _, migration := range []string{ + "../../migrations/0067_identity_configuration_revisions.sql", + "../../migrations/0068_identity_onboarding_exchanges.sql", + "../../migrations/0069_identity_pairing_cancellation.sql", + } { + applyIdentityMigrationTestFile(t, ctx, pool, migration) + } +} + +func seedOutstandingIdentityPairingForReservationMigration( + t *testing.T, + ctx context.Context, + pool *pgxpool.Pool, +) (string, string, time.Time) { + t.Helper() + revisionID := uuid.NewString() + pairingID := uuid.NewString() + expiresAt := time.Now().UTC().Add(30 * time.Minute).Truncate(time.Microsecond) + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'draft','https://auth.test.example','default', + 'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil { + t.Fatalf("seed identity revision for pairing reservation migration: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_onboarding_exchanges ( + id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,$5)`, + pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID, expiresAt); err != nil { + t.Fatalf("seed identity exchange for pairing reservation migration: %v", err) + } + return revisionID, pairingID, expiresAt +} + +func assertCurrentIdentityPairingStartReservation( + t *testing.T, + ctx context.Context, + pool *pgxpool.Pool, + wantAttemptID string, + wantRevisionID string, + wantExpiresAt time.Time, +) { + t.Helper() + var count int + var attemptID, state, revisionID string + var expiresAt, updatedAt time.Time + if err := pool.QueryRow(ctx, ` +SELECT count(*) OVER (),attempt_id::text,state,revision_id::text,expires_at,updated_at +FROM gateway_identity_pairing_start_reservation`). + Scan(&count, &attemptID, &state, &revisionID, &expiresAt, &updatedAt); err != nil { + t.Fatalf("read upgraded pairing start reservation: %v", err) + } + if count != 1 || attemptID != wantAttemptID || state != "paired" || revisionID != wantRevisionID || + !expiresAt.Equal(wantExpiresAt) || updatedAt.IsZero() { + t.Fatalf("unexpected upgraded pairing reservation count=%d attempt=%q state=%q revision=%q expires=%v updated=%v", + count, attemptID, state, revisionID, expiresAt, updatedAt) + } +} + +func assertIdentityPairingStartReservationSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + for _, column := range []struct { + name string + wantDefaultFragment string + }{ + {name: "state", wantDefaultFragment: "starting"}, + {name: "updated_at", wantDefaultFragment: "now()"}, + } { + var nullable, defaultExpression string + if err := pool.QueryRow(ctx, ` +SELECT is_nullable,COALESCE(column_default,'') +FROM information_schema.columns +WHERE table_schema=current_schema() + AND table_name='gateway_identity_pairing_start_reservation' + AND column_name=$1`, column.name).Scan(&nullable, &defaultExpression); err != nil { + t.Fatalf("read upgraded pairing reservation column %s: %v", column.name, err) + } + if nullable != "NO" || !strings.Contains(defaultExpression, column.wantDefaultFragment) { + t.Fatalf("pairing reservation column %s nullable=%q default=%q", column.name, nullable, defaultExpression) + } + } + + var expiryIndex, revisionIndex string + if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_expiry'::regclass)`). + Scan(&expiryIndex); err != nil { + t.Fatalf("read pairing reservation expiry index: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT pg_get_indexdef('idx_gateway_identity_pairing_start_revision_unique'::regclass)`). + Scan(&revisionIndex); err != nil { + t.Fatalf("read pairing reservation revision index: %v", err) + } + if !strings.Contains(strings.ToLower(expiryIndex), "(state, expires_at)") || + !strings.Contains(strings.ToLower(revisionIndex), "unique") || + !strings.Contains(strings.ToLower(revisionIndex), "(revision_id)") { + t.Fatalf("unexpected pairing reservation indexes expiry=%q revision=%q", expiryIndex, revisionIndex) + } + + _, err := pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET state='starting'`) + requireIdentityPairingStartReservationMigrationCode(t, err, "23514", "starting reservation with revision") + _, err = pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET revision_id=$1::uuid`, uuid.NewString()) + requireIdentityPairingStartReservationMigrationCode(t, err, "23503", "reservation with unknown revision") +} + +func requireIdentityPairingStartReservationMigrationCode(t *testing.T, err error, wantCode, operation string) { + t.Helper() + if err == nil { + t.Fatalf("%s unexpectedly satisfied migration constraints", operation) + } + var postgresError *pgconn.PgError + if !errors.As(err, &postgresError) || postgresError.Code != wantCode { + t.Fatalf("%s error=%v, want PostgreSQL code %s", operation, err, wantCode) + } +} diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index 9ccf709..868f967 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -1,9 +1,17 @@ package main import ( + "context" + "errors" "os" "strings" "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" ) func TestSecurityEventIdempotencyRepairMigrationExists(t *testing.T) { @@ -85,3 +93,276 @@ func TestIdentityOnboardingExchangeMigrationStoresOnlySecretReferences(t *testin } } } + +func TestIdentityPairingCancellationMigrationSeparatesCleanupLifecycle(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0069_identity_pairing_cancellation.sql") + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "'cancelled'", + "cleanup_status text not null default 'none'", + "cleanup_completed_at", + "idx_gateway_identity_onboarding_cleanup", + "last_error_category", + "set last_error_category = 'pairing_step_failed'", + "set local lock_timeout = '10s'", + "set local statement_timeout = '60s'", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity pairing cancellation migration is missing %q", required) + } + } + for _, forbidden := range []string{"exchange_token text", "client_secret", "machine_secret"} { + if strings.Contains(content, forbidden) { + t.Fatalf("identity pairing cancellation migration stores forbidden secret field %q", forbidden) + } + } +} + +func TestIdentityPairingErrorCategoryUpgradeMigrationExists(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0074_identity_pairing_error_categories.sql") + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "drop constraint if exists gateway_identity_onboarding_error_category_check", + "security_event_credential_handoff_unsafe", + "security_event_connection_binding_missing", + "security_event_connection_binding_unavailable", + "security_event_connection_binding_invalid", + "security_event_connection_binding_mismatch", + "cleanup_security_event_credential_handoff_unsafe", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity pairing error category migration is missing %q", required) + } + } +} + +func TestIdentityPairingErrorCategoryUpgradeMigrationExecutesAgainstCurrentSchema(t *testing.T) { + pool := newIdentityMigrationPostgresTestSchema(t) + ctx := context.Background() + for _, migration := range []string{ + "../../migrations/0067_identity_configuration_revisions.sql", + "../../migrations/0068_identity_onboarding_exchanges.sql", + "../../migrations/0069_identity_pairing_cancellation.sql", + "../../migrations/0070_identity_secret_cleanup_queue.sql", + "../../migrations/0071_identity_pairing_start_reservation.sql", + "../../migrations/0072_identity_secret_cleanup_claim_lifecycle.sql", + "../../migrations/0073_identity_pairing_start_reservation_upgrade.sql", + "../../migrations/0074_identity_pairing_error_categories.sql", + } { + applyIdentityMigrationTestFile(t, ctx, pool, migration) + } + + revisionID, pairingID := uuid.NewString(), uuid.NewString() + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'draft','https://auth.test.example','default', + 'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil { + t.Fatalf("seed identity revision: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_onboarding_exchanges ( + id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes')`, + pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil { + t.Fatalf("seed onboarding exchange: %v", err) + } + + for _, category := range []string{ + "security_event_credential_handoff_unsafe", + "security_event_connection_binding_missing", + "security_event_connection_binding_unavailable", + "security_event_connection_binding_invalid", + "security_event_connection_binding_mismatch", + "cleanup_security_event_credential_handoff_unsafe", + } { + if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET last_error_category=$2 WHERE id=$1::uuid`, pairingID, category); err != nil { + t.Fatalf("persist supported error category %q: %v", category, err) + } + } + _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET last_error_category='credential_secret_leaked' WHERE id=$1::uuid`, pairingID) + requireIdentityMigrationCheckViolation(t, err, "unknown upgraded identity pairing category") +} + +func TestIdentitySecretCleanupQueueMigrationStoresOnlyReferences(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0070_identity_secret_cleanup_queue.sql") + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "create table if not exists gateway_identity_secret_cleanup_queue", + "secret_ref text primary key", + "not_before timestamptz not null", + "status text not null default 'pending'", + "claim_token uuid", + "lease_expires_at timestamptz", + "gateway_identity_secret_cleanup_claim_check", + "idx_gateway_identity_secret_cleanup_due", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity Secret cleanup migration is missing %q", required) + } + } + for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} { + if strings.Contains(content, forbidden) { + t.Fatalf("identity Secret cleanup migration stores forbidden value field %q", forbidden) + } + } +} + +func TestIdentityPairingCancellationMigrationExecutesAgainstLegacyData(t *testing.T) { + pool := newIdentityMigrationPostgresTestSchema(t) + ctx := context.Background() + applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0067_identity_configuration_revisions.sql") + applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0068_identity_onboarding_exchanges.sql") + + revisionID, pairingID := uuid.NewString(), uuid.NewString() + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'draft','https://auth.test.example','default', + 'https://gateway.test.example','https://gateway-web.test.example')`, revisionID); err != nil { + t.Fatalf("seed legacy identity revision: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO gateway_identity_onboarding_exchanges ( + id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_error_category +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,'credentials_saved',4,now() + interval '30 minutes','secret_token_abcdef')`, + pairingID, revisionID, uuid.NewString(), "identity-exchange-"+pairingID); err != nil { + t.Fatalf("seed legacy onboarding exchange: %v", err) + } + + applyIdentityMigrationTestFile(t, ctx, pool, "../../migrations/0069_identity_pairing_cancellation.sql") + + var status, cleanupStatus, errorCategory string + var cancelledAt, cleanupCompletedAt *time.Time + if err := pool.QueryRow(ctx, ` +SELECT status,cleanup_status,last_error_category,cancelled_at,cleanup_completed_at +FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID). + Scan(&status, &cleanupStatus, &errorCategory, &cancelledAt, &cleanupCompletedAt); err != nil { + t.Fatalf("read migrated onboarding exchange: %v", err) + } + if status != "credentials_saved" || cleanupStatus != "none" || errorCategory != "pairing_step_failed" || + cancelledAt != nil || cleanupCompletedAt != nil { + t.Fatalf("unexpected migrated exchange status=%q cleanup=%q error=%q cancelled=%v completed=%v", + status, cleanupStatus, errorCategory, cancelledAt, cleanupCompletedAt) + } + + _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges SET status='cancelled' WHERE id=$1::uuid`, pairingID) + requireIdentityMigrationCheckViolation(t, err, "cancelled status without cleanup intent") + + if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET status='cancelled',cleanup_status='pending',cancelled_at=now() +WHERE id=$1::uuid`, pairingID); err != nil { + t.Fatalf("persist valid pending cleanup lifecycle: %v", err) + } + _, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET cleanup_status='completed' WHERE id=$1::uuid`, pairingID) + requireIdentityMigrationCheckViolation(t, err, "completed cleanup without completion timestamp") + + if _, err := pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET cleanup_status='completed',cleanup_completed_at=now() +WHERE id=$1::uuid`, pairingID); err != nil { + t.Fatalf("persist valid completed cleanup lifecycle: %v", err) + } + _, err = pool.Exec(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET last_error_category='still-invalid' WHERE id=$1::uuid`, pairingID) + requireIdentityMigrationCheckViolation(t, err, "invalid post-migration error category") + + if err := pool.QueryRow(ctx, `SELECT status,cleanup_status FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, pairingID). + Scan(&status, &cleanupStatus); err != nil { + t.Fatalf("read completed cleanup lifecycle: %v", err) + } + if status != "cancelled" || cleanupStatus != "completed" { + t.Fatalf("completed cleanup lifecycle status=%q cleanup=%q", status, cleanupStatus) + } +} + +func applyIdentityMigrationTestFile(t *testing.T, ctx context.Context, pool *pgxpool.Pool, path string) { + t.Helper() + payload, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read migration %s: %v", path, err) + } + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin migration %s: %v", path, err) + } + if _, err := tx.Exec(ctx, string(payload)); err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("execute migration %s: %v", path, err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit migration %s: %v", path, err) + } +} + +func requireIdentityMigrationCheckViolation(t *testing.T, err error, operation string) { + t.Helper() + if err == nil { + t.Fatalf("%s unexpectedly satisfied migration constraints", operation) + } + var postgresError *pgconn.PgError + if !errors.As(err, &postgresError) || postgresError.Code != "23514" { + t.Fatalf("%s error=%v, want PostgreSQL check violation", operation, err) + } +} + +func newIdentityMigrationPostgresTestSchema(t *testing.T) *pgxpool.Pool { + t.Helper() + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity migration PostgreSQL integration tests") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect identity migration test database: %v", err) + } + var databaseName string + if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil { + admin.Close() + t.Fatalf("read identity migration test database name: %v", err) + } + if !strings.Contains(strings.ToLower(databaseName), "test") { + admin.Close() + t.Fatalf("refusing to use non-test database %q", databaseName) + } + + schemaName := "gateway_identity_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "") + schemaIdentifier := pgx.Identifier{schemaName}.Sanitize() + if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil { + admin.Close() + t.Fatalf("create identity migration test schema: %v", err) + } + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("parse identity migration test database URL: %v", err) + } + config.ConnConfig.RuntimeParams["search_path"] = schemaName + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("connect identity migration test schema: %v", err) + } + t.Cleanup(func() { + pool.Close() + if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil { + t.Errorf("drop identity migration test schema: %v", err) + } + admin.Close() + }) + return pool +} diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 22c082c..11d7cd0 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -2918,7 +2918,7 @@ "BearerAuth": [] } ], - "description": "保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。", + "description": "将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。", "produces": [ "application/json" ], @@ -3116,6 +3116,174 @@ } } }, + "/api/admin/system/identity/pairings/{pairingID}/cancel": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange;下一次凭据交付会轮换机器凭据。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "放弃本地统一认证配对", + "parameters": [ + { + "type": "string", + "description": "配对 ID", + "name": "pairingID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Pairing ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/identity.PairingExchange" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, + "/api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。", + "produces": [ + "application/json" + ], + "tags": [ + "identity" + ], + "summary": "安全退役阻塞配对的旧 SSF 连接", + "parameters": [ + { + "type": "string", + "description": "配对 ID", + "name": "pairingID", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "幂等键", + "name": "Idempotency-Key", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "当前 Pairing ETag", + "name": "If-Match", + "in": "header", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/identity.PairingExchange" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/admin/system/identity/revisions/{revisionID}": { "patch": { "security": [ @@ -3290,13 +3458,14 @@ "BearerAuth": [] } ], + "description": "当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。", "produces": [ "application/json" ], "tags": [ "identity" ], - "summary": "回滚统一认证 Revision", + "summary": "请求恢复历史统一认证 Revision", "parameters": [ { "type": "string", @@ -4792,7 +4961,7 @@ }, "/api/v1/auth/login": { "post": { - "description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。", + "description": "使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。", "consumes": [ "application/json" ], @@ -11492,12 +11661,34 @@ } } }, + "identity.PairingCleanupStatus": { + "type": "string", + "enum": [ + "none", + "pending", + "completed" + ], + "x-enum-varnames": [ + "PairingCleanupNone", + "PairingCleanupPending", + "PairingCleanupCompleted" + ] + }, "identity.PairingExchange": { "type": "object", "properties": { "authCenterAuditId": { "type": "string" }, + "cancelledAt": { + "type": "string" + }, + "cleanupCompletedAt": { + "type": "string" + }, + "cleanupStatus": { + "$ref": "#/definitions/identity.PairingCleanupStatus" + }, "createdAt": { "type": "string" }, @@ -11565,7 +11756,8 @@ "credentials_saved", "completed", "failed", - "expired" + "expired", + "cancelled" ], "x-enum-varnames": [ "PairingMetadataPending", @@ -11574,7 +11766,8 @@ "PairingCredentialsSaved", "PairingCompleted", "PairingFailed", - "PairingExpired" + "PairingExpired", + "PairingCancelled" ] }, "identity.Revision": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 9ba8cf4..9f47de3 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -932,10 +932,26 @@ definitions: example: manual recharge type: string type: object + identity.PairingCleanupStatus: + enum: + - none + - pending + - completed + type: string + x-enum-varnames: + - PairingCleanupNone + - PairingCleanupPending + - PairingCleanupCompleted identity.PairingExchange: properties: authCenterAuditId: type: string + cancelledAt: + type: string + cleanupCompletedAt: + type: string + cleanupStatus: + $ref: '#/definitions/identity.PairingCleanupStatus' createdAt: type: string expiresAt: @@ -983,6 +999,7 @@ definitions: - completed - failed - expired + - cancelled type: string x-enum-varnames: - PairingMetadataPending @@ -992,6 +1009,7 @@ definitions: - PairingCompleted - PairingFailed - PairingExpired + - PairingCancelled identity.Revision: properties: activatedAt: @@ -4616,7 +4634,7 @@ paths: - identity /api/admin/system/identity/disable: post: - description: 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。 + description: 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。 parameters: - description: 幂等键 in: header @@ -4746,6 +4764,118 @@ paths: summary: 查询统一认证配对状态 tags: - identity + /api/admin/system/identity/pairings/{pairingID}/cancel: + post: + description: 原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 + Exchange;下一次凭据交付会轮换机器凭据。 + parameters: + - description: 配对 ID + in: path + name: pairingID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Pairing ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/identity.PairingExchange' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 放弃本地统一认证配对 + tags: + - identity + /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event: + post: + description: 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision + 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。 + parameters: + - description: 配对 ID + in: path + name: pairingID + required: true + type: string + - description: 幂等键 + in: header + name: Idempotency-Key + required: true + type: string + - description: 当前 Pairing ETag + in: header + name: If-Match + required: true + type: string + produces: + - application/json + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/identity.PairingExchange' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "403": + description: Forbidden + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "409": + description: Conflict + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "412": + description: Precondition Failed + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 安全退役阻塞配对的旧 SSF 连接 + tags: + - identity /api/admin/system/identity/revisions/{revisionID}: patch: consumes: @@ -4857,6 +4987,7 @@ paths: - identity /api/admin/system/identity/revisions/{revisionID}/rollback: post: + description: 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。 parameters: - description: Revision ID in: path @@ -4902,7 +5033,7 @@ paths: $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 回滚统一认证 Revision + summary: 请求恢复历史统一认证 Revision tags: - identity /api/admin/system/identity/revisions/{revisionID}/validate: @@ -5825,7 +5956,7 @@ paths: post: consumes: - application/json - description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。 + description: 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。 parameters: - description: 登录请求,account 可为用户名或邮箱 in: body diff --git a/apps/api/internal/auth/auth.go b/apps/api/internal/auth/auth.go index b401837..72640b5 100644 --- a/apps/api/internal/auth/auth.go +++ b/apps/api/internal/auth/auth.go @@ -22,7 +22,9 @@ import ( type Permission string const ( - OIDCSessionCookieName = "easyai_gateway_oidc_session" + OIDCSessionCookieName = "easyai_gateway_oidc_session" + localBreakGlassTokenPurpose = "local_break_glass_manager" + legacyAccessTokenPurpose = "legacy_access" PermissionPublic Permission = "public" PermissionBasic Permission = "basic" @@ -52,6 +54,7 @@ type User struct { TokenExpiresAt time.Time `json:"-"` TokenIssuedAt time.Time `json:"-"` Issuer string `json:"-"` + TokenPurpose string `json:"-"` } type contextKey string @@ -142,15 +145,24 @@ func (a *Authenticator) Require(permission Permission, next http.Handler) http.H } func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { - token := extractBearer(r.Header.Get("Authorization")) - if token == "" { - token = strings.TrimSpace(r.Header.Get("x-comfy-api-key")) - } - if token == "" { - token = strings.TrimSpace(r.Header.Get("x-goog-api-key")) - } - if token == "" { - token = strings.TrimSpace(r.URL.Query().Get("key")) + var token string + if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" { + token = extractBearer(authorization) + if token == "" { + return nil, ErrUnauthorized + } + } else if value := strings.TrimSpace(r.Header.Get("x-comfy-api-key")); value != "" { + token = value + } else if value := strings.TrimSpace(r.Header.Get("x-goog-api-key")); value != "" { + token = value + } else if value := strings.TrimSpace(r.URL.Query().Get("key")); value != "" { + // Query credentials are retained only for API compatibility with + // providers that use `?key=sk-*`. Bearer/OIDC tokens in URLs would leak + // through browser history, reverse-proxy logs, and diagnostics. + if !strings.HasPrefix(value, "sk-") { + return nil, ErrUnauthorized + } + token = value } if token == "" { if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil { @@ -175,10 +187,14 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) { if algorithm == "RS256" || algorithm == "ES256" { return a.AuthenticateOIDCAccessToken(r.Context(), token) } - if !a.legacyJWTEnabled() { - return nil, ErrUnauthorized + user, err := a.verifyJWT(token) + if err != nil { + return nil, err } - return a.verifyJWT(token) + if a.legacyJWTEnabled() || isLocalBreakGlassManager(user) { + return user, nil + } + return nil, ErrUnauthorized } func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) { @@ -233,6 +249,7 @@ func (a *Authenticator) verifyJWT(tokenString string) (*User, error) { APIKeyName: stringClaim(claims, "apiKeyName"), APIKeyPrefix: stringClaim(claims, "apiKeyPrefix"), APIKeyScopes: stringSliceClaim(claims, "apiKeyScopes"), + TokenPurpose: stringClaim(claims, "tokenPurpose"), } if user.Source == "" { user.Source = "gateway" @@ -248,6 +265,10 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) { ttl = time.Hour } now := time.Now() + tokenPurpose := legacyAccessTokenPurpose + if user.Source == "gateway" && hasManagerRole(user.Roles) { + tokenPurpose = localBreakGlassTokenPurpose + } claims := jwt.MapClaims{ "sub": user.ID, "username": user.Username, @@ -264,6 +285,7 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) { "apiKeyName": user.APIKeyName, "apiKeyPrefix": user.APIKeyPrefix, "apiKeyScopes": user.APIKeyScopes, + "tokenPurpose": tokenPurpose, "iat": now.Unix(), "exp": now.Add(ttl).Unix(), } @@ -271,6 +293,19 @@ func (a *Authenticator) SignJWT(user *User, ttl time.Duration) (string, error) { return token.SignedString([]byte(a.JWTSecret)) } +func isLocalBreakGlassManager(user *User) bool { + return user != nil && user.Source == "gateway" && user.TokenPurpose == localBreakGlassTokenPurpose && hasManagerRole(user.Roles) +} + +func hasManagerRole(roles []string) bool { + for _, role := range roles { + if role == "manager" || role == "admin" { + return true + } + } + return false +} + func (a *Authenticator) verifyAPIKey(ctx context.Context, apiKey string) (*User, error) { if a.LocalAPIKeyVerifier != nil { user, err := a.LocalAPIKeyVerifier(ctx, apiKey) diff --git a/apps/api/internal/auth/oidc.go b/apps/api/internal/auth/oidc.go index d86cd9c..eaca8c2 100644 --- a/apps/api/internal/auth/oidc.go +++ b/apps/api/internal/auth/oidc.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "math/big" + "net" "net/http" "net/url" "strings" @@ -25,6 +26,7 @@ const maxOIDCResponseBytes = 1 << 20 const defaultOIDCHTTPTimeout = 10 * time.Second type OIDCConfig struct { + AppEnv string Issuer string Audience string TenantID string @@ -90,7 +92,7 @@ func NewOIDCVerifier(config OIDCConfig) (*OIDCVerifier, error) { config.Audience = strings.TrimSpace(config.Audience) config.TenantID = strings.TrimSpace(config.TenantID) config.RolePrefix = strings.TrimSpace(config.RolePrefix) - if err := validatePublicURL(config.Issuer); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { + if err := validatePublicURL(config.Issuer, config.AppEnv); err != nil || config.Audience == "" || config.TenantID == "" || config.RolePrefix == "" { return nil, errors.New("issuer, audience, tenant and role prefix are required") } if config.IntrospectionEnabled && config.IntrospectionCredentialProvider == nil && @@ -262,10 +264,10 @@ func (v *OIDCVerifier) refresh(ctx context.Context, force bool) error { if err := v.fetchJSON(ctx, discoveryURL, &discovery); err != nil { return err } - if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI) != nil { + if discovery.Issuer != v.config.Issuer || validatePublicURL(discovery.JWKSURI, v.config.AppEnv) != nil { return errors.New("OIDC discovery metadata is invalid") } - if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint) != nil { + if (v.config.IntrospectionEnabled || v.config.SecurityEventEvaluator != nil) && validatePublicURL(discovery.IntrospectionEndpoint, v.config.AppEnv) != nil { return errors.New("OIDC introspection metadata is invalid") } var set jsonWebKeySet @@ -405,7 +407,7 @@ func decodeBigInt(value string) (*big.Int, error) { return new(big.Int).SetBytes(payload), nil } -func validatePublicURL(raw string) error { +func validatePublicURL(raw, appEnv string) error { parsed, err := url.Parse(raw) if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return errors.New("invalid URL") @@ -413,12 +415,30 @@ func validatePublicURL(raw string) error { if parsed.Scheme == "https" { return nil } - if parsed.Scheme == "http" && (parsed.Hostname() == "127.0.0.1" || parsed.Hostname() == "localhost") { + if parsed.Scheme == "http" && isLocalOIDCEnvironment(appEnv) && isLoopbackOIDCHost(parsed.Hostname()) { return nil } return errors.New("URL must use HTTPS") } +func isLocalOIDCEnvironment(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "development", "dev", "local", "test": + return true + default: + return false + } +} + +func isLoopbackOIDCHost(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "localhost" { + return true + } + ip := net.ParseIP(value) + return ip != nil && ip.IsLoopback() +} + func numericDateClaim(value any) (time.Time, bool) { switch typed := value.(type) { case float64: diff --git a/apps/api/internal/auth/oidc_client.go b/apps/api/internal/auth/oidc_client.go index edd9128..5472adb 100644 --- a/apps/api/internal/auth/oidc_client.go +++ b/apps/api/internal/auth/oidc_client.go @@ -19,6 +19,7 @@ var ErrOIDCInvalidGrant = errors.New("OIDC refresh token is invalid") var errOIDCResponseTooLarge = errors.New("OIDC response exceeds size limit") type OIDCPublicClientConfig struct { + AppEnv string Issuer string ClientID string RedirectURI string @@ -64,7 +65,7 @@ func NewOIDCPublicClient(config OIDCPublicClientConfig) (*OIDCPublicClient, erro return nil, errors.New("offline_access is not allowed for Gateway browser sessions") } } - if validatePublicURL(config.Issuer) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI) != nil || validatePublicURL(config.PostLogoutRedirectURI) != nil { + if validatePublicURL(config.Issuer, config.AppEnv) != nil || config.ClientID == "" || validatePublicURL(config.RedirectURI, config.AppEnv) != nil || validatePublicURL(config.PostLogoutRedirectURI, config.AppEnv) != nil { return nil, errors.New("issuer, public client id and exact redirect URLs are required") } return &OIDCPublicClient{config: config, client: newOIDCHTTPClient(config.HTTPClient)}, nil @@ -220,9 +221,9 @@ func (c *OIDCPublicClient) configuration(ctx context.Context) (*oauth2.Config, o } var metadata oidcClientDiscovery if err := provider.Claims(&metadata); err != nil || metadata.Issuer != c.config.Issuer || - validatePublicURL(metadata.AuthorizationEndpoint) != nil || validatePublicURL(metadata.TokenEndpoint) != nil || validatePublicURL(metadata.JWKSURI) != nil || - metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint) != nil || - metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint) != nil { + validatePublicURL(metadata.AuthorizationEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.TokenEndpoint, c.config.AppEnv) != nil || validatePublicURL(metadata.JWKSURI, c.config.AppEnv) != nil || + metadata.RevocationEndpoint != "" && validatePublicURL(metadata.RevocationEndpoint, c.config.AppEnv) != nil || + metadata.EndSessionEndpoint != "" && validatePublicURL(metadata.EndSessionEndpoint, c.config.AppEnv) != nil { return nil, oidcClientDiscovery{}, errors.New("OIDC discovery metadata is invalid") } endpoint := provider.Endpoint() diff --git a/apps/api/internal/auth/oidc_client_test.go b/apps/api/internal/auth/oidc_client_test.go index 19f16df..01faf9f 100644 --- a/apps/api/internal/auth/oidc_client_test.go +++ b/apps/api/internal/auth/oidc_client_test.go @@ -57,6 +57,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T issuer = server.URL client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "test", Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/gateway-api/api/v1/auth/oidc/callback", PostLogoutRedirectURI: "https://gateway.example.com/", Scopes: []string{"openid", "profile", "gateway.access"}, HTTPClient: server.Client(), }) @@ -84,6 +85,7 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T func TestOIDCPublicClientRejectsOfflineAccess(t *testing.T) { _, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "production", Issuer: "https://auth.example.com", ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/api/v1/auth/oidc/callback", PostLogoutRedirectURI: "https://gateway.example.com/", @@ -119,6 +121,7 @@ func TestOIDCPublicClientVerifiesIDTokenNonceAndAudience(t *testing.T) { issuer = server.URL client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "test", Issuer: issuer, ClientID: "gateway-public-client", RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(), }) @@ -195,6 +198,7 @@ func TestOIDCPublicClientRefreshAndRevokeNeverSendSecret(t *testing.T) { defer server.Close() issuer = server.URL client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "test", Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(), }) @@ -234,6 +238,7 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi issuer = server.URL client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "test", Issuer: issuer, ClientID: "gateway-public", RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/", HTTPClient: server.Client(), }) @@ -245,3 +250,43 @@ func TestOIDCPublicClientMapsInvalidGrantWithoutLeakingProviderResponse(t *testi t.Fatalf("invalid_grant mapping was not stable and redacted: %v", err) } } + +func TestOIDCPublicClientRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) { + var issuer, insecureField string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + metadata := map[string]any{ + "issuer": issuer, "authorization_endpoint": issuer + "/authorize", + "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + "revocation_endpoint": issuer + "/revoke", "end_session_endpoint": issuer + "/logout", + "id_token_signing_alg_values_supported": []string{"RS256", "ES256"}, + } + metadata[insecureField] = "http://127.0.0.1:1/oidc-endpoint" + _ = json.NewEncoder(w).Encode(metadata) + })) + defer server.Close() + issuer = server.URL + + for _, field := range []string{ + "authorization_endpoint", "token_endpoint", "jwks_uri", "revocation_endpoint", "end_session_endpoint", + } { + t.Run(field, func(t *testing.T) { + insecureField = field + client, err := NewOIDCPublicClient(OIDCPublicClientConfig{ + AppEnv: "production", Issuer: issuer, ClientID: "gateway-public", + RedirectURI: "https://gateway.example.com/callback", PostLogoutRedirectURI: "https://gateway.example.com/", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if err := client.ValidateConfiguration(context.Background()); err == nil { + t.Fatalf("production accepted loopback HTTP %s", field) + } + }) + } +} diff --git a/apps/api/internal/auth/oidc_session_cookie_test.go b/apps/api/internal/auth/oidc_session_cookie_test.go index 8c596ac..4230501 100644 --- a/apps/api/internal/auth/oidc_session_cookie_test.go +++ b/apps/api/internal/auth/oidc_session_cookie_test.go @@ -2,10 +2,13 @@ package auth import ( "context" + "errors" "net/http" "net/http/httptest" "testing" "time" + + "github.com/golang-jwt/jwt/v5" ) func TestAuthenticateResolvesOpaqueOIDCSessionCookie(t *testing.T) { @@ -49,6 +52,47 @@ func TestAuthenticateBearerTakesPrecedenceOverOIDCSessionCookie(t *testing.T) { } } +func TestAuthenticateRejectsMalformedExplicitCredentialInsteadOfFallingBackToCookie(t *testing.T) { + authenticator := New("local-jwt-secret", "", "") + var resolved bool + authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) { + resolved = true + return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil + } + request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil) + request.Header.Set("Authorization", "not-a-bearer-credential") + request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"}) + + if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("malformed explicit credential error=%v, want unauthorized", err) + } + if resolved { + t.Fatal("malformed Authorization header fell back to the OIDC session cookie") + } +} + +func TestAuthenticateRejectsManagerJWTInQueryWithoutCookieFallback(t *testing.T) { + authenticator := New("local-jwt-secret", "", "") + managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour) + if err != nil { + t.Fatal(err) + } + var resolved bool + authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) { + resolved = true + return &User{ID: "cookie-manager", Source: "gateway", Roles: []string{"manager"}}, nil + } + request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil) + request.AddCookie(&http.Cookie{Name: OIDCSessionCookieName, Value: "valid-cookie-session"}) + + if _, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("manager query JWT error=%v, want unauthorized", err) + } + if resolved { + t.Fatal("rejected query credential fell back to the OIDC session cookie") + } +} + func TestAuthenticateRejectsInvalidOIDCSessionCookie(t *testing.T) { authenticator := New("local-jwt-secret", "", "") request := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) @@ -81,6 +125,46 @@ func TestAuthenticatorReadsOIDCSessionResolverAndLegacyPolicyDynamically(t *test } } +func TestAuthenticateKeepsOnlySignedBreakGlassManagerWhenLegacyJWTDisabled(t *testing.T) { + authenticator := New("local-jwt-secret", "", "") + authenticator.LegacyJWTEnabledProvider = func() bool { return false } + + managerToken, err := authenticator.SignJWT(&User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour) + if err != nil { + t.Fatal(err) + } + managerRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil) + managerRequest.Header.Set("Authorization", "Bearer "+managerToken) + manager, err := authenticator.Authenticate(managerRequest) + if err != nil || manager.ID != "manager" { + t.Fatalf("signed break-glass manager was rejected: user=%#v err=%v", manager, err) + } + + userToken, err := authenticator.SignJWT(&User{ID: "user", Source: "gateway", Roles: []string{"user"}}, time.Hour) + if err != nil { + t.Fatal(err) + } + userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + userRequest.Header.Set("Authorization", "Bearer "+userToken) + if _, err := authenticator.Authenticate(userRequest); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("ordinary local JWT error = %v, want unauthorized", err) + } + + legacyManager := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "legacy-manager", "source": "gateway", "role": []string{"manager"}, + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }) + legacyManagerToken, err := legacyManager.SignedString([]byte(authenticator.JWTSecret)) + if err != nil { + t.Fatal(err) + } + legacyRequest := httptest.NewRequest(http.MethodGet, "/api/admin/system/identity/configuration", nil) + legacyRequest.Header.Set("Authorization", "Bearer "+legacyManagerToken) + if _, err := authenticator.Authenticate(legacyRequest); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("legacy manager without token purpose error = %v, want unauthorized", err) + } +} + func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) { authenticator := New("local-jwt-secret", "", "") authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) { diff --git a/apps/api/internal/auth/oidc_test.go b/apps/api/internal/auth/oidc_test.go index 5a0f637..df7683d 100644 --- a/apps/api/internal/auth/oidc_test.go +++ b/apps/api/internal/auth/oidc_test.go @@ -43,6 +43,7 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) { defer server.Close() issuer = server.URL verifier, err := NewOIDCVerifier(OIDCConfig{ + AppEnv: "test", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), }) @@ -85,6 +86,7 @@ func TestOIDCVerifierRejectsMissingOrMismatchedSecurityClaims(t *testing.T) { defer server.Close() issuer = server.URL verifier, _ := NewOIDCVerifier(OIDCConfig{ + AppEnv: "test", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), }) @@ -139,6 +141,7 @@ func TestOIDCVerifierFailsClosedWhenIntrospectionMarksSessionInactive(t *testing defer server.Close() issuer = server.URL verifier, err := NewOIDCVerifier(OIDCConfig{ + AppEnv: "test", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), IntrospectionEnabled: true, @@ -182,6 +185,7 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes issuer = server.URL evaluation := OIDCSecurityEventEvaluation{RequireIntrospection: true} verifier, err := NewOIDCVerifier(OIDCConfig{ + AppEnv: "test", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", RequiredScopes: []string{"gateway.access"}, HTTPClient: server.Client(), IntrospectionEnabled: true, @@ -227,6 +231,72 @@ func TestOIDCSecurityEventEvaluationUsesWatermarkAndFallbackIntrospection(t *tes } } +func TestOIDCVerifierRejectsLoopbackHTTPDiscoveryEndpointsInProduction(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var issuer, jwksURI, introspectionEndpoint string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": issuer, "jwks_uri": jwksURI, "introspection_endpoint": introspectionEndpoint, + }) + case "/jwks": + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{rsaJWK("rsa-key", &key.PublicKey)}}) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + issuer = server.URL + + for _, test := range []struct { + name string + jwks string + introspection string + }{ + {name: "JWKS", jwks: "http://127.0.0.1:1/jwks"}, + {name: "introspection", jwks: issuer + "/jwks", introspection: "http://localhost:1/introspect"}, + } { + t.Run(test.name, func(t *testing.T) { + jwksURI, introspectionEndpoint = test.jwks, test.introspection + config := OIDCConfig{ + AppEnv: "production", Issuer: issuer, Audience: "gateway-api", TenantID: "tenant-1", RolePrefix: "gateway.", + HTTPClient: server.Client(), + } + if test.introspection != "" { + config.IntrospectionEnabled = true + config.IntrospectionCredentialProvider = func(context.Context) (string, []byte, error) { + return "gateway-machine", []byte("machine-secret-long-enough"), nil + } + } + verifier, createErr := NewOIDCVerifier(config) + if createErr != nil { + t.Fatal(createErr) + } + if validateErr := verifier.ValidateConfiguration(context.Background()); validateErr == nil { + t.Fatalf("production accepted loopback HTTP %s endpoint", test.name) + } + }) + } +} + +func TestOIDCURLPolicyAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { + for _, appEnv := range []string{"", "production", "staging"} { + if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err == nil { + t.Fatalf("%s accepted loopback HTTP OIDC URL", appEnv) + } + } + for _, appEnv := range []string{"local", "development", "dev", "test"} { + if err := validatePublicURL("http://127.0.0.2:18003/issuer/easyai", appEnv); err != nil { + t.Fatalf("%s rejected loopback HTTP OIDC URL: %v", appEnv, err) + } + } +} + func signedOIDCToken(t *testing.T, issuer, kid string, method jwt.SigningMethod, key any, mutate func(jwt.MapClaims)) string { t.Helper() now := time.Now() diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index a2bea6c..7baeb75 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -73,6 +73,12 @@ func TestCoreLocalFlow(t *testing.T) { if registerResponse.AccessToken == "" { t.Fatal("register did not return access token") } + ordinaryUsername := "smoke_user_" + suffixText + doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{ + "username": ordinaryUsername, + "email": ordinaryUsername + "@example.com", + "password": password, + }, http.StatusCreated, &struct{}{}) var duplicateResponse map[string]any doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{ @@ -129,6 +135,25 @@ func TestCoreLocalFlow(t *testing.T) { if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil { t.Fatalf("promote smoke user: %v", err) } + serverMainCtx, cancelServerMain := context.WithCancel(ctx) + serverMain := httptest.NewServer(NewServerWithContext(serverMainCtx, config.Config{ + AppEnv: "test", HTTPAddr: ":0", DatabaseURL: databaseURL, IdentityMode: "server-main", + JWTSecret: "test-secret", CORSAllowedOrigin: "*", + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + var breakGlassLogin struct { + AccessToken string `json:"accessToken"` + } + doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "account": username, "password": password, + }, http.StatusOK, &breakGlassLogin) + if breakGlassLogin.AccessToken == "" { + t.Fatal("server-main break-glass manager login did not return access token") + } + doJSON(t, serverMain.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "account": ordinaryUsername, "password": password, + }, http.StatusForbidden, &map[string]any{}) + serverMain.Close() + cancelServerMain() doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ "account": username, "password": password, @@ -1697,6 +1722,9 @@ func TestOriginAllowedSupportsCommaSeparatedOrigins(t *testing.T) { if originAllowed("http://127.0.0.1:5179", allowed) { t.Fatal("unexpected origin should not be allowed") } + if originAllowed("https://evil.example.com", "*") { + t.Fatal("credentialed wildcard CORS origin should not be allowed") + } } func assertRuntimeRecoveryReleasesPendingRateReservations(t *testing.T, ctx context.Context, db *store.Store) { diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 7c0ee71..52e59ef 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -79,7 +79,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { // @Failure 500 {object} ErrorEnvelope // @Router /api/v1/auth/register [post] func (s *Server) register(w http.ResponseWriter, r *http.Request) { - if !s.localIdentityEnabled() { + if !s.localIdentityEnabled() || !s.ordinaryLocalJWTEnabled() { writeError(w, http.StatusForbidden, "local registration is disabled") return } @@ -111,7 +111,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { // login godoc // @Summary 本地登录 -// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。 +// @Description 使用用户名或邮箱登录本地账号,并返回 24 小时 JWT。非本地身份模式只允许本地应急 Manager 登录。 // @Tags auth // @Accept json // @Produce json @@ -123,10 +123,6 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { // @Failure 500 {object} ErrorEnvelope // @Router /api/v1/auth/login [post] func (s *Server) login(w http.ResponseWriter, r *http.Request) { - if !s.localIdentityEnabled() { - writeError(w, http.StatusForbidden, "local login is disabled") - return - } var input store.LocalLoginInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") @@ -142,6 +138,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "login failed") return } + if !s.localLoginAllowed(user) { + writeError(w, http.StatusForbidden, "local login is disabled except for break-glass managers") + return + } s.writeAuthResponse(w, http.StatusOK, user) } @@ -150,6 +150,19 @@ func (s *Server) localIdentityEnabled() bool { return mode == "" || mode == "standalone" || mode == "hybrid" } +func (s *Server) localLoginAllowed(user store.GatewayUser) bool { + for _, role := range user.Roles { + if role == "manager" || role == "admin" { + return true + } + } + return s.localIdentityEnabled() && s.ordinaryLocalJWTEnabled() +} + +func (s *Server) ordinaryLocalJWTEnabled() bool { + return s.identityRuntime == nil || s.identityRuntime.LegacyJWTEnabled() +} + func (s *Server) writeAuthResponse(w http.ResponseWriter, status int, user store.GatewayUser) { authUser := authUserFromGatewayUser(user) const ttl = 24 * time.Hour diff --git a/apps/api/internal/httpapi/identity_configuration_handlers.go b/apps/api/internal/httpapi/identity_configuration_handlers.go index 06d96ae..8adea1f 100644 --- a/apps/api/internal/httpapi/identity_configuration_handlers.go +++ b/apps/api/internal/httpapi/identity_configuration_handlers.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "crypto/sha256" "encoding/json" "errors" @@ -68,6 +69,11 @@ type identityWriteOperation struct { once sync.Once } +type identityPairingWorker struct { + cancel context.CancelFunc + done chan struct{} +} + func (operation *identityWriteOperation) close() { if operation != nil { operation.once.Do(operation.release) @@ -163,12 +169,15 @@ func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) { } defer operation.close() traceID := ensureIdentityTraceID(w, r) + auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.start", "pending", traceID) + if !ok { + return + } pairing, err := s.identityPairing.Start(r.Context(), input, traceID) if err != nil { s.writeIdentityError(w, r, "pairing.start", "", traceID, err) return } - auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "") pairing.AuthCenterAuditID = "" s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID) s.startIdentityPairingWorker(pairing.ID) @@ -196,6 +205,95 @@ func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, pairing) } +// cancelIdentityPairing godoc +// @Summary 放弃本地统一认证配对 +// @Description 原子封存未激活 Revision,并异步清理由该 Revision 拥有的临时 Secret 与 SSF 连接。不会撤销已经完成的远端 Exchange;下一次凭据交付会轮换机器凭据。 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param pairingID path string true "配对 ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Pairing ETag" +// @Success 202 {object} identity.PairingExchange +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/pairings/{pairingID}/cancel [post] +func (s *Server) cancelIdentityPairing(w http.ResponseWriter, r *http.Request) { + expectedVersion, ok := requiredIdentityVersion(w, r) + if !ok { + return + } + pairingID := r.PathValue("pairingID") + operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.cancel", expectedVersion, struct { + PairingID string `json:"pairingId"` + }{PairingID: pairingID}) + if !ok { + return + } + defer operation.close() + traceID := ensureIdentityTraceID(w, r) + auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.cancel", pairingID, traceID) + if !ok { + return + } + pairing, err := s.identityPairing.Cancel(r.Context(), pairingID, expectedVersion, traceID, auditID) + if err != nil { + s.writeIdentityError(w, r, "pairing.cancel", pairingID, traceID, err) + return + } + done := s.stopIdentityPairingWorker(pairing.ID) + s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID) + s.startIdentityPairingCleanupWorker(pairing.ID, done) +} + +// retireIdentityPairingSecurityEventConflict godoc +// @Summary 安全退役阻塞配对的旧 SSF 连接 +// @Description 仅当指定 Pairing 仍因 owner 冲突停在 credentials_saved 时,退役不属于该 Revision 的旧连接;陈旧请求不会退役当前 Pairing 已创建的新连接。 +// @Tags identity +// @Produce json +// @Security BearerAuth +// @Param pairingID path string true "配对 ID" +// @Param Idempotency-Key header string true "幂等键" +// @Param If-Match header string true "当前 Pairing ETag" +// @Success 202 {object} identity.PairingExchange +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Failure 409 {object} ErrorEnvelope +// @Failure 412 {object} ErrorEnvelope +// @Failure 428 {object} ErrorEnvelope +// @Router /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event [post] +func (s *Server) retireIdentityPairingSecurityEventConflict(w http.ResponseWriter, r *http.Request) { + expectedVersion, ok := requiredIdentityVersion(w, r) + if !ok { + return + } + pairingID := r.PathValue("pairingID") + operation, ok := s.beginIdentityWriteWithVersion(w, r, "pairing.retire_security_event_conflict", expectedVersion, struct { + PairingID string `json:"pairingId"` + }{PairingID: pairingID}) + if !ok { + return + } + defer operation.close() + traceID := ensureIdentityTraceID(w, r) + auditID, ok := s.requireIdentityConfigurationAudit(w, r, "pairing.retire_security_event_conflict", pairingID, traceID) + if !ok { + return + } + pairing, err := s.identityPairing.RetireConflictingSecurityEvents(r.Context(), pairingID, expectedVersion) + if err != nil { + s.writeIdentityError(w, r, "pairing.retire_security_event_conflict", pairingID, traceID, err) + return + } + s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID) + s.startIdentityPairingWorker(pairing.ID) +} + // updateIdentityDraftPolicy godoc // @Summary 修改统一认证 Draft 策略 // @Tags identity @@ -238,13 +336,16 @@ func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Reques SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds, } applyIdentityPolicyPatch(&policy, patch) - updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy) - if err != nil { - s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err) + traceID := ensureIdentityTraceID(w, r) + auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.policy", revision.ID, traceID) + if !ok { + return + } + updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy) + if err != nil { + s.writeIdentityError(w, r, "revision.policy", revision.ID, traceID, err) return } - traceID := ensureIdentityTraceID(w, r) - auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "") s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID) } @@ -292,7 +393,8 @@ func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request } // rollbackIdentityRevision godoc -// @Summary 回滚统一认证 Revision +// @Summary 请求恢复历史统一认证 Revision +// @Description 当前远端 OAuth/SSF 资源未版本化,接口会拒绝直接回滚并要求禁用后使用新接入码完成交接。 // @Tags identity // @Produce json // @Security BearerAuth @@ -314,7 +416,7 @@ func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request // disableIdentityConfiguration godoc // @Summary 禁用统一认证 -// @Description 保留 Superseded Revision 供回滚,清理 BFF Session,并继续允许本地管理登录。 +// @Description 将 Revision 转为只读 Superseded 审计历史,清理 BFF Session,并继续允许本地管理登录;重新接入必须使用新的接入码。 // @Tags identity // @Produce json // @Security BearerAuth @@ -338,7 +440,10 @@ func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Req } defer operation.close() traceID := ensureIdentityTraceID(w, r) - auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "") + auditID, ok := s.requireIdentityConfigurationAudit(w, r, "revision.disable", "active", traceID) + if !ok { + return + } disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID) if err != nil { s.writeIdentityError(w, r, "revision.disable", "active", traceID, err) @@ -358,7 +463,10 @@ func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Reques } defer operation.close() traceID := ensureIdentityTraceID(w, r) - auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "") + auditID, ok := s.requireIdentityConfigurationAudit(w, r, action, r.PathValue("revisionID"), traceID) + if !ok { + return + } revision, err := run(expectedVersion, traceID, auditID) if err != nil { s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err) @@ -425,9 +533,7 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED") return nil, false } - encoded, _ := json.Marshal(request) - digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...)) - requestHash := fmt.Sprintf("%x", digest[:]) + requestHash := identityRequestHash(operation, version, request) s.identityManagementMu.Lock() write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock} if s.store == nil { @@ -468,6 +574,12 @@ func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Re return nil, false } +func identityRequestHash(operation string, version int64, request any) string { + encoded, _ := json.Marshal(request) + digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...)) + return fmt.Sprintf("%x", digest[:]) +} + func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) { body, _ := json.Marshal(payload) stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID}) @@ -541,6 +653,11 @@ func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, acti } func identityErrorProjection(err error) (int, string, string) { + var categorized interface{ SafeErrorCategory() string } + safeCategory := "" + if errors.As(err, &categorized) { + safeCategory = categorized.SafeErrorCategory() + } switch { case errors.Is(err, identity.ErrRevisionNotFound): return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND" @@ -550,6 +667,20 @@ func identityErrorProjection(err error) (int, string, string) { return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED" case errors.Is(err, identity.ErrLocalTenantInvalid): return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID" + case errors.Is(err, identity.ErrPairingInProgress): + return http.StatusConflict, "请先完成或放弃当前统一认证配对", "IDENTITY_PAIRING_IN_PROGRESS" + case errors.Is(err, identity.ErrActiveConfigurationHandoffRequired): + return http.StatusConflict, "当前仍有 Active 统一认证配置;请先禁用,再使用新接入码配对", "IDENTITY_ACTIVE_CONFIGURATION_HANDOFF_REQUIRED" + case errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired): + return http.StatusConflict, "旧版本关联的远端 OAuth/SSF 资源可能已变化;请禁用后使用新接入码恢复", "IDENTITY_ROLLBACK_CONFIGURATION_HANDOFF_REQUIRED" + case errors.Is(err, identity.ErrSecurityEventRetirementPending): + return http.StatusConflict, "旧安全事件 Stream 尚未完成断开;系统会继续重试,请稍后再次禁用", "IDENTITY_SECURITY_EVENT_RETIREMENT_PENDING" + case errors.Is(err, identity.ErrPairingNotCancellable): + return http.StatusConflict, "当前统一认证配置不能放弃", "IDENTITY_PAIRING_NOT_CANCELLABLE" + case errors.Is(err, identity.ErrPairingConflictNotResolvable): + return http.StatusConflict, "当前配对已不需要退役旧安全事件连接,请刷新状态", "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE" + case safeCategory == "credential_handoff_unsafe": + return http.StatusConflict, "旧安全事件连接与本次认证中心不匹配,系统已拒绝发送凭据;请先在原配置下断开旧连接", "IDENTITY_SECURITY_EVENT_HANDOFF_UNSAFE" case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")): return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID" default: @@ -583,23 +714,97 @@ func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targe return audit.ID } +func (s *Server) requireIdentityConfigurationAudit(w http.ResponseWriter, r *http.Request, action, targetID, traceID string) (string, bool) { + auditID := s.recordIdentityConfigurationAudit(r, action, targetID, "requested", traceID, "") + if auditID == "" { + writeError(w, http.StatusServiceUnavailable, "统一认证审计暂时不可用,操作未执行", "IDENTITY_AUDIT_UNAVAILABLE") + return "", false + } + w.Header().Set("X-Audit-Id", auditID) + return auditID, true +} + func (s *Server) startIdentityPairingWorker(pairingID string) { - if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded { + workerContext, cancel := context.WithCancel(s.ctx) + worker := &identityPairingWorker{cancel: cancel, done: make(chan struct{})} + if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, worker); loaded { + cancel() return } go func() { - defer s.identityPairingWorkers.Delete(pairingID) + defer func() { + s.identityPairingWorkers.CompareAndDelete(pairingID, worker) + close(worker.done) + }() delay := time.Second for { - pairing, err := s.identityPairing.Continue(s.ctx, pairingID) + pairing, err := s.identityPairing.Continue(workerContext, pairingID) if err == nil { - if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired { + if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired || pairing.Status == identity.PairingCancelled { return } delay = time.Second } else { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } if s.logger != nil { - s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed") + s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_step_failed")) + } + if delay < 15*time.Second { + delay *= 2 + } + } + select { + case <-workerContext.Done(): + return + case <-time.After(delay): + } + } + }() +} + +func (s *Server) stopIdentityPairingWorker(pairingID string) <-chan struct{} { + if value, ok := s.identityPairingWorkers.Load(pairingID); ok { + worker := value.(*identityPairingWorker) + worker.cancel() + return worker.done + } + done := make(chan struct{}) + close(done) + return done +} + +func (s *Server) startIdentityPairingCleanupWorker(pairingID string, processingDone <-chan struct{}) { + if processingDone == nil { + processingDone = s.stopIdentityPairingWorker(pairingID) + } + if _, loaded := s.identityCleanupWorkers.LoadOrStore(pairingID, struct{}{}); loaded { + return + } + go func() { + defer s.identityCleanupWorkers.Delete(pairingID) + if processingDone != nil { + select { + case <-s.ctx.Done(): + return + case <-processingDone: + } + } + delay := time.Second + for { + pairing, err := s.identityPairing.Cleanup(s.ctx, pairingID) + if err == nil { + if pairing.CleanupStatus == identity.PairingCleanupCompleted { + return + } + delay = time.Second + } else { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, identity.ErrPairingNotCancellable) { + return + } + if s.logger != nil { + s.logger.Warn("identity pairing cleanup failed and will retry", "pairing_id", pairingID, "error_category", firstNonEmptyText(pairing.LastErrorCategory, "pairing_cleanup_failed")) } if delay < 15*time.Second { delay *= 2 diff --git a/apps/api/internal/httpapi/identity_configuration_handlers_test.go b/apps/api/internal/httpapi/identity_configuration_handlers_test.go index 4dc8a23..d616f23 100644 --- a/apps/api/internal/httpapi/identity_configuration_handlers_test.go +++ b/apps/api/internal/httpapi/identity_configuration_handlers_test.go @@ -1,9 +1,12 @@ package httpapi import ( + "errors" "net/http" "net/http/httptest" "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" ) func TestRequiredIdentityWriteHeaders(t *testing.T) { @@ -28,6 +31,46 @@ func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) { } } +func TestIdentityPairingRecoveryErrorsUseStableConflictResponses(t *testing.T) { + for _, test := range []struct { + err error + code string + }{ + {err: identity.ErrPairingInProgress, code: "IDENTITY_PAIRING_IN_PROGRESS"}, + {err: identity.ErrPairingNotCancellable, code: "IDENTITY_PAIRING_NOT_CANCELLABLE"}, + {err: identity.ErrPairingConflictNotResolvable, code: "IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE"}, + } { + status, message, code := identityErrorProjection(test.err) + if status != http.StatusConflict || code != test.code || message == "" || errors.Is(assertionError(message), test.err) { + t.Fatalf("err=%v status=%d code=%q message=%q", test.err, status, code, message) + } + } +} + +func TestIdentityWriteHashScopesPairingCancellationToTarget(t *testing.T) { + type cancellationRequest struct { + PairingID string `json:"pairingId"` + } + first := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-a"}) + second := identityRequestHash("pairing.cancel", 7, cancellationRequest{PairingID: "pairing-b"}) + if first == second { + t.Fatal("pairing cancellation idempotency hash must include the target pairing") + } +} + +func TestIdentityWriteAuditFailsClosedBeforeMutation(t *testing.T) { + server := &Server{} + request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil) + recorder := httptest.NewRecorder() + + if _, ok := server.requireIdentityConfigurationAudit(recorder, request, "revision.disable", "active", "trace-test"); ok { + t.Fatal("identity write unexpectedly continued without durable audit storage") + } + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("audit failure status=%d, want 503", recorder.Code) + } +} + type assertionError string func (err assertionError) Error() string { return string(err) } diff --git a/apps/api/internal/httpapi/identity_pairing_coordinator.go b/apps/api/internal/httpapi/identity_pairing_coordinator.go new file mode 100644 index 0000000..3dfd16c --- /dev/null +++ b/apps/api/internal/httpapi/identity_pairing_coordinator.go @@ -0,0 +1,126 @@ +package httpapi + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +const identityPairingReconcileInterval = 5 * time.Second + +type identityPairingReconciliationStore interface { + PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) + CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) + PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) +} + +type identityPairingRestorer interface { + RestoreCompletedSecurityEvents(context.Context, string) error +} + +func (s *Server) reconcileCanonicalIdentityPairing() { + reconcileCanonicalIdentityPairing( + s.ctx, + s.store, + s.identityPairing, + &s.identityRestoredPairings, + s.startIdentityPairingWorker, + func(pairingID string) { s.startIdentityPairingCleanupWorker(pairingID, nil) }, + s.logger, + ) +} + +func (s *Server) runIdentityPairingCoordinator() { + ticker := time.NewTicker(identityPairingReconcileInterval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.reconcileIdentityRuntime() + s.reconcileCanonicalIdentityPairing() + } + } +} + +func reconcileCanonicalIdentityPairing( + ctx context.Context, + repository identityPairingReconciliationStore, + restorer identityPairingRestorer, + restored *sync.Map, + startWorker func(string), + startCleanup func(string), + logger *slog.Logger, +) { + cleanups, cleanupErr := repository.PendingIdentityPairingCleanups(ctx) + if cleanupErr != nil { + if logger != nil { + logger.Warn("pending identity pairing cleanups could not be resumed", "error_category", "pairing_cleanup_resume_failed") + } + } else { + for _, pairing := range cleanups { + startCleanup(pairing.ID) + } + } + + pending, err := repository.PendingIdentityPairingExchanges(ctx) + if err != nil { + if logger != nil { + logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed") + } + return + } + if len(pending) > 0 { + forgetOtherRestoredPairings(restored, "") + startWorker(pending[0].ID) + return + } + + completed, err := repository.CompletedIdentityPairingsAwaitingActivation(ctx) + if err != nil { + if logger != nil { + logger.Warn("completed identity pairings could not be loaded", "error_category", "pairing_receiver_restore_failed") + } + return + } + if len(completed) == 0 { + forgetOtherRestoredPairings(restored, "") + return + } + + pairingID := completed[0].ID + forgetOtherRestoredPairings(restored, pairingID) + if _, loaded := restored.LoadOrStore(pairingID, struct{}{}); loaded { + return + } + if err := restorer.RestoreCompletedSecurityEvents(ctx, pairingID); err != nil { + restored.Delete(pairingID) + if logger != nil { + logger.Warn("completed identity pairing receiver could not be restored", "pairing_id", pairingID, "error_category", "pairing_receiver_restore_failed") + } + } +} + +func (s *Server) reconcileIdentityRuntime() { + if s.identityRuntime == nil || !s.identityRuntime.ReconciliationRequired() { + return + } + ctx, cancel := context.WithTimeout(s.ctx, 5*time.Second) + defer cancel() + if err := s.identityRuntime.ReconcileActive(ctx); err != nil && s.logger != nil { + s.logger.Warn("identity runtime reconciliation failed and will retry", "error_category", "identity_runtime_reconciliation_failed") + } +} + +func forgetOtherRestoredPairings(restored *sync.Map, keepID string) { + restored.Range(func(key, _ any) bool { + if key != keepID { + restored.Delete(key) + } + return true + }) +} diff --git a/apps/api/internal/httpapi/identity_pairing_coordinator_test.go b/apps/api/internal/httpapi/identity_pairing_coordinator_test.go new file mode 100644 index 0000000..052d030 --- /dev/null +++ b/apps/api/internal/httpapi/identity_pairing_coordinator_test.go @@ -0,0 +1,150 @@ +package httpapi + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +type identityPairingCoordinatorStore struct { + pending []identity.PairingExchange + completed []identity.PairingExchange + cleanups []identity.PairingExchange + pendingErr error + completedErr error + cleanupErr error +} + +func (store identityPairingCoordinatorStore) PendingIdentityPairingExchanges(context.Context) ([]identity.PairingExchange, error) { + return store.pending, store.pendingErr +} + +func (store identityPairingCoordinatorStore) CompletedIdentityPairingsAwaitingActivation(context.Context) ([]identity.PairingExchange, error) { + return store.completed, store.completedErr +} + +func (store identityPairingCoordinatorStore) PendingIdentityPairingCleanups(context.Context) ([]identity.PairingExchange, error) { + return store.cleanups, store.cleanupErr +} + +type identityPairingCoordinatorRestorer struct { + calls int + err error +} + +func (restorer *identityPairingCoordinatorRestorer) RestoreCompletedSecurityEvents(context.Context, string) error { + restorer.calls++ + return restorer.err +} + +func TestReconcileCanonicalIdentityPairingRetriesFailedRestore(t *testing.T) { + repository := identityPairingCoordinatorStore{ + completed: []identity.PairingExchange{{ID: "pairing-1"}}, + } + restorer := &identityPairingCoordinatorRestorer{err: errors.New("SecretStore temporarily unavailable")} + restored := &sync.Map{} + + reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil) + reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil) + + if restorer.calls != 2 { + t.Fatalf("restore calls = %d, want 2", restorer.calls) + } +} + +func TestReconcileCanonicalIdentityPairingRestoresSuccessfulPairingOnce(t *testing.T) { + repository := identityPairingCoordinatorStore{ + completed: []identity.PairingExchange{{ID: "pairing-1"}}, + } + restorer := &identityPairingCoordinatorRestorer{} + restored := &sync.Map{} + + reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil) + reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(string) {}, func(string) {}, nil) + + if restorer.calls != 1 { + t.Fatalf("restore calls = %d, want 1", restorer.calls) + } +} + +func TestReconcileCanonicalIdentityPairingPrioritizesPendingWork(t *testing.T) { + repository := identityPairingCoordinatorStore{ + pending: []identity.PairingExchange{{ID: "pending-pairing"}}, + completed: []identity.PairingExchange{{ID: "completed-pairing"}}, + } + restorer := &identityPairingCoordinatorRestorer{} + restored := &sync.Map{} + started := "" + + reconcileCanonicalIdentityPairing(context.Background(), repository, restorer, restored, func(pairingID string) { + started = pairingID + }, func(string) {}, nil) + + if started != "pending-pairing" { + t.Fatalf("started pairing = %q, want pending-pairing", started) + } + if restorer.calls != 0 { + t.Fatalf("restore calls = %d, want 0", restorer.calls) + } +} + +func TestReconcileCanonicalIdentityPairingForgetsTerminalPairing(t *testing.T) { + restored := &sync.Map{} + restored.Store("terminal-pairing", struct{}{}) + + reconcileCanonicalIdentityPairing( + context.Background(), + identityPairingCoordinatorStore{}, + &identityPairingCoordinatorRestorer{}, + restored, + func(string) {}, + func(string) {}, + nil, + ) + + if _, ok := restored.Load("terminal-pairing"); ok { + t.Fatal("terminal pairing restore marker was not removed") + } +} + +func TestReconcileCanonicalIdentityPairingResumesCommittedCleanup(t *testing.T) { + repository := identityPairingCoordinatorStore{ + cleanups: []identity.PairingExchange{{ID: "cancelled-pairing"}}, + } + restorer := &identityPairingCoordinatorRestorer{} + restored := &sync.Map{} + startedCleanup := "" + + reconcileCanonicalIdentityPairing( + context.Background(), + repository, + restorer, + restored, + func(string) {}, + func(pairingID string) { startedCleanup = pairingID }, + nil, + ) + + if startedCleanup != "cancelled-pairing" { + t.Fatalf("started cleanup = %q, want cancelled-pairing", startedCleanup) + } +} + +func TestCleanupCoordinatorCancelsPairingWorkerBeforeCleanup(t *testing.T) { + serverContext, cancelServer := context.WithCancel(context.Background()) + cancelServer() + workerContext, cancelWorker := context.WithCancel(context.Background()) + server := &Server{ctx: serverContext} + server.identityPairingWorkers.Store("pairing-1", &identityPairingWorker{cancel: cancelWorker, done: make(chan struct{})}) + + server.startIdentityPairingCleanupWorker("pairing-1", nil) + + select { + case <-workerContext.Done(): + default: + t.Fatal("coordinator cleanup did not cancel the pairing worker first") + } +} diff --git a/apps/api/internal/httpapi/identity_runtime.go b/apps/api/internal/httpapi/identity_runtime.go index 192a9ef..51fffb4 100644 --- a/apps/api/internal/httpapi/identity_runtime.go +++ b/apps/api/internal/httpapi/identity_runtime.go @@ -79,6 +79,9 @@ func (s *Server) currentIdentityRuntime() *identityRequestRuntime { } func (s *Server) currentSecurityEventManager() *ssfreceiver.ConnectionManager { + if s.identityRuntime != nil { + return s.identityRuntime.SecurityEventManager() + } if runtime := s.currentIdentityRuntime(); runtime != nil { return runtime.SecurityEvents } diff --git a/apps/api/internal/httpapi/local_login_policy_test.go b/apps/api/internal/httpapi/local_login_policy_test.go new file mode 100644 index 0000000..46d0ff7 --- /dev/null +++ b/apps/api/internal/httpapi/local_login_policy_test.go @@ -0,0 +1,26 @@ +package httpapi + +import ( + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestLocalLoginPolicyAlwaysPreservesBreakGlassManager(t *testing.T) { + serverMain := &Server{cfg: config.Config{IdentityMode: "server-main"}} + if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"manager"}}) { + t.Fatal("server-main mode rejected a local break-glass manager") + } + if !serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"admin"}}) { + t.Fatal("server-main mode rejected a local break-glass admin") + } + if serverMain.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) { + t.Fatal("server-main mode accepted an ordinary local user") + } + + hybrid := &Server{cfg: config.Config{IdentityMode: "hybrid"}} + if !hybrid.localLoginAllowed(store.GatewayUser{Roles: []string{"user"}}) { + t.Fatal("hybrid mode rejected an ordinary local user") + } +} diff --git a/apps/api/internal/httpapi/oidc_jit_integration_test.go b/apps/api/internal/httpapi/oidc_jit_integration_test.go index 238d477..d4fd8ad 100644 --- a/apps/api/internal/httpapi/oidc_jit_integration_test.go +++ b/apps/api/internal/httpapi/oidc_jit_integration_test.go @@ -281,7 +281,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, draft, err := identity.NewDraft(identity.PairingInput{ AuthCenterURL: issuer, PublicBaseURL: "http://localhost", WebBaseURL: "http://localhost:5178", LocalTenantKey: localTenantKey, LegacyJWTEnabled: true, - }) + }, "test") if err != nil { t.Fatalf("create OIDC JIT draft: %v", err) } @@ -304,7 +304,7 @@ func prepareOIDCJITRevision(t *testing.T, ctx context.Context, db *store.Store, Capabilities: []string{"oidc_login", "api_access"}, Audience: "gateway-api", Scopes: []string{"gateway.access"}, Clients: identity.ManifestClients{BrowserLogin: &identity.ManifestClient{ClientID: "gateway-public-test"}}, }, - SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", + SessionEncryptionKeyRef: sessionReference, TraceID: "oidc-jit-test", AuditID: "oidc-jit-test", AppEnv: "test", }) if err != nil { t.Fatalf("apply OIDC JIT manifest: %v", err) diff --git a/apps/api/internal/httpapi/oidc_session.go b/apps/api/internal/httpapi/oidc_session.go index d442980..dc6ad81 100644 --- a/apps/api/internal/httpapi/oidc_session.go +++ b/apps/api/internal/httpapi/oidc_session.go @@ -357,7 +357,7 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) { func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { runtime := s.currentIdentityRuntime() - if runtime == nil || !runtime.BrowserEnabled || isSafeHTTPMethod(r.Method) || hasExplicitCredential(r) { + if runtime == nil || !runtime.BrowserEnabled || hasExplicitCredential(r) { next.ServeHTTP(w, r) return } @@ -366,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler { return } origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" || !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) { + if origin != "" && !originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) || origin == "" && !isSafeHTTPMethod(r.Method) { writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF) return } @@ -384,8 +384,16 @@ func isSafeHTTPMethod(method string) bool { } func hasExplicitCredential(r *http.Request) bool { - return strings.TrimSpace(r.Header.Get("Authorization")) != "" || + return extractBearerCredential(r.Header.Get("Authorization")) != "" || strings.TrimSpace(r.Header.Get("x-comfy-api-key")) != "" || strings.TrimSpace(r.Header.Get("x-goog-api-key")) != "" || - strings.TrimSpace(r.URL.Query().Get("key")) != "" + strings.HasPrefix(strings.TrimSpace(r.URL.Query().Get("key")), "sk-") +} + +func extractBearerCredential(value string) string { + fields := strings.Fields(value) + if len(fields) == 2 && strings.EqualFold(fields[0], "bearer") { + return fields[1] + } + return "" } diff --git a/apps/api/internal/httpapi/oidc_session_test.go b/apps/api/internal/httpapi/oidc_session_test.go index 0c67a9a..e7c78fa 100644 --- a/apps/api/internal/httpapi/oidc_session_test.go +++ b/apps/api/internal/httpapi/oidc_session_test.go @@ -161,6 +161,7 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { }{ {name: "missing origin", method: http.MethodPost, wantStatus: http.StatusForbidden}, {name: "foreign origin", method: http.MethodDelete, origin: "https://evil.example", wantStatus: http.StatusForbidden}, + {name: "foreign origin cannot read cookie authenticated data", method: http.MethodGet, origin: "https://evil.example", wantStatus: http.StatusForbidden}, {name: "allowed origin", method: http.MethodPatch, origin: "https://gateway.example.com", wantStatus: http.StatusNoContent}, {name: "safe request", method: http.MethodGet, wantStatus: http.StatusNoContent}, {name: "explicit bearer bypasses cookie csrf", method: http.MethodPost, bearer: true, wantStatus: http.StatusNoContent}, @@ -181,6 +182,94 @@ func TestOIDCSessionCSRFAcceptsOnlyAllowedOriginForCookieWrites(t *testing.T) { } } +func TestOIDCSessionCSRFMalformedAuthorizationCannotBypassForeignOrigin(t *testing.T) { + authenticator := auth.New("local-jwt-secret", "", "") + authenticator.OIDCSessionResolver = func(context.Context, string) (*auth.User, error) { + return &auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, nil + } + server := &Server{ + auth: authenticator, + identityTestRevision: identity.Revision{WebBaseURL: "https://gateway.example.com"}, + identityTestBrowserEnabled: true, + } + called := false + handler := server.protectOIDCSessionCookie(server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + }))) + request := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil) + request.Header.Set("Origin", "https://evil.example.com") + request.Header.Set("Authorization", "malformed") + request.AddCookie(&http.Cookie{Name: auth.OIDCSessionCookieName, Value: "manager-session"}) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden || called { + t.Fatalf("malformed Authorization CSRF status=%d handler_called=%t", recorder.Code, called) + } +} + +func TestAdminRouteRejectsManagerJWTInQueryAndAcceptsAuthorizationHeader(t *testing.T) { + authenticator := auth.New("local-jwt-secret", "", "") + managerToken, err := authenticator.SignJWT(&auth.User{ID: "manager", Source: "gateway", Roles: []string{"manager"}}, time.Hour) + if err != nil { + t.Fatal(err) + } + server := &Server{auth: authenticator} + handler := server.requireAdmin(auth.PermissionManager, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + queryRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable?key="+managerToken, nil) + queryRecorder := httptest.NewRecorder() + handler.ServeHTTP(queryRecorder, queryRequest) + if queryRecorder.Code != http.StatusUnauthorized { + t.Fatalf("query manager JWT status=%d, want 401", queryRecorder.Code) + } + + headerRequest := httptest.NewRequest(http.MethodPost, "/api/admin/system/identity/disable", nil) + headerRequest.Header.Set("Authorization", "Bearer "+managerToken) + headerRecorder := httptest.NewRecorder() + handler.ServeHTTP(headerRecorder, headerRequest) + if headerRecorder.Code != http.StatusNoContent { + t.Fatalf("Authorization manager JWT status=%d, want 204", headerRecorder.Code) + } +} + +func TestCORSUsesOnlyCurrentActiveIdentityWebOriginWithoutRestart(t *testing.T) { + server := &Server{ + cfg: config.Config{CORSAllowedOrigin: "https://bootstrap.example.com"}, + identityTestRevision: identity.Revision{ + ID: "active-revision", State: identity.RevisionActive, WebBaseURL: "https://gateway.example.com", + }, + } + handler := server.cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) + request := func(origin string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodOptions, "/api/admin/system/identity/configuration", nil) + r.Header.Set("Origin", origin) + r.Header.Set("Access-Control-Request-Method", http.MethodGet) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + + active := request("https://gateway.example.com") + if active.Header().Get("Access-Control-Allow-Origin") != "https://gateway.example.com" || active.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("active Web origin headers=%v", active.Header()) + } + if evil := request("https://evil.example.com"); evil.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("evil origin was allowed: headers=%v", evil.Header()) + } + + server.identityTestRevision = identity.Revision{} + if disabled := request("https://gateway.example.com"); disabled.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("disabled Revision retained dynamic origin: headers=%v", disabled.Header()) + } + if bootstrap := request("https://bootstrap.example.com"); bootstrap.Header().Get("Access-Control-Allow-Origin") != "https://bootstrap.example.com" { + t.Fatalf("deployment bootstrap origin stopped working: headers=%v", bootstrap.Header()) + } +} + func TestOIDCSessionCSRFIsInactiveWhenOIDCIsDisabled(t *testing.T) { server := &Server{} request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) diff --git a/apps/api/internal/httpapi/security_event_connection_handlers.go b/apps/api/internal/httpapi/security_event_connection_handlers.go index b82b890..4cde34c 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers.go @@ -78,10 +78,28 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque defer clear(secret) secretDigest := sha256.Sum256(secret) requestHash := securityEventOperationHash("connect", request.TransmitterIssuer+"\x00"+request.ManagementClientID+"\x00"+fmt.Sprintf("%x", secretDigest[:])) + s.securityEventManagementMu.Lock() + defer s.securityEventManagementMu.Unlock() if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) { return } - if current, err := manager.Get(r.Context()); err == nil && !matchConnectionVersion(w, r, current.Version) { + current, currentErr := manager.Get(r.Context()) + switch { + case currentErr == nil: + if !matchConnectionVersion(w, r, current.Version) { + return + } + case errors.Is(currentErr, store.ErrSecurityEventConnectionNotFound): + current = ssfreceiver.ConnectionView{} + if !matchConnectionVersion(w, r, 0) { + return + } + default: + writeError(w, http.StatusServiceUnavailable, "security event connection state is unavailable", "security_event_state_unavailable") + return + } + auditID, ok := s.requireSecurityEventConnectionAudit(w, r, "connect", traceID, current) + if !ok { return } connection, err := manager.Connect(r.Context(), request.TransmitterIssuer, request.ManagementClientID, secret, idempotencyKey) @@ -89,13 +107,15 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection) + s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, auditID, connection) } func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) + s.securityEventManagementMu.Lock() + defer s.securityEventManagementMu.Unlock() manager := s.currentSecurityEventManager() - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID) + idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "verify", traceID) if !ok { return } @@ -104,13 +124,15 @@ func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Re s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err) return } - s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection) + s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, auditID, connection) } func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) + s.securityEventManagementMu.Lock() + defer s.securityEventManagementMu.Unlock() manager := s.currentSecurityEventManager() - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID) + idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "rotate", traceID) if !ok { return } @@ -119,13 +141,15 @@ func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err) return } - s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection) + s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, auditID, connection) } func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) { traceID := ensureSecurityEventTraceID(w, r) + s.securityEventManagementMu.Lock() + defer s.securityEventManagementMu.Unlock() manager := s.currentSecurityEventManager() - idempotencyKey, requestHash, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID) + idempotencyKey, requestHash, auditID, ok := s.beginSecurityEventOperation(w, r, manager, "disconnect", traceID) if !ok { return } @@ -134,7 +158,7 @@ func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Re s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err) return } - s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection) + s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, auditID, connection) } func (s *Server) securityEventPrerequisites() map[string]any { @@ -164,25 +188,32 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s return value, true } -func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) { +func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, string, bool) { if manager == nil { writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found") - return "", "", false + return "", "", "", false } idempotencyKey, ok := requiredConnectionIdempotencyKey(w, r) if !ok { - return "", "", false + return "", "", "", false } requestHash := securityEventOperationHash(operation, normalizedConnectionETag(r.Header.Get("If-Match"))) if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) { - return "", "", false + return "", "", "", false } connection, err := manager.Get(r.Context()) if err != nil { s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err) - return "", "", false + return "", "", "", false } - return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version) + if !matchConnectionVersion(w, r, connection.Version) { + return "", "", "", false + } + auditID, ok := s.requireSecurityEventConnectionAudit(w, r, operation, traceID, connection) + if !ok { + return "", "", "", false + } + return idempotencyKey, requestHash, auditID, true } func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash string) bool { @@ -216,8 +247,7 @@ func (s *Server) replaySecurityEventOperation(w http.ResponseWriter, r *http.Req return true } -func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID string, connection ssfreceiver.ConnectionView) { - auditID := s.recordSecurityEventConnectionAudit(r, operation, "success", "", traceID, connection) +func (s *Server) writeSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, idempotencyKey, requestHash, traceID, auditID string, connection ssfreceiver.ConnectionView) { if auditID != "" { w.Header().Set("X-Audit-Id", auditID) } @@ -328,6 +358,16 @@ func (s *Server) recordSecurityEventConnectionAudit(r *http.Request, operation, return audit.ID } +func (s *Server) requireSecurityEventConnectionAudit(w http.ResponseWriter, r *http.Request, operation, traceID string, connection ssfreceiver.ConnectionView) (string, bool) { + auditID := s.recordSecurityEventConnectionAudit(r, operation, "requested", "", traceID, connection) + if auditID == "" { + writeError(w, http.StatusServiceUnavailable, "security event audit is unavailable; operation was not executed", "security_event_audit_unavailable") + return "", false + } + w.Header().Set("X-Audit-Id", auditID) + return auditID, true +} + func securityEventConnectionAuditInput(r *http.Request, actor *auth.User, operation, outcome, errorCategory, traceID string, connection ssfreceiver.ConnectionView) store.AuditLogInput { input := store.AuditLogInput{ Category: "identity", Action: "identity.security_event_connection." + operation, diff --git a/apps/api/internal/httpapi/security_event_connection_handlers_test.go b/apps/api/internal/httpapi/security_event_connection_handlers_test.go index 22e11d9..2a09d38 100644 --- a/apps/api/internal/httpapi/security_event_connection_handlers_test.go +++ b/apps/api/internal/httpapi/security_event_connection_handlers_test.go @@ -43,6 +43,16 @@ func TestSecurityEventConnectionWriteHeaders(t *testing.T) { if _, ok := requiredConnectionIdempotencyKey(recorder, request); ok || recorder.Code != http.StatusBadRequest { t.Fatalf("missing Idempotency-Key status=%d", recorder.Code) } + request = httptest.NewRequest(http.MethodPut, "/connection", nil) + recorder = httptest.NewRecorder() + if matchConnectionVersion(recorder, request, 0) || recorder.Code != http.StatusPreconditionRequired { + t.Fatalf("initial connection without If-Match status=%d", recorder.Code) + } + request.Header.Set("If-Match", `W/"0"`) + recorder = httptest.NewRecorder() + if !matchConnectionVersion(recorder, request, 0) { + t.Fatalf("initial zero version was rejected: status=%d", recorder.Code) + } request = httptest.NewRequest(http.MethodPost, "/connection/verify", nil) request.Header.Set("If-Match", `W/"7"`) diff --git a/apps/api/internal/httpapi/security_events.go b/apps/api/internal/httpapi/security_events.go index addf64e..5845d3d 100644 --- a/apps/api/internal/httpapi/security_events.go +++ b/apps/api/internal/httpapi/security_events.go @@ -18,9 +18,13 @@ import "net/http" // @Failure 503 {object} map[string]string // @Router /api/v1/security-events/ssf [post] func (s *Server) receiveSecurityEvent(w http.ResponseWriter, r *http.Request) { - if s.securityEventReceiver == nil { + receiver := s.securityEventReceiver + if s.identityRuntime != nil { + receiver = s.identityRuntime.SecurityEventReceiver() + } + if receiver == nil { http.NotFound(w, r) return } - s.securityEventReceiver.ServeHTTP(w, r) + receiver.ServeHTTP(w, r) } diff --git a/apps/api/internal/httpapi/security_events_test.go b/apps/api/internal/httpapi/security_events_test.go new file mode 100644 index 0000000..ab0e162 --- /dev/null +++ b/apps/api/internal/httpapi/security_events_test.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identityruntime" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" +) + +type preparedReceiverBuilder struct { + receiver http.Handler +} + +func (builder *preparedReceiverBuilder) Build(context.Context, identity.Revision) (*identityruntime.Runtime, error) { + return &identityruntime.Runtime{}, nil +} + +func (builder *preparedReceiverBuilder) PreparedSecurityEventReceiver() http.Handler { + return builder.receiver +} + +func TestReceiveSecurityEventUsesPreparedReceiverBeforeFirstActivation(t *testing.T) { + called := false + builder := &preparedReceiverBuilder{receiver: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusAccepted) + })} + server := &Server{identityRuntime: identityruntime.NewManager(nil, builder)} + request := httptest.NewRequest(http.MethodPost, "/api/v1/security-events/ssf", nil) + response := httptest.NewRecorder() + + server.receiveSecurityEvent(response, request) + + if !called || response.Code != http.StatusAccepted { + t.Fatalf("prepared SSF receiver called=%t status=%d", called, response.Code) + } +} + +func TestSecurityEventWriteAuditFailsClosedBeforeMutation(t *testing.T) { + server := &Server{} + request := httptest.NewRequest(http.MethodPost, "/api/admin/system/security-events/connection/verify", nil) + recorder := httptest.NewRecorder() + + if _, ok := server.requireSecurityEventConnectionAudit( + recorder, request, "verify", "trace-test", securityevents.ConnectionView{}, + ); ok { + t.Fatal("security event write unexpectedly continued without durable audit storage") + } + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("audit failure status=%d, want 503", recorder.Code) + } +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index b6050f7..d6bf1e7 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -36,7 +36,10 @@ type Server struct { identityRuntime *identityruntime.Manager identityPairing *identity.PairingService identityManagementMu sync.Mutex + securityEventManagementMu sync.Mutex identityPairingWorkers sync.Map + identityCleanupWorkers sync.Map + identityRestoredPairings sync.Map identityTestRevision identity.Revision identityTestCookieSecure bool identityTestBrowserEnabled bool @@ -79,6 +82,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor if err != nil { panic("invalid identity SecretStore: " + err.Error()) } + go ssfreceiver.RunSecurityEventRetirementWorker(ctx, db) + go ssfreceiver.RunIdentitySecretCleanupWorker(ctx, db, secretStore) runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{ AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute, HeartbeatInterval: time.Duration(cfg.IdentitySecurityEventHeartbeatIntervalSeconds) * time.Second, @@ -90,15 +95,10 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor logger.Error("load active identity runtime failed; local management login remains available", "error_category", "identity_runtime_load_failed") } server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) { - return identity.NewOnboardingClient(baseURL, nil) - }, server.identityRuntime) - if pending, pendingErr := db.PendingIdentityPairingExchanges(ctx); pendingErr == nil { - for _, pairing := range pending { - server.startIdentityPairingWorker(pairing.ID) - } - } else if logger != nil { - logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed") - } + return identity.NewOnboardingClient(baseURL, nil, cfg.AppEnv) + }, server.identityRuntime, cfg.AppEnv) + server.reconcileCanonicalIdentityPairing() + go server.runIdentityPairingCoordinator() server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier { if runtime := server.identityRuntime.Current(); runtime != nil { return runtime.Verifier @@ -114,8 +114,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor return user, oidcSessionRequestError(resolveErr) } server.auth.LegacyJWTEnabledProvider = func() bool { - runtime := server.identityRuntime.Current() - return runtime == nil || runtime.Revision.LegacyJWTEnabled + return server.identityRuntime.LegacyJWTEnabled() } server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey server.runner.StartAsyncQueueWorker(ctx) @@ -210,6 +209,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration))) mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing))) mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing))) + mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/cancel", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.cancelIdentityPairing))) + mux.Handle("POST /api/admin/system/identity/pairings/{pairingID}/retire-conflicting-security-event", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.retireIdentityPairingSecurityEventConflict))) mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy))) mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision))) mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision))) @@ -349,7 +350,7 @@ func (s *Server) requireAdmin(permission auth.Permission, next http.Handler) htt func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") - if origin != "" && originAllowed(origin, s.cfg.CORSAllowedOrigin) { + if origin != "" && s.corsOriginAllowed(origin) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") @@ -364,10 +365,21 @@ func (s *Server) cors(next http.Handler) http.Handler { }) } +func (s *Server) corsOriginAllowed(origin string) bool { + if originAllowed(origin, s.cfg.CORSAllowedOrigin) { + return true + } + if s.identityRuntime != nil { + return originMatchesBaseURL(origin, s.identityRuntime.TrustedWebBaseURL()) + } + runtime := s.currentIdentityRuntime() + return runtime != nil && originMatchesBaseURL(origin, runtime.Revision.WebBaseURL) +} + func originAllowed(origin string, allowed string) bool { for _, item := range strings.Split(allowed, ",") { item = strings.TrimSpace(item) - if item == "*" || strings.EqualFold(origin, item) { + if item != "*" && strings.EqualFold(origin, item) { return true } } diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go index f3eb4ad..5cb41e5 100644 --- a/apps/api/internal/identity/configuration.go +++ b/apps/api/internal/identity/configuration.go @@ -11,10 +11,13 @@ import ( ) var ( - ErrRevisionNotFound = errors.New("identity configuration revision not found") - ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state") - ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required") - ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid") + ErrRevisionNotFound = errors.New("identity configuration revision not found") + ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state") + ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required") + ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid") + ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing") + ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff") + ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled") ) type RevisionPolicy struct { @@ -111,15 +114,16 @@ type ManifestApplication struct { SessionEncryptionKeyRef string TraceID string AuditID string + AppEnv string } -func NewDraft(input PairingInput) (Revision, error) { - if _, err := input.ConsumerMetadata(false); err != nil { +func NewDraft(input PairingInput, appEnv string) (Revision, error) { + if _, err := input.ConsumerMetadata(false, appEnv); err != nil { return Revision{}, err } - authCenter, _ := exactBaseURL(input.AuthCenterURL) - publicBase, _ := exactBaseURL(input.PublicBaseURL) - webBase, _ := exactBaseURL(input.WebBaseURL) + authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv) + publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv) + webBase, _ := exactBaseURL(input.WebBaseURL, appEnv) return Revision{ ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey), @@ -133,7 +137,7 @@ func ApplyManifest(revision Revision, input ManifestApplication) (Revision, erro if revision.State != RevisionDraft { return Revision{}, ErrRevisionConflict } - if err := input.Manifest.Validate(); err != nil { + if err := input.Manifest.Validate(input.AppEnv); err != nil { return Revision{}, err } capabilities := make(map[string]bool, len(input.Manifest.Capabilities)) @@ -187,17 +191,17 @@ func CanTransition(from, to RevisionState) bool { } } -func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) { - authCenter, err := exactBaseURL(input.AuthCenterURL) +func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) { + authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv) if err != nil { return ConsumerMetadata{}, errors.New("auth center URL is invalid") } _ = authCenter - publicBase, err := exactBaseURL(input.PublicBaseURL) + publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv) if err != nil { return ConsumerMetadata{}, errors.New("public base URL is invalid") } - webBase, err := exactBaseURL(input.WebBaseURL) + webBase, err := exactBaseURL(input.WebBaseURL, appEnv) if err != nil { return ConsumerMetadata{}, errors.New("web base URL is invalid") } @@ -215,7 +219,7 @@ func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMeta return metadata, nil } -func exactBaseURL(raw string) (string, error) { +func exactBaseURL(raw, appEnv string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", errors.New("invalid public URL") @@ -225,7 +229,7 @@ func exactBaseURL(raw string) (string, error) { return "", errors.New("base URL must not contain a path") } scheme := strings.ToLower(parsed.Scheme) - if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) { + if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) { return "", errors.New("public URL must use HTTPS") } port := parsed.Port() @@ -242,6 +246,49 @@ func exactBaseURL(raw string) (string, error) { return scheme + "://" + host, nil } +func isLocalIdentityEnvironment(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "development", "dev", "local", "test": + return true + default: + return false + } +} + +// ValidateRevisionURLs re-applies the deployment environment URL policy when +// constructing a Runtime. This protects against legacy or directly persisted +// revisions bypassing the pairing boundary. +func ValidateRevisionURLs(revision Revision, appEnv string) error { + urls := []struct { + label string + raw string + base bool + }{ + {label: "auth center", raw: revision.AuthCenterURL, base: true}, + {label: "issuer", raw: revision.Issuer}, + {label: "public base", raw: revision.PublicBaseURL, base: true}, + {label: "web base", raw: revision.WebBaseURL, base: true}, + } + for _, candidate := range urls { + var err error + if candidate.base { + _, err = exactBaseURL(candidate.raw, appEnv) + } else { + err = validatePublicIdentityURL(candidate.raw, appEnv) + } + if err != nil { + return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment") + } + } + if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" { + if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil || + validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil { + return errors.New("identity security event URLs must use HTTPS in this environment") + } + } + return nil +} + func isLoopbackHost(host string) bool { if host == "localhost" { return true diff --git a/apps/api/internal/identity/configuration_test.go b/apps/api/internal/identity/configuration_test.go index 1b7b745..f71eede 100644 --- a/apps/api/internal/identity/configuration_test.go +++ b/apps/api/internal/identity/configuration_test.go @@ -48,7 +48,7 @@ func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) { AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", } - metadata, err := input.ConsumerMetadata(true) + metadata, err := input.ConsumerMetadata(true, "production") if err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) { {AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, {AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, } { - if _, err := input.ConsumerMetadata(false); err == nil { + if _, err := input.ConsumerMetadata(false, "production"); err == nil { t.Fatalf("unsafe pairing input accepted: %#v", input) } } @@ -75,7 +75,7 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", LegacyJWTEnabled: true, - }) + }, "production") if err != nil { t.Fatal(err) } @@ -87,3 +87,41 @@ func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testin t.Fatalf("draft exposed onboarding code: %s", payload) } } + +func TestNewDraftAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { + localInput := PairingInput{ + AuthCenterURL: "http://localhost:18000", PublicBaseURL: "http://127.0.0.1:18089", + WebBaseURL: "http://localhost:5178", LocalTenantKey: "default", + } + secureInput := PairingInput{ + AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + } + for _, test := range []struct { + name string + mutate func(*PairingInput) + }{ + {name: "auth center", mutate: func(input *PairingInput) { input.AuthCenterURL = localInput.AuthCenterURL }}, + {name: "public base", mutate: func(input *PairingInput) { input.PublicBaseURL = localInput.PublicBaseURL }}, + {name: "web base", mutate: func(input *PairingInput) { input.WebBaseURL = localInput.WebBaseURL }}, + } { + t.Run(test.name, func(t *testing.T) { + input := secureInput + test.mutate(&input) + for _, appEnv := range []string{"", "production", "staging"} { + if _, err := NewDraft(input, appEnv); err == nil { + t.Fatalf("%s accepted loopback HTTP %s URL", appEnv, test.name) + } + } + }) + } + for _, appEnv := range []string{"local", "development", "dev", "test"} { + draft, err := NewDraft(localInput, appEnv) + if err != nil { + t.Fatalf("%s rejected loopback HTTP pairing URLs: %v", appEnv, err) + } + if draft.AuthCenterURL != localInput.AuthCenterURL || draft.PublicBaseURL != localInput.PublicBaseURL || draft.WebBaseURL != localInput.WebBaseURL { + t.Fatalf("%s changed normalized loopback URLs: %#v", appEnv, draft) + } + } +} diff --git a/apps/api/internal/identity/onboarding_client.go b/apps/api/internal/identity/onboarding_client.go index 1b4030b..ec343f7 100644 --- a/apps/api/internal/identity/onboarding_client.go +++ b/apps/api/internal/identity/onboarding_client.go @@ -85,8 +85,8 @@ type CredentialDelivery struct { Version int64 `json:"-"` } -func (manifest ManifestV1) Validate() error { - if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil { +func (manifest ManifestV1) Validate(appEnv string) error { + if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer, appEnv) != nil { return errors.New("application manifest identity metadata is invalid") } if _, err := uuid.Parse(manifest.TenantID); err != nil { @@ -116,9 +116,12 @@ func (manifest ManifestV1) Validate() error { if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" { return errors.New("application manifest audience is missing") } - if capabilities["session_revocation"] { - if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil || - validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" { + if capabilities["session_revocation"] && manifest.SecurityEvents == nil { + return errors.New("application manifest security event metadata is invalid") + } + if manifest.SecurityEvents != nil { + if validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer, appEnv) != nil || + validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint, appEnv) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" { return errors.New("application manifest security event metadata is invalid") } } @@ -133,8 +136,8 @@ func (manifest ManifestV1) Validate() error { return nil } -func validatePublicIdentityURL(raw string) error { - _, err := exactBaseURL(raw) +func validatePublicIdentityURL(raw, appEnv string) error { + _, err := exactBaseURL(raw, appEnv) if err == nil { return nil } @@ -145,7 +148,7 @@ func validatePublicIdentityURL(raw string) error { return errors.New("identity URL is invalid") } scheme := strings.ToLower(parsed.URL.Scheme) - if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) { + if scheme == "https" || scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) { return nil } return errors.New("identity URL must use HTTPS") @@ -154,10 +157,11 @@ func validatePublicIdentityURL(raw string) error { type OnboardingClient struct { baseURL string client *http.Client + appEnv string } -func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) { - normalized, err := exactBaseURL(baseURL) +func NewOnboardingClient(baseURL string, base *http.Client, appEnv string) (*OnboardingClient, error) { + normalized, err := exactBaseURL(baseURL, appEnv) if err != nil { return nil, errors.New("auth center URL is invalid") } @@ -169,7 +173,7 @@ func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, client.Timeout = 10 * time.Second } client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } - return &OnboardingClient{baseURL: normalized, client: &client}, nil + return &OnboardingClient{baseURL: normalized, client: &client, appEnv: appEnv}, nil } func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) { @@ -208,7 +212,7 @@ func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange if err != nil || status != http.StatusOK { return CredentialDelivery{}, onboardingProtocolError(err) } - if err := output.Manifest.Validate(); err != nil { + if err := output.Manifest.Validate(client.appEnv); err != nil { return CredentialDelivery{}, err } output.Version, err = parseWeakETag(etag) diff --git a/apps/api/internal/identity/onboarding_client_test.go b/apps/api/internal/identity/onboarding_client_test.go index aadce38..56aecf4 100644 --- a/apps/api/internal/identity/onboarding_client_test.go +++ b/apps/api/internal/identity/onboarding_client_test.go @@ -39,7 +39,7 @@ func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) { })) defer server.Close() - client, err := NewOnboardingClient(server.URL, server.Client()) + client, err := NewOnboardingClient(server.URL, server.Client(), "production") if err != nil { t.Fatal(err) } @@ -63,17 +63,17 @@ func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *te Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}}, SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}, } - if err := valid.Validate(); err != nil { + if err := valid.Validate("production"); err != nil { t.Fatal(err) } invalid := valid invalid.Capabilities = []string{"session_revocation"} - if err := invalid.Validate(); err == nil { + if err := invalid.Validate("production"); err == nil { t.Fatal("manifest with missing capability dependencies was accepted") } invalid = valid invalid.SchemaVersion = 2 - if err := invalid.Validate(); err == nil { + if err := invalid.Validate("production"); err == nil { t.Fatal("unsupported manifest schema was accepted") } } @@ -87,7 +87,7 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) { http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) })) defer redirect.Close() - client, err := NewOnboardingClient(redirect.URL, redirect.Client()) + client, err := NewOnboardingClient(redirect.URL, redirect.Client(), "production") if err != nil { t.Fatal(err) } @@ -95,3 +95,61 @@ func TestOnboardingClientRejectsRedirects(t *testing.T) { t.Fatal("redirecting onboarding endpoint was accepted") } } + +func TestManifestAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { + manifest := ManifestV1{ + SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/easyai", + TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + Capabilities: []string{"machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"gateway.access"}, + Clients: ManifestClients{MachineToMachine: &ManifestClient{ClientID: "service"}}, + SecurityEvents: &ManifestSecurityEvents{ + TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", + Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + }, + } + for _, test := range []struct { + name string + mutate func(*ManifestV1) + }{ + {name: "OIDC issuer", mutate: func(value *ManifestV1) { value.Issuer = "http://localhost:18003/issuer/easyai" }}, + {name: "SSF issuer", mutate: func(value *ManifestV1) { value.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" }}, + {name: "SSF configuration", mutate: func(value *ManifestV1) { + value.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf" + }}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := manifest + securityEvents := *manifest.SecurityEvents + candidate.SecurityEvents = &securityEvents + test.mutate(&candidate) + if err := candidate.Validate("production"); err == nil { + t.Fatalf("production accepted loopback HTTP %s URL", test.name) + } + }) + } + localManifest := manifest + localSecurityEvents := *manifest.SecurityEvents + localManifest.SecurityEvents = &localSecurityEvents + localManifest.Issuer = "http://localhost:18003/issuer/easyai" + localManifest.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" + localManifest.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf" + if err := localManifest.Validate("test"); err != nil { + t.Fatalf("test environment rejected loopback HTTP manifest URLs: %v", err) + } + localManifest.Capabilities = []string{"machine_to_machine"} + localManifest.Issuer = manifest.Issuer + if err := localManifest.Validate("production"); err == nil { + t.Fatal("production accepted optional loopback HTTP security event metadata") + } +} + +func TestOnboardingClientAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + if _, err := NewOnboardingClient(server.URL, server.Client(), "production"); err == nil { + t.Fatal("production accepted loopback HTTP Auth Center URL") + } + if _, err := NewOnboardingClient(server.URL, server.Client(), "development"); err != nil { + t.Fatalf("development rejected loopback HTTP Auth Center URL: %v", err) + } +} diff --git a/apps/api/internal/identity/pairing_service.go b/apps/api/internal/identity/pairing_service.go index 7ddc79c..7d4244e 100644 --- a/apps/api/internal/identity/pairing_service.go +++ b/apps/api/internal/identity/pairing_service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "errors" "strings" + "sync" "time" "github.com/google/uuid" @@ -20,22 +21,45 @@ const ( PairingCompleted PairingStatus = "completed" PairingFailed PairingStatus = "failed" PairingExpired PairingStatus = "expired" + PairingCancelled PairingStatus = "cancelled" +) + +type PairingCleanupStatus string + +const ( + PairingCleanupNone PairingCleanupStatus = "none" + PairingCleanupPending PairingCleanupStatus = "pending" + PairingCleanupCompleted PairingCleanupStatus = "completed" +) + +var ( + ErrPairingInProgress = errors.New("an identity pairing is already in progress") + ErrPairingNotCancellable = errors.New("identity pairing cannot be cancelled") + ErrPairingConflictNotResolvable = errors.New("identity pairing has no resolvable security event conflict") +) + +const ( + identityPairingStartReservationTTL = 2 * time.Minute + identityPairingStartReleaseTimeout = 5 * time.Second ) type PairingExchange struct { - ID string `json:"id"` - RevisionID string `json:"revisionId"` - RemoteExchangeID string `json:"remoteExchangeId"` - ExchangeTokenRef string `json:"-"` - Status PairingStatus `json:"status"` - RemoteVersion int64 `json:"remoteVersion"` - ExpiresAt time.Time `json:"expiresAt"` - Version int64 `json:"version"` - LastErrorCategory string `json:"lastErrorCategory,omitempty"` - AuthCenterAuditID string `json:"authCenterAuditId,omitempty"` - LastTraceID string `json:"lastTraceId,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + RevisionID string `json:"revisionId"` + RemoteExchangeID string `json:"remoteExchangeId"` + ExchangeTokenRef string `json:"-"` + Status PairingStatus `json:"status"` + CleanupStatus PairingCleanupStatus `json:"cleanupStatus"` + RemoteVersion int64 `json:"remoteVersion"` + ExpiresAt time.Time `json:"expiresAt"` + Version int64 `json:"version"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + AuthCenterAuditID string `json:"authCenterAuditId,omitempty"` + LastTraceID string `json:"lastTraceId,omitempty"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + CleanupCompletedAt *time.Time `json:"cleanupCompletedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type PairingExchangeUpdate struct { @@ -46,12 +70,20 @@ type PairingExchangeUpdate struct { } type PairingRepository interface { - CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error) + ReserveIdentityPairingStart(context.Context, string, time.Time) error + ReleaseIdentityPairingStart(context.Context, string) error + CommitIdentityPairingStart(context.Context, Revision, PairingExchange) (PairingExchange, error) + IdentityPairingStartBlocked(context.Context) (bool, error) IdentityConfigurationRevision(context.Context, string) (Revision, error) ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error) - CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error) IdentityPairingExchange(context.Context, string) (PairingExchange, error) UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error) + RecordIdentityPairingRetryFailure(context.Context, string, PairingStatus, string) (PairingExchange, error) + CancelIdentityPairingExchange(context.Context, string, int64, string, string) (PairingExchange, error) + CompleteIdentityPairingCleanup(context.Context, string, int64) (PairingExchange, error) + RecordIdentityPairingCleanupFailure(context.Context, string, string) (PairingExchange, error) + QueueIdentitySecretCleanup(context.Context, string, time.Time) error + RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error) } type PairingSecretStore interface { @@ -70,6 +102,11 @@ type OnboardingRemote interface { type SecurityEventPreparer interface { PrepareSecurityEvents(context.Context, Revision, []byte) error + CleanupPreparedSecurityEvents(context.Context, Revision) error +} + +type SecurityEventConflictResolver interface { + RetireConflictingSecurityEvents(context.Context, Revision) error } type PairingService struct { @@ -77,22 +114,37 @@ type PairingService struct { secrets PairingSecretStore remote func(string) (OnboardingRemote, error) security SecurityEventPreparer + appEnv string + operations sync.Map } -func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService { - return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security} +func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer, appEnvs ...string) *PairingService { + appEnv := "production" + if len(appEnvs) > 0 { + appEnv = appEnvs[0] + } + return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security, appEnv: appEnv} } func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) { - draft, err := NewDraft(input) + draft, err := NewDraft(input, service.appEnv) if err != nil { return PairingExchange{}, err } draft.LastTraceID = strings.TrimSpace(traceID) - draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft) - if err != nil { + pairingID := uuid.NewString() + tokenReference := "identity-exchange-" + pairingID + if err := service.repository.ReserveIdentityPairingStart(ctx, pairingID, time.Now().UTC().Add(identityPairingStartReservationTTL)); err != nil { return PairingExchange{}, err } + committed := false + defer func() { + if !committed { + releaseContext, cancel := context.WithTimeout(context.Background(), identityPairingStartReleaseTimeout) + defer cancel() + _ = service.repository.ReleaseIdentityPairingStart(releaseContext, pairingID) + } + }() remote, err := service.remote(draft.AuthCenterURL) if err != nil { return PairingExchange{}, err @@ -101,32 +153,211 @@ func (service *PairingService) Start(ctx context.Context, input PairingInput, tr if err != nil { return PairingExchange{}, err } - pairingID := uuid.NewString() - tokenReference := "identity-exchange-" + pairingID - if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil { + token := []byte(claimed.ExchangeToken) + claimed.ExchangeToken = "" + if err := service.stageIdentitySecret(ctx, tokenReference, token); err != nil { + clear(token) return PairingExchange{}, err } - claimed.ExchangeToken = "" + clear(token) pairing := PairingExchange{ ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference, - Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt, + Status: PairingMetadataPending, CleanupStatus: PairingCleanupNone, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt, Version: 1, LastTraceID: strings.TrimSpace(traceID), } - pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing) + pairing, err = service.repository.CommitIdentityPairingStart(ctx, draft, pairing) if err != nil { - _ = service.secrets.Delete(ctx, tokenReference) + // Commit may have succeeded even when its response was lost. The + // staging row is the source of truth: rollback leaves it queued, while + // a successful commit atomically adopts it. Re-queueing here could + // delete a Secret that the new Pairing already references. return PairingExchange{}, err } + committed = true return pairing, nil } +func (service *PairingService) Cancel(ctx context.Context, pairingID string, expectedVersion int64, traceID, auditID string) (PairingExchange, error) { + return service.repository.CancelIdentityPairingExchange(ctx, pairingID, expectedVersion, strings.TrimSpace(traceID), strings.TrimSpace(auditID)) +} + +func (service *PairingService) RetireConflictingSecurityEvents(ctx context.Context, pairingID string, expectedVersion int64) (PairingExchange, error) { + unlock := service.lockPairingOperation(pairingID) + defer unlock() + pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) + if err != nil { + return PairingExchange{}, err + } + if pairing.Version != expectedVersion { + return pairing, ErrRevisionConflict + } + if pairing.Status != PairingCredentialsSaved || pairing.CleanupStatus != PairingCleanupNone || + pairing.LastErrorCategory != "security_event_connection_conflict" { + return pairing, ErrPairingConflictNotResolvable + } + revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) + if err != nil { + return pairing, err + } + if revision.State != RevisionDraft || !revision.SessionRevocation { + return pairing, ErrPairingConflictNotResolvable + } + resolver, ok := service.security.(SecurityEventConflictResolver) + if !ok { + return pairing, errors.New("security event conflict recovery is unavailable") + } + if err := resolver.RetireConflictingSecurityEvents(ctx, revision); err != nil { + return pairing, err + } + return service.repository.RecordIdentityPairingRetryFailure(ctx, pairing.ID, pairing.Status, "security_event_retirement_pending") +} + +// RestoreCompletedSecurityEvents reconstructs the prepared Receiver after a +// process restart while a completed Revision is still awaiting validation or +// activation. The machine Secret is read only from SecretStore and cleared +// immediately after use. +func (service *PairingService) RestoreCompletedSecurityEvents(ctx context.Context, pairingID string) error { + unlock := service.lockPairingOperation(pairingID) + defer unlock() + pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) + if err != nil { + return err + } + if pairing.Status != PairingCompleted { + return ErrRevisionConflict + } + revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) + if err != nil { + return err + } + if revision.State == RevisionActive { + return nil + } + if revision.State != RevisionDraft && revision.State != RevisionValidated { + return ErrRevisionConflict + } + if !revision.SessionRevocation { + return nil + } + if service.security == nil || revision.MachineCredentialRef == "" { + return errors.New("completed security event configuration is unavailable") + } + secret, err := service.secrets.Get(ctx, revision.MachineCredentialRef) + if err != nil { + return err + } + defer clear(secret) + if err := service.security.PrepareSecurityEvents(ctx, revision, secret); err != nil { + return err + } + currentRevision, revisionErr := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) + if revisionErr != nil { + return revisionErr + } + if currentRevision.State == RevisionActive { + return nil + } + currentPairing, pairingErr := service.repository.IdentityPairingExchange(ctx, pairingID) + if pairingErr != nil { + return pairingErr + } + if currentPairing.Status == PairingCompleted && + (currentRevision.State == RevisionDraft || currentRevision.State == RevisionValidated) { + return nil + } + if cleanupErr := service.security.CleanupPreparedSecurityEvents(ctx, currentRevision); cleanupErr != nil { + return cleanupErr + } + return ErrRevisionConflict +} + +func (service *PairingService) Cleanup(ctx context.Context, pairingID string) (PairingExchange, error) { + unlock := service.lockPairingOperation(pairingID) + defer unlock() + pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) + if err != nil { + return PairingExchange{}, err + } + if pairing.Status != PairingCancelled { + return pairing, ErrPairingNotCancellable + } + if pairing.CleanupStatus == PairingCleanupCompleted { + return pairing, nil + } + if pairing.CleanupStatus != PairingCleanupPending { + return pairing, ErrRevisionConflict + } + revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) + if err != nil { + return service.recordCleanupFailure(ctx, pairing, "cleanup_revision_unavailable", err) + } + if revision.SessionRevocation { + if service.security == nil { + return service.recordCleanupFailure(ctx, pairing, "cleanup_security_event_unavailable", errors.New("security event cleanup is unavailable")) + } + if err := service.security.CleanupPreparedSecurityEvents(ctx, revision); err != nil { + return service.recordCleanupFailure(ctx, pairing, pairingCleanupFailureCategory(err), err) + } + } + for _, reference := range []string{pairing.ExchangeTokenRef, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef} { + if reference == "" { + continue + } + if err := service.secrets.Delete(ctx, reference); err != nil { + return service.recordCleanupFailure(ctx, pairing, "cleanup_secret_store_failed", err) + } + } + completed, err := service.repository.CompleteIdentityPairingCleanup(ctx, pairing.ID, pairing.Version) + if err != nil { + return service.recordCleanupFailure(ctx, pairing, "cleanup_finalize_failed", err) + } + return completed, nil +} + +func (service *PairingService) recordCleanupFailure(ctx context.Context, pairing PairingExchange, category string, cause error) (PairingExchange, error) { + if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { + return pairing, cause + } + recorded, err := service.repository.RecordIdentityPairingCleanupFailure(ctx, pairing.ID, category) + if err != nil { + return pairing, cause + } + return recorded, cause +} + func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) { + unlock := service.lockPairingOperation(pairingID) + defer unlock() + pairing, err := service.continuePairing(ctx, pairingID) + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return pairing, err + } + current, currentErr := service.repository.IdentityPairingExchange(ctx, pairingID) + if currentErr != nil || !isPairingInProgress(current.Status) { + return pairing, err + } + category := pairingRetryFailureCategory(current.Status, err) + recorded, recordErr := service.repository.RecordIdentityPairingRetryFailure(ctx, current.ID, current.Status, category) + if recordErr != nil { + return current, err + } + return recorded, err +} + +func (service *PairingService) lockPairingOperation(pairingID string) func() { + value, _ := service.operations.LoadOrStore(pairingID, &sync.Mutex{}) + mutex := value.(*sync.Mutex) + mutex.Lock() + return mutex.Unlock +} + +func (service *PairingService) continuePairing(ctx context.Context, pairingID string) (PairingExchange, error) { for step := 0; step < 6; step++ { pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) if err != nil { return PairingExchange{}, err } - if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired { + if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired || pairing.Status == PairingCancelled { return pairing, nil } if !pairing.ExpiresAt.After(time.Now()) { @@ -134,7 +365,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired", }) if updateErr == nil { - _ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef) + service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef) } return expired, updateErr } @@ -156,7 +387,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( metadata, metadataErr := PairingInput{ AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL, WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey, - }.ConsumerMetadata(true) + }.ConsumerMetadata(true, service.appEnv) if metadataErr != nil { clear(token) return PairingExchange{}, metadataErr @@ -224,7 +455,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion) clear(token) if completeErr != nil { - return PairingExchange{}, completeErr + return PairingExchange{}, pairingStepError{category: "exchange_completion_failed", cause: completeErr} } completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion, @@ -232,7 +463,7 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( if err != nil { return PairingExchange{}, err } - _ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef) + service.deleteRetiredIdentitySecret(ctx, pairing.ExchangeTokenRef) return completed, nil default: clear(token) @@ -242,8 +473,71 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) ( return service.repository.IdentityPairingExchange(ctx, pairingID) } +func isPairingInProgress(status PairingStatus) bool { + switch status { + case PairingMetadataPending, PairingPreparing, PairingReady, PairingCredentialsSaved: + return true + default: + return false + } +} + +type safeErrorCategory interface { + SafeErrorCategory() string +} + +type pairingStepError struct { + category string + cause error +} + +func (err pairingStepError) Error() string { return "identity pairing step failed: " + err.category } +func (err pairingStepError) Unwrap() error { return err.cause } +func (err pairingStepError) SafeErrorCategory() string { return err.category } + +func pairingRetryFailureCategory(status PairingStatus, err error) string { + var categorized safeErrorCategory + if errors.As(err, &categorized) && categorized.SafeErrorCategory() == "exchange_completion_failed" { + return "exchange_completion_failed" + } + switch status { + case PairingMetadataPending: + return "metadata_submission_failed" + case PairingPreparing: + return "exchange_status_unavailable" + case PairingReady: + return "credential_delivery_failed" + case PairingCredentialsSaved: + if errors.As(err, &categorized) { + switch categorized.SafeErrorCategory() { + case "configuration_invalid", "connection_conflict", "discovery_failed", "management_token_failed", + "stream_create_failed", "stream_response_invalid", "receiver_activation_failed", "preparation_failed", "retirement_pending", + "credential_handoff_unsafe", "connection_binding_missing", "connection_binding_unavailable", + "connection_binding_invalid", "connection_binding_mismatch": + return "security_event_" + categorized.SafeErrorCategory() + } + } + return "security_event_preparation_failed" + default: + return "pairing_step_failed" + } +} + +func pairingCleanupFailureCategory(err error) string { + var categorized safeErrorCategory + if errors.As(err, &categorized) { + switch categorized.SafeErrorCategory() { + case "configuration_invalid", "retirement_pending", "connection_conflict", "connection_cleanup_failed", "secret_cleanup_failed", + "discovery_failed", "management_token_failed", "stream_create_failed", "stream_response_invalid", + "receiver_activation_failed", "preparation_failed", "credential_handoff_unsafe": + return "cleanup_security_event_" + categorized.SafeErrorCategory() + } + } + return "cleanup_security_event_failed" +} + func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) { - if err := delivery.Manifest.Validate(); err != nil { + if err := delivery.Manifest.Validate(service.appEnv); err != nil { return Revision{}, err } machineReference := "" @@ -251,10 +545,10 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID { return Revision{}, errors.New("onboarding machine credential is invalid") } - machineReference = "identity-machine-" + revision.ID + machineReference = "identity-machine-" + uuid.NewString() secret := []byte(delivery.MachineCredential.ClientSecret) delivery.MachineCredential.ClientSecret = "" - if err := service.secrets.Put(ctx, machineReference, secret); err != nil { + if err := service.stageIdentitySecret(ctx, machineReference, secret); err != nil { clear(secret) return Revision{}, err } @@ -262,49 +556,77 @@ func (service *PairingService) saveDelivery(ctx context.Context, revision Revisi } sessionReference := "" if delivery.Manifest.Clients.BrowserLogin != nil { - sessionReference = "identity-session-" + revision.ID + sessionReference = "identity-session-" + uuid.NewString() key := make([]byte, 32) if _, err := rand.Read(key); err != nil { - _ = service.secrets.Delete(ctx, machineReference) + _ = service.retireIdentitySecret(ctx, machineReference) return Revision{}, err } - if err := service.secrets.Put(ctx, sessionReference, key); err != nil { + if err := service.stageIdentitySecret(ctx, sessionReference, key); err != nil { clear(key) - _ = service.secrets.Delete(ctx, machineReference) + _ = service.retireIdentitySecret(ctx, machineReference) return Revision{}, err } clear(key) } updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{ Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference, - TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, + TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, AppEnv: service.appEnv, }) if err != nil { - if machineReference != "" { - _ = service.secrets.Delete(ctx, machineReference) - } - if sessionReference != "" { - _ = service.secrets.Delete(ctx, sessionReference) - } + // ApplyIdentityManifest adopts the staged references in the same DB + // transaction as the Revision update. On rollback the staging rows + // remain; on an ambiguous successful commit they are gone. Do not + // re-queue here or a valid active credential could be destroyed. return Revision{}, err } return updated, nil } +func (service *PairingService) stageIdentitySecret(ctx context.Context, reference string, value []byte) error { + if err := service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(10*time.Minute)); err != nil { + return err + } + if err := service.secrets.Put(ctx, reference, value); err != nil { + deleteErr := service.secrets.Delete(ctx, reference) + if deleteErr == nil { + _, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference) + } + return err + } + return nil +} + +func (service *PairingService) retireIdentitySecret(ctx context.Context, reference string) error { + if reference == "" { + return nil + } + return service.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC()) +} + +func (service *PairingService) deleteRetiredIdentitySecret(ctx context.Context, reference string) { + if err := service.secrets.Delete(ctx, reference); err == nil { + _, _ = service.repository.RemovePendingIdentitySecretCleanup(ctx, reference) + } +} + func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) { status := PairingPreparing + category := "" switch remote.Status { case ExchangeReady, ExchangeCredentialDelivered: status = PairingReady case ExchangeCompleted: status = PairingFailed - remote.LastErrorCategory = "remote_state_invalid" + category = "remote_state_invalid" case ExchangeFailed: status = PairingFailed + category = "remote_exchange_failed" case ExchangeExpired: status = PairingExpired + category = "exchange_expired" } return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ - Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID, + Status: status, RemoteVersion: remote.Version, LastErrorCategory: category, AuthCenterAuditID: remote.AuditID, }) } diff --git a/apps/api/internal/identity/pairing_service_test.go b/apps/api/internal/identity/pairing_service_test.go index dd73124..c6869e6 100644 --- a/apps/api/internal/identity/pairing_service_test.go +++ b/apps/api/internal/identity/pairing_service_test.go @@ -2,14 +2,68 @@ package identity import ( "context" + "encoding/json" "errors" + "strings" + "sync" "testing" "time" ) type pairingRepositoryFake struct { - revision Revision - exchange PairingExchange + revision Revision + exchange PairingExchange + blocked bool + startReservationErr error + startReservationID string + startReservationState string + startReservationCalls int + startReservationReleases int + pendingSecretCleanups map[string]time.Time + commitStartErr error + commitStartAmbiguous bool + applyManifestAmbiguousOnce bool + failCredentialsSavedOnce bool +} + +func (f *pairingRepositoryFake) ReserveIdentityPairingStart(_ context.Context, attemptID string, _ time.Time) error { + f.startReservationCalls++ + if f.startReservationErr != nil { + return f.startReservationErr + } + if f.blocked || f.startReservationID != "" { + return ErrPairingInProgress + } + f.startReservationID = attemptID + f.startReservationState = "starting" + return nil +} +func (f *pairingRepositoryFake) ReleaseIdentityPairingStart(_ context.Context, attemptID string) error { + if f.startReservationID == attemptID && f.startReservationState == "starting" { + f.startReservationID = "" + f.startReservationState = "" + f.startReservationReleases++ + } + return nil +} +func (f *pairingRepositoryFake) CommitIdentityPairingStart(_ context.Context, revision Revision, exchange PairingExchange) (PairingExchange, error) { + if f.commitStartErr != nil && !f.commitStartAmbiguous { + return PairingExchange{}, f.commitStartErr + } + if f.startReservationID != exchange.ID || f.startReservationState != "starting" { + return PairingExchange{}, ErrPairingInProgress + } + if _, staged := f.pendingSecretCleanups[exchange.ExchangeTokenRef]; !staged { + return PairingExchange{}, errors.New("exchange token was not staged") + } + delete(f.pendingSecretCleanups, exchange.ExchangeTokenRef) + f.revision = revision + f.exchange = exchange + f.startReservationState = "paired" + if f.commitStartAmbiguous { + return PairingExchange{}, f.commitStartErr + } + return exchange, nil } func (f *pairingRepositoryFake) CreateIdentityConfigurationRevision(_ context.Context, revision Revision) (Revision, error) { @@ -20,12 +74,33 @@ func (f *pairingRepositoryFake) IdentityConfigurationRevision(context.Context, s return f.revision, nil } func (f *pairingRepositoryFake) ApplyIdentityManifest(_ context.Context, _ string, _ int64, applied ManifestApplication) (Revision, error) { + for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} { + if reference == "" { + continue + } + if _, staged := f.pendingSecretCleanups[reference]; !staged { + return Revision{}, errors.New("identity secret was not staged") + } + } + oldMachineReference, oldSessionReference := f.revision.MachineCredentialRef, f.revision.SessionEncryptionKeyRef revision, err := ApplyManifest(f.revision, applied) if err != nil { return Revision{}, err } revision.Version++ f.revision = revision + for _, reference := range []string{applied.MachineCredentialRef, applied.SessionEncryptionKeyRef} { + delete(f.pendingSecretCleanups, reference) + } + for _, reference := range []string{oldMachineReference, oldSessionReference} { + if reference != "" && reference != applied.MachineCredentialRef && reference != applied.SessionEncryptionKeyRef { + f.pendingSecretCleanups[reference] = time.Now() + } + } + if f.applyManifestAmbiguousOnce { + f.applyManifestAmbiguousOnce = false + return Revision{}, errors.New("manifest commit response lost") + } return revision, nil } func (f *pairingRepositoryFake) CreateIdentityPairingExchange(_ context.Context, exchange PairingExchange) (PairingExchange, error) { @@ -39,9 +114,97 @@ func (f *pairingRepositoryFake) UpdateIdentityPairingExchange(_ context.Context, if f.exchange.ID != id || f.exchange.Version != expected { return PairingExchange{}, ErrRevisionConflict } + if update.Status == PairingCredentialsSaved && f.failCredentialsSavedOnce { + f.failCredentialsSavedOnce = false + return PairingExchange{}, errors.New("pairing status temporarily unavailable") + } f.exchange.Status, f.exchange.RemoteVersion = update.Status, update.RemoteVersion f.exchange.LastErrorCategory, f.exchange.AuthCenterAuditID = update.LastErrorCategory, update.AuthCenterAuditID f.exchange.Version++ + if update.Status == PairingCompleted || update.Status == PairingFailed || update.Status == PairingExpired { + if f.pendingSecretCleanups == nil { + f.pendingSecretCleanups = map[string]time.Time{} + } + f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now() + } + return f.exchange, nil +} +func (f *pairingRepositoryFake) RecordIdentityPairingRetryFailure(_ context.Context, id string, status PairingStatus, category string) (PairingExchange, error) { + if f.exchange.ID != id || f.exchange.Status != status { + return PairingExchange{}, ErrRevisionConflict + } + f.exchange.LastErrorCategory = category + return f.exchange, nil +} +func (f *pairingRepositoryFake) IdentityPairingStartBlocked(context.Context) (bool, error) { + return f.blocked, nil +} +func (f *pairingRepositoryFake) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error { + if f.pendingSecretCleanups == nil { + f.pendingSecretCleanups = map[string]time.Time{} + } + current, exists := f.pendingSecretCleanups[reference] + if !exists || notBefore.Before(current) { + f.pendingSecretCleanups[reference] = notBefore + } + return nil +} +func (f *pairingRepositoryFake) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) { + if _, exists := f.pendingSecretCleanups[reference]; !exists { + return false, nil + } + delete(f.pendingSecretCleanups, reference) + return true, nil +} +func (f *pairingRepositoryFake) CancelIdentityPairingExchange(_ context.Context, id string, expected int64, traceID, auditID string) (PairingExchange, error) { + if f.exchange.ID != id || f.revision.State == RevisionActive || f.revision.State == RevisionSuperseded { + return PairingExchange{}, ErrRevisionConflict + } + if f.exchange.Status == PairingCancelled { + return f.exchange, nil + } + if f.exchange.Version != expected { + return PairingExchange{}, ErrRevisionConflict + } + f.exchange.Status = PairingCancelled + f.exchange.CleanupStatus = PairingCleanupPending + f.exchange.Version++ + now := time.Now().UTC() + f.exchange.CancelledAt = &now + f.revision.State = RevisionFailed + f.revision.LastErrorCategory = "pairing_cancelled" + f.revision.LastTraceID = traceID + f.revision.LastAuditID = auditID + f.revision.Version++ + if f.exchange.ExchangeTokenRef != "" { + if f.pendingSecretCleanups == nil { + f.pendingSecretCleanups = map[string]time.Time{} + } + f.pendingSecretCleanups[f.exchange.ExchangeTokenRef] = time.Now() + } + return f.exchange, nil +} +func (f *pairingRepositoryFake) CompleteIdentityPairingCleanup(_ context.Context, id string, expected int64) (PairingExchange, error) { + if f.exchange.ID != id || f.exchange.Version != expected || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending { + return PairingExchange{}, ErrRevisionConflict + } + f.exchange.CleanupStatus = PairingCleanupCompleted + f.exchange.Version++ + now := time.Now().UTC() + f.exchange.CleanupCompletedAt = &now + f.revision.MachineCredentialRef = "" + f.revision.SessionEncryptionKeyRef = "" + if f.startReservationState == "paired" && f.startReservationID == f.exchange.ID { + f.startReservationID = "" + f.startReservationState = "" + } + return f.exchange, nil +} +func (f *pairingRepositoryFake) RecordIdentityPairingCleanupFailure(_ context.Context, id string, category string) (PairingExchange, error) { + if f.exchange.ID != id || f.exchange.Status != PairingCancelled || f.exchange.CleanupStatus != PairingCleanupPending { + return PairingExchange{}, ErrRevisionConflict + } + f.exchange.LastErrorCategory = category return f.exchange, nil } @@ -74,14 +237,18 @@ func (f *secretStoreFake) Delete(_ context.Context, reference string) error { type onboardingRemoteFake struct { claimed ClaimedExchange + claimErr error + claimCalls int view Exchange delivery CredentialDelivery deliveryCalls int completed bool + completeErr error } func (f *onboardingRemoteFake) Claim(context.Context, string) (ClaimedExchange, error) { - return f.claimed, nil + f.claimCalls++ + return f.claimed, f.claimErr } func (f *onboardingRemoteFake) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) { f.view.Status, f.view.Version = ExchangePreparing, 2 @@ -99,14 +266,159 @@ func (f *onboardingRemoteFake) DeliverCredential(context.Context, Exchange, stri } func (f *onboardingRemoteFake) Complete(context.Context, string, string, string, int64) error { f.completed = true + return f.completeErr +} + +type securityEventPreparerFake struct { + called bool + err error + cleanupCalled bool + cleanupErr error + conflictRetireCalled bool + conflictRetireErr error + conflictRevisionID string +} + +type blockingSecurityEventPreparer struct { + prepareStarted chan struct{} + releasePrepare chan struct{} + cleanupStarted chan struct{} + cleanupOnce sync.Once +} + +func (preparer *blockingSecurityEventPreparer) PrepareSecurityEvents(context.Context, Revision, []byte) error { + close(preparer.prepareStarted) + <-preparer.releasePrepare return nil } -type securityEventPreparerFake struct{ called bool } +func (preparer *blockingSecurityEventPreparer) CleanupPreparedSecurityEvents(context.Context, Revision) error { + preparer.cleanupOnce.Do(func() { close(preparer.cleanupStarted) }) + return nil +} func (f *securityEventPreparerFake) PrepareSecurityEvents(context.Context, Revision, []byte) error { f.called = true - return nil + return f.err +} +func (f *securityEventPreparerFake) CleanupPreparedSecurityEvents(context.Context, Revision) error { + f.cleanupCalled = true + return f.cleanupErr +} +func (f *securityEventPreparerFake) RetireConflictingSecurityEvents(_ context.Context, revision Revision) error { + f.conflictRetireCalled = true + f.conflictRevisionID = revision.ID + return f.conflictRetireErr +} + +type safeCategoryTestError struct { + category string + message string +} + +func (err safeCategoryTestError) Error() string { return err.message } +func (err safeCategoryTestError) SafeErrorCategory() string { return err.category } + +func TestPairingStartReservationSerializesAndRecoversFailedClaims(t *testing.T) { + input := PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + } + + t.Run("existing pairing blocks before remote claim", func(t *testing.T) { + repository := &pairingRepositoryFake{blocked: true} + remote := &onboardingRemoteFake{} + service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + if _, err := service.Start(context.Background(), input, "trace-blocked"); !errors.Is(err, ErrPairingInProgress) { + t.Fatalf("blocked Start error=%v", err) + } + if repository.startReservationCalls != 1 || repository.startReservationReleases != 0 || repository.startReservationID != "" || remote.claimCalls != 0 { + t.Fatalf("reservation/claim lifecycle calls=%d releases=%d id=%q claims=%d", + repository.startReservationCalls, repository.startReservationReleases, repository.startReservationID, remote.claimCalls) + } + }) + + t.Run("active machine credential blocks destructive rotation before remote claim", func(t *testing.T) { + repository := &pairingRepositoryFake{startReservationErr: ErrActiveConfigurationHandoffRequired} + remote := &onboardingRemoteFake{} + service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + if _, err := service.Start(context.Background(), input, "trace-active"); !errors.Is(err, ErrActiveConfigurationHandoffRequired) { + t.Fatalf("active credential guard error=%v", err) + } + if remote.claimCalls != 0 { + t.Fatal("active credential guard ran after the one-time onboarding code was claimed") + } + }) + + t.Run("remote failure releases database guard", func(t *testing.T) { + repository := &pairingRepositoryFake{} + remote := &onboardingRemoteFake{claimErr: errors.New("remote unavailable")} + service := NewPairingService(repository, &secretStoreFake{}, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + if _, err := service.Start(context.Background(), input, "trace-failed"); err == nil { + t.Fatal("remote claim failure was ignored") + } + if repository.startReservationCalls != 1 || repository.startReservationReleases != 1 || repository.startReservationID != "" || remote.claimCalls != 1 { + t.Fatalf("reservation was not released after remote failure: %#v remote=%#v", repository, remote) + } + }) +} + +func TestPairingStartLeavesUncommittedExchangeTokenInDurableCleanupQueue(t *testing.T) { + repository := &pairingRepositoryFake{commitStartErr: errors.New("database commit unavailable")} + secrets := &secretStoreFake{values: map[string][]byte{}} + remote := &onboardingRemoteFake{claimed: ClaimedExchange{ + ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", + Version: 1, ExpiresAt: time.Now().Add(time.Hour), + }} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + if _, err := service.Start(context.Background(), PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + }, "trace-uncommitted"); err == nil { + t.Fatal("failed pairing commit was ignored") + } + if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 1 || repository.startReservationID != "" || repository.startReservationReleases != 1 { + t.Fatalf("uncommitted token was not left recoverable: secrets=%d cleanup=%d reservation=%q releases=%d", + len(secrets.values), len(repository.pendingSecretCleanups), repository.startReservationID, repository.startReservationReleases) + } + for reference := range secrets.values { + if _, queued := repository.pendingSecretCleanups[reference]; !queued { + t.Fatalf("uncommitted Secret reference %q is not queued for cleanup", reference) + } + } +} + +func TestPairingStartAmbiguousCommitNeverRequeuesAdoptedExchangeToken(t *testing.T) { + repository := &pairingRepositoryFake{ + commitStartErr: errors.New("commit response lost"), commitStartAmbiguous: true, + } + secrets := &secretStoreFake{values: map[string][]byte{}} + remote := &onboardingRemoteFake{claimed: ClaimedExchange{ + ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", + Version: 1, ExpiresAt: time.Now().Add(time.Hour), + }} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + if _, err := service.Start(context.Background(), PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + }, "trace-ambiguous"); err == nil { + t.Fatal("ambiguous pairing commit was not reported") + } + if repository.exchange.ID == "" || repository.revision.ID == "" { + t.Fatal("test did not simulate a committed Pairing") + } + if len(secrets.values) != 1 || len(repository.pendingSecretCleanups) != 0 { + t.Fatalf("adopted exchange token was re-queued: secrets=%d cleanup=%d", len(secrets.values), len(repository.pendingSecretCleanups)) + } + if repository.startReservationID != repository.exchange.ID || repository.startReservationState != "paired" || repository.startReservationReleases != 0 { + t.Fatalf("committed reservation was not retained: id=%q state=%q releases=%d", + repository.startReservationID, repository.startReservationState, repository.startReservationReleases) + } } func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T) { @@ -155,6 +467,91 @@ func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T } } +func TestPairingRetryNeverOverwritesOrDeletesAdoptedIdentitySecrets(t *testing.T) { + repository := &pairingRepositoryFake{applyManifestAmbiguousOnce: true, failCredentialsSavedOnce: true} + secrets := &secretStoreFake{values: map[string][]byte{}} + remote := &onboardingRemoteFake{ + claimed: ClaimedExchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ExchangeToken: "exchange-token-value", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + view: Exchange{ExchangeID: "11111111-1111-1111-1111-111111111111", ApplicationID: "22222222-2222-2222-2222-222222222222", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + delivery: CredentialDelivery{Manifest: ManifestV1{ + SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "33333333-3333-3333-3333-333333333333", + ApplicationID: "22222222-2222-2222-2222-222222222222", Audience: "urn:easyai:resource:22222222-2222-2222-2222-222222222222", + Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"openid", "gateway.access"}, + Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}}, + SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:22222222-2222-2222-2222-222222222222"}, + }, MachineCredential: &MachineCredential{ClientID: "service", ClientSecret: "placeholder", IssuedAt: time.Now()}}, + } + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer) + pairing, err := service.Start(context.Background(), PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "one-time-code-value", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + }, "trace-retry") + if err != nil { + t.Fatal(err) + } + + if _, err := service.Continue(context.Background(), pairing.ID); err == nil { + t.Fatal("simulated pairing status persistence failure was ignored") + } + firstMachine := repository.revision.MachineCredentialRef + firstSession := repository.revision.SessionEncryptionKeyRef + if firstMachine == "" || firstSession == "" { + t.Fatalf("first delivery was not adopted: %#v", repository.revision) + } + if _, ok := secrets.values[firstMachine]; !ok { + t.Fatal("first adopted machine Secret was deleted") + } + if _, ok := secrets.values[firstSession]; !ok { + t.Fatal("first adopted session Secret was deleted") + } + for _, reference := range []string{firstMachine, firstSession} { + if _, queued := repository.pendingSecretCleanups[reference]; queued { + t.Fatalf("ambiguously adopted identity Secret %q was re-queued", reference) + } + } + + if _, err := service.Continue(context.Background(), pairing.ID); err == nil { + t.Fatal("simulated pairing status persistence failure was ignored") + } + secondMachine := repository.revision.MachineCredentialRef + secondSession := repository.revision.SessionEncryptionKeyRef + if secondMachine == firstMachine || secondSession == firstSession { + t.Fatalf("identity Secret references were overwritten in place: first=(%s,%s) second=(%s,%s)", firstMachine, firstSession, secondMachine, secondSession) + } + for _, reference := range []string{firstMachine, firstSession} { + if _, queued := repository.pendingSecretCleanups[reference]; !queued { + t.Fatalf("first superseded identity Secret %q was not queued for cleanup", reference) + } + } + + completed, err := service.Continue(context.Background(), pairing.ID) + if err != nil { + t.Fatal(err) + } + if completed.Status != PairingCompleted { + t.Fatalf("retry did not complete: %#v", completed) + } + thirdMachine := repository.revision.MachineCredentialRef + thirdSession := repository.revision.SessionEncryptionKeyRef + if thirdMachine == secondMachine || thirdSession == secondSession { + t.Fatalf("second retry overwrote identity Secret references in place: second=(%s,%s) third=(%s,%s)", secondMachine, secondSession, thirdMachine, thirdSession) + } + for _, reference := range []string{thirdMachine, thirdSession} { + if _, ok := secrets.values[reference]; !ok { + t.Fatalf("active identity Secret %q was deleted", reference) + } + if _, queued := repository.pendingSecretCleanups[reference]; queued { + t.Fatalf("active identity Secret %q remained in cleanup queue", reference) + } + } + for _, reference := range []string{firstMachine, firstSession, secondMachine, secondSession} { + if _, queued := repository.pendingSecretCleanups[reference]; !queued { + t.Fatalf("superseded identity Secret %q was not queued for cleanup", reference) + } + } +} + func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) { repository := &pairingRepositoryFake{exchange: PairingExchange{ ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing", @@ -177,3 +574,307 @@ func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) { t.Fatal("expired exchange token remained in SecretStore") } } + +func TestPairingPersistsOnlySafeSecurityEventFailureCategory(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ + ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com", + SessionRevocation: true, MachineCredentialRef: "identity-machine-revision", + }, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111", + ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved, + RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7, + }, + } + secrets := &secretStoreFake{values: map[string][]byte{ + "identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"), + "identity-machine-revision": []byte("temporary-machine-secret-value-0000000"), + }} + remote := &onboardingRemoteFake{} + preparer := &securityEventPreparerFake{err: safeCategoryTestError{ + category: "discovery_failed", + message: "sensitive-marker https://internal.example/ssf token-value", + }} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, preparer) + + failed, err := service.Continue(context.Background(), "pairing") + if err == nil { + t.Fatal("security event discovery failure was ignored") + } + if failed.Status != PairingCredentialsSaved || failed.LastErrorCategory != "security_event_discovery_failed" { + t.Fatalf("unsafe or missing pairing failure state: %#v", failed) + } + encoded, marshalErr := json.Marshal(failed) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if strings.Contains(string(encoded), "sensitive-marker") || strings.Contains(string(encoded), "internal.example") || strings.Contains(string(encoded), "token-value") { + t.Fatalf("raw security event error escaped through pairing JSON: %s", encoded) + } + + preparer.err = nil + completed, err := service.Continue(context.Background(), "pairing") + if err != nil { + t.Fatal(err) + } + if completed.Status != PairingCompleted || completed.LastErrorCategory != "" { + t.Fatalf("successful retry did not clear the diagnostic category: %#v", completed) + } +} + +func TestPairingCancellationPersistsIntentBeforeCleaningTemporaryResources(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ + ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true, + MachineCredentialRef: "identity-machine-revision", SessionEncryptionKeyRef: "identity-session-revision", + }, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing", + Status: PairingCredentialsSaved, CleanupStatus: PairingCleanupNone, RemoteVersion: 4, + ExpiresAt: time.Now().Add(time.Hour), Version: 7, + }, + } + secrets := &secretStoreFake{values: map[string][]byte{ + "identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"), + "identity-machine-revision": []byte("temporary-machine-secret-value-0000000"), + "identity-session-revision": []byte("temporary-session-secret-value-0000000"), + }} + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { + t.Fatal("cancellation must not call the onboarding remote") + return nil, nil + }, preparer) + + cancelled, err := service.Cancel(context.Background(), "pairing", 7, "trace-cancel", "audit-cancel") + if err != nil { + t.Fatal(err) + } + if cancelled.Status != PairingCancelled || cancelled.CleanupStatus != PairingCleanupPending || cancelled.CancelledAt == nil { + t.Fatalf("cancellation intent was not persisted: %#v", cancelled) + } + if repository.revision.State != RevisionFailed || repository.revision.LastErrorCategory != "pairing_cancelled" { + t.Fatalf("draft was not atomically retired: %#v", repository.revision) + } + if len(secrets.values) != 3 { + t.Fatal("synchronous cancellation removed resources before the cleanup worker could resume them") + } + replayed, err := service.Cancel(context.Background(), "pairing", 7, "trace-replay", "audit-replay") + if err != nil || replayed.Version != cancelled.Version || replayed.Status != PairingCancelled { + t.Fatalf("lost cancellation response could not be replayed safely: %#v err=%v", replayed, err) + } + + cleaned, err := service.Cleanup(context.Background(), "pairing") + if err != nil { + t.Fatal(err) + } + if cleaned.CleanupStatus != PairingCleanupCompleted || cleaned.CleanupCompletedAt == nil || !preparer.cleanupCalled { + t.Fatalf("pairing cleanup was not completed: %#v", cleaned) + } + if len(secrets.values) != 0 || repository.revision.MachineCredentialRef != "" || repository.revision.SessionEncryptionKeyRef != "" { + t.Fatalf("temporary secret references survived cleanup: secrets=%d revision=%#v", len(secrets.values), repository.revision) + } +} + +func TestPairingCleanupRetriesWithSafeCategory(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ID: "revision", State: RevisionFailed, Version: 4, SessionRevocation: true}, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", Status: PairingCancelled, CleanupStatus: PairingCleanupPending, + RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 8, + }, + } + preparer := &securityEventPreparerFake{cleanupErr: safeCategoryTestError{ + category: "retirement_pending", message: "sensitive cleanup marker token-value", + }} + service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{}}, nil, preparer) + + pending, err := service.Cleanup(context.Background(), "pairing") + if err == nil { + t.Fatal("pending security event retirement was treated as completed") + } + if pending.CleanupStatus != PairingCleanupPending || pending.LastErrorCategory != "cleanup_security_event_retirement_pending" || strings.Contains(pending.LastErrorCategory, "sensitive") { + t.Fatalf("cleanup failure was not safely persisted: %#v", pending) + } + + preparer.cleanupErr = nil + completed, err := service.Cleanup(context.Background(), "pairing") + if err != nil { + t.Fatal(err) + } + if completed.CleanupStatus != PairingCleanupCompleted { + t.Fatalf("cleanup retry did not complete: %#v", completed) + } +} + +func TestPairingMapsUntrustedRemoteErrorCategoryToStableValue(t *testing.T) { + repository := &pairingRepositoryFake{exchange: PairingExchange{ID: "pairing", Version: 2}} + service := NewPairingService(repository, &secretStoreFake{}, nil, nil) + + updated, err := service.updateFromRemote(context.Background(), repository.exchange, Exchange{ + Status: ExchangeFailed, Version: 3, LastErrorCategory: "secret_token_abcdef", + }) + if err != nil { + t.Fatal(err) + } + if updated.Status != PairingFailed || updated.LastErrorCategory != "remote_exchange_failed" { + t.Fatalf("untrusted remote category was persisted: %#v", updated) + } +} + +func TestPairingConflictRetirementIsScopedToCurrentBlockedPairing(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ID: "revision", State: RevisionDraft, Version: 3, SessionRevocation: true}, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", Status: PairingCredentialsSaved, + CleanupStatus: PairingCleanupNone, Version: 7, LastErrorCategory: "security_event_connection_conflict", + }, + } + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, &secretStoreFake{}, nil, preparer) + + resolved, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7) + if err != nil { + t.Fatal(err) + } + if resolved.ID != "pairing" || resolved.LastErrorCategory != "security_event_retirement_pending" || !preparer.conflictRetireCalled || preparer.conflictRevisionID != "revision" { + t.Fatalf("conflict retirement escaped pairing scope: pairing=%#v preparer=%#v", resolved, preparer) + } + + preparer.conflictRetireCalled = false + if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 6); !errors.Is(err, ErrRevisionConflict) { + t.Fatalf("stale pairing version error=%v", err) + } + repository.exchange.LastErrorCategory = "" + if _, err := service.RetireConflictingSecurityEvents(context.Background(), "pairing", 7); !errors.Is(err, ErrPairingConflictNotResolvable) { + t.Fatalf("resolved pairing could retire another connection: %v", err) + } + if preparer.conflictRetireCalled { + t.Fatal("stale conflict action reached the security event manager") + } +} + +func TestCompletedPairingRestoresPreparedReceiverAfterRestart(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ + ID: "revision", State: RevisionValidated, SessionRevocation: true, + MachineCredentialRef: "identity-machine-revision", + }, + exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8}, + } + secrets := &secretStoreFake{values: map[string][]byte{ + "identity-machine-revision": []byte("machine-secret-value-restored-from-store"), + }} + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, secrets, nil, preparer) + + if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); err != nil { + t.Fatal(err) + } + if !preparer.called { + t.Fatal("completed pairing did not reconstruct its prepared security event receiver") + } +} + +func TestCompletedPairingDoesNotRestoreReceiverForSupersededRevision(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ + ID: "revision", State: RevisionSuperseded, Version: 5, SessionRevocation: true, + MachineCredentialRef: "identity-machine-revision", + }, + exchange: PairingExchange{ID: "pairing", RevisionID: "revision", Status: PairingCompleted, Version: 8}, + } + preparer := &securityEventPreparerFake{} + service := NewPairingService(repository, &secretStoreFake{values: map[string][]byte{ + "identity-machine-revision": []byte("must-not-be-read"), + }}, nil, preparer) + + if err := service.RestoreCompletedSecurityEvents(context.Background(), "pairing"); !errors.Is(err, ErrRevisionConflict) { + t.Fatalf("restore error=%v, want revision conflict", err) + } + if preparer.called { + t.Fatal("superseded Revision reconstructed an orphan prepared Receiver") + } +} + +func TestCancelledPairingCleanupWaitsForCompletedReceiverRestore(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ + ID: "revision", State: RevisionValidated, Version: 3, SessionRevocation: true, + MachineCredentialRef: "identity-machine-revision", + }, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing", + Status: PairingCompleted, CleanupStatus: PairingCleanupNone, Version: 8, + }, + } + secrets := &secretStoreFake{values: map[string][]byte{ + "identity-exchange-pairing": []byte("exchange-token"), + "identity-machine-revision": []byte("machine-secret"), + }} + preparer := &blockingSecurityEventPreparer{ + prepareStarted: make(chan struct{}), + releasePrepare: make(chan struct{}), + cleanupStarted: make(chan struct{}), + } + service := NewPairingService(repository, secrets, nil, preparer) + restoreResult := make(chan error, 1) + go func() { restoreResult <- service.RestoreCompletedSecurityEvents(context.Background(), "pairing") }() + <-preparer.prepareStarted + + cancelled, err := service.Cancel(context.Background(), "pairing", 8, "trace", "audit") + if err != nil { + t.Fatal(err) + } + cleanupResult := make(chan PairingExchange, 1) + cleanupError := make(chan error, 1) + go func() { + cleaned, cleanupErr := service.Cleanup(context.Background(), "pairing") + cleanupResult <- cleaned + cleanupError <- cleanupErr + }() + + select { + case <-preparer.cleanupStarted: + t.Fatal("cleanup overtook an in-flight completed receiver restore") + case <-time.After(100 * time.Millisecond): + } + close(preparer.releasePrepare) + if err := <-restoreResult; !errors.Is(err, ErrRevisionConflict) { + t.Fatalf("restore error = %v, want revision conflict after cancellation", err) + } + if err := <-cleanupError; err != nil { + t.Fatal(err) + } + cleaned := <-cleanupResult + if cancelled.Status != PairingCancelled || cleaned.CleanupStatus != PairingCleanupCompleted { + t.Fatalf("cancel/cleanup did not converge: cancelled=%#v cleaned=%#v", cancelled, cleaned) + } + if len(secrets.values) != 0 { + t.Fatalf("cancelled restore left temporary secrets: %d", len(secrets.values)) + } +} + +func TestPairingClassifiesExchangeCompletionFailureSeparatelyFromSSF(t *testing.T) { + repository := &pairingRepositoryFake{ + revision: Revision{ID: "revision", State: RevisionDraft, AuthCenterURL: "https://auth.example.com"}, + exchange: PairingExchange{ + ID: "pairing", RevisionID: "revision", RemoteExchangeID: "11111111-1111-1111-1111-111111111111", + ExchangeTokenRef: "identity-exchange-pairing", Status: PairingCredentialsSaved, + RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), Version: 7, + }, + } + secrets := &secretStoreFake{values: map[string][]byte{ + "identity-exchange-pairing": []byte("temporary-exchange-token-value-000000"), + }} + remote := &onboardingRemoteFake{completeErr: errors.New("upstream body contains sensitive marker")} + service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) { return remote, nil }, nil) + + failed, err := service.Continue(context.Background(), "pairing") + if err == nil { + t.Fatal("exchange completion failure was ignored") + } + if failed.LastErrorCategory != "exchange_completion_failed" { + t.Fatalf("completion failure was misclassified: %#v", failed) + } +} diff --git a/apps/api/internal/identityruntime/builder.go b/apps/api/internal/identityruntime/builder.go index 71374d9..5c3b569 100644 --- a/apps/api/internal/identityruntime/builder.go +++ b/apps/api/internal/identityruntime/builder.go @@ -3,8 +3,10 @@ package identityruntime import ( "context" "errors" + "net/http" "net/url" "slices" + "strings" "sync" "time" @@ -24,10 +26,23 @@ type RuntimeBuilderConfig struct { } type preparedSecurityRuntime struct { - manager *securityevents.ConnectionManager - cancel context.CancelFunc + manager *securityevents.ConnectionManager + cancel context.CancelFunc + receiverReady bool + blockedCategory string } +type safeRuntimeError struct { + category string + cause error +} + +func (err safeRuntimeError) Error() string { + return "identity runtime operation failed: " + err.category +} +func (err safeRuntimeError) Unwrap() error { return err.cause } +func (err safeRuntimeError) SafeErrorCategory() string { return err.category } + type RuntimeBuilder struct { ctx context.Context store *store.Store @@ -54,7 +69,36 @@ func NewRuntimeBuilder(ctx context.Context, data *store.Store, secrets PairingSe return &RuntimeBuilder{ctx: ctx, store: data, secrets: secrets, config: config, metrics: metrics, prepared: map[string]preparedSecurityRuntime{}} } +func (builder *RuntimeBuilder) PreparedSecurityEventReceiver() http.Handler { + builder.mutex.Lock() + defer builder.mutex.Unlock() + if len(builder.prepared) != 1 { + return nil + } + for _, prepared := range builder.prepared { + if prepared.receiverReady { + return prepared.manager + } + } + return nil +} + +func (builder *RuntimeBuilder) PreparedSecurityEventManager() *securityevents.ConnectionManager { + builder.mutex.Lock() + defer builder.mutex.Unlock() + if len(builder.prepared) != 1 { + return nil + } + for _, prepared := range builder.prepared { + return prepared.manager + } + return nil +} + func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revision) (*Runtime, error) { + if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil { + return nil, err + } if builder.store == nil || builder.secrets == nil || revision.Issuer == "" || revision.TenantID == "" || revision.Audience == "" || revision.RolePrefix == "" { return nil, errors.New("identity runtime configuration is incomplete") @@ -69,23 +113,28 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi var securityManager *securityevents.ConnectionManager if revision.SessionRevocation { - prepared := builder.takePreparedSecurityRuntime(revision.ID) + prepared := builder.preparedSecurityRuntime(revision.ID) securityManager = prepared.manager - if prepared.cancel != nil { - runtime.close = func() { + if securityManager != nil { + if err := securityManager.ValidateConfiguredConnectionBinding(ctx); err != nil { cancel() - prepared.cancel() + return nil, err + } + if !prepared.receiverReady { + cancel() + return nil, safeRuntimeError{category: "security_event_not_ready", cause: store.ErrSecurityEventConnectionConflict} } } if securityManager == nil { var err error - securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision) + securityManager, err = builder.newSecurityEventManager(runtimeCtx, revision, true) if err != nil { cancel() return nil, err } } runtime.SecurityEvents = securityManager + runtime.securityEventDisconnector = securityManager } var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) @@ -100,6 +149,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi return revision.MachineClientID, secret, err } verifier, err := auth.NewOIDCVerifier(auth.OIDCConfig{ + AppEnv: builder.config.AppEnv, Issuer: revision.Issuer, Audience: revision.Audience, TenantID: revision.TenantID, RolePrefix: revision.RolePrefix, RequiredScopes: append([]string(nil), revision.Scopes...), JWKSCacheTTL: builder.config.JWKSCacheTTL, IntrospectionEnabled: revision.TokenIntrospection, @@ -134,6 +184,7 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi return nil, err } client, err := auth.NewOIDCPublicClient(auth.OIDCPublicClientConfig{ + AppEnv: builder.config.AppEnv, Issuer: revision.Issuer, ClientID: revision.BrowserClientID, RedirectURI: revision.PublicBaseURL + "/api/v1/auth/oidc/callback", PostLogoutRedirectURI: revision.WebBaseURL + "/", Scopes: append([]string{"openid", "profile"}, revision.Scopes...), @@ -161,46 +212,205 @@ func (builder *RuntimeBuilder) Build(ctx context.Context, revision identity.Revi } func (builder *RuntimeBuilder) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, managementSecret []byte) error { - if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.MachineClientID == "" { + if err := identity.ValidateRevisionURLs(revision, builder.config.AppEnv); err != nil { + return safeRuntimeError{category: "configuration_invalid", cause: err} + } + if !revision.SessionRevocation || revision.SecurityEventIssuer == "" || revision.SecurityEventAudience == "" || revision.MachineClientID == "" { return errors.New("security event configuration is incomplete") } + if prepared := builder.preparedSecurityRuntime(revision.ID); prepared.manager != nil && prepared.blockedCategory != "" { + view, err := prepared.manager.Get(ctx) + if err == nil { + category := prepared.blockedCategory + if view.LifecycleStatus == "retiring" || view.LifecycleStatus == "disconnect_pending" { + category = "retirement_pending" + } + return safeRuntimeError{category: category, cause: store.ErrSecurityEventConnectionConflict} + } + if !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return safeSecurityEventRuntimeError(err) + } + builder.removePreparedSecurityRuntime(revision.ID, prepared) + } runtimeCtx, cancel := context.WithCancel(builder.ctx) - manager, err := builder.newSecurityEventManager(runtimeCtx, revision) + manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false) if err != nil { cancel() - return err + return safeRuntimeError{category: "configuration_invalid", cause: err} } secretCopy := append([]byte(nil), managementSecret...) _, err = manager.Connect(ctx, revision.SecurityEventIssuer, revision.MachineClientID, secretCopy, "identity-pairing-ssf-"+revision.ID) clear(secretCopy) if err != nil { + // A conflicting persisted connection is itself the resource an + // administrator must safely retire. Keep this manager reachable by the + // recovery API even when there is no Active identity Runtime yet. + if errors.Is(err, store.ErrSecurityEventConnectionConflict) { + builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{ + manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_conflict"), + }) + return safeSecurityEventRuntimeError(err) + } cancel() - return err + return safeSecurityEventRuntimeError(err) } + if err := manager.ValidateConfiguredConnectionBinding(ctx); err != nil { + builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{ + manager: manager, cancel: cancel, blockedCategory: safeSecurityEventCategory(err, "connection_binding_mismatch"), + }) + return safeSecurityEventRuntimeError(err) + } + builder.replacePreparedSecurityRuntime(revision.ID, preparedSecurityRuntime{manager: manager, cancel: cancel, receiverReady: true}) + return nil +} + +func (builder *RuntimeBuilder) removePreparedSecurityRuntime(revisionID string, prepared preparedSecurityRuntime) { builder.mutex.Lock() - previous := builder.prepared[revision.ID] - builder.prepared[revision.ID] = preparedSecurityRuntime{manager: manager, cancel: cancel} + current := builder.prepared[revisionID] + if current.manager == prepared.manager { + delete(builder.prepared, revisionID) + } + builder.mutex.Unlock() + if prepared.cancel != nil { + prepared.cancel() + } +} + +func (builder *RuntimeBuilder) replacePreparedSecurityRuntime(revisionID string, replacement preparedSecurityRuntime) { + builder.mutex.Lock() + previous := builder.prepared[revisionID] + builder.prepared[revisionID] = replacement builder.mutex.Unlock() if previous.cancel != nil { previous.cancel() } +} + +func (builder *RuntimeBuilder) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error { + builder.mutex.Lock() + prepared, exists := builder.prepared[revision.ID] + builder.mutex.Unlock() + if !exists { + runtimeCtx, cancel := context.WithCancel(builder.ctx) + manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false) + if err != nil { + cancel() + return safeRuntimeError{category: "configuration_invalid", cause: err} + } + prepared = preparedSecurityRuntime{manager: manager, cancel: cancel} + } + err := prepared.manager.DiscardPreparedConnection(ctx, "identity-pairing-ssf-"+revision.ID) + if err != nil { + builder.mutex.Lock() + if _, alreadyStored := builder.prepared[revision.ID]; !alreadyStored { + builder.prepared[revision.ID] = prepared + } + builder.mutex.Unlock() + return safeSecurityEventRuntimeError(err) + } + builder.mutex.Lock() + current := builder.prepared[revision.ID] + if current.manager == prepared.manager { + delete(builder.prepared, revision.ID) + } + builder.mutex.Unlock() + if prepared.cancel != nil { + prepared.cancel() + } return nil } -func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision) (*securityevents.ConnectionManager, error) { - return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, securityevents.ConnectionManagerConfig{ - AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID, - ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL, - HeartbeatInterval: builder.config.HeartbeatInterval, StaleAfter: builder.config.StaleAfter, ClockSkew: builder.config.ClockSkew, - }, builder.metrics) +func (builder *RuntimeBuilder) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error { + prepared := builder.preparedSecurityRuntime(revision.ID) + if prepared.manager == nil { + runtimeCtx, cancel := context.WithCancel(builder.ctx) + manager, err := builder.newSecurityEventManager(runtimeCtx, revision, false) + if err != nil { + cancel() + return safeRuntimeError{category: "configuration_invalid", cause: err} + } + prepared = preparedSecurityRuntime{manager: manager, cancel: cancel} + builder.replacePreparedSecurityRuntime(revision.ID, prepared) + } + if err := prepared.manager.RetireConflictingConnection(ctx, "identity-pairing-ssf-"+revision.ID); err != nil { + return safeSecurityEventRuntimeError(err) + } + return nil } -func (builder *RuntimeBuilder) takePreparedSecurityRuntime(revisionID string) preparedSecurityRuntime { +// RecoverSecurityEventDisconnector reconstructs only the SSF management +// surface needed to disable a fail-closed Active Revision. It deliberately +// avoids OIDC discovery, JIT, BFF Session and tenant Runtime construction. +func (builder *RuntimeBuilder) RecoverSecurityEventDisconnector(_ context.Context, revision identity.Revision) (SecurityEventDisconnector, error) { + prepared := builder.preparedSecurityRuntime(revision.ID) + if prepared.manager != nil { + if err := prepared.manager.ValidateConfiguredConnectionBinding(builder.ctx); err != nil { + return nil, err + } + return prepared.manager, nil + } + manager, err := builder.newSecurityEventManager(builder.ctx, revision, true) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return manager, nil +} + +// AdoptPreparedSecurityEvents transfers the prepared manager lifetime to an +// activated Runtime. Validation only peeks at prepared state, so Verification +// callbacks remain available between validation and activation. +func (builder *RuntimeBuilder) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc { builder.mutex.Lock() defer builder.mutex.Unlock() prepared := builder.prepared[revisionID] delete(builder.prepared, revisionID) - return prepared + return prepared.cancel +} + +func safeSecurityEventRuntimeError(err error) error { + var categorized interface{ SafeErrorCategory() string } + if errors.As(err, &categorized) { + return err + } + if errors.Is(err, store.ErrSecurityEventConnectionConflict) { + return safeRuntimeError{category: "connection_conflict", cause: err} + } + return safeRuntimeError{category: "preparation_failed", cause: err} +} + +func safeSecurityEventCategory(err error, fallback string) string { + var categorized interface{ SafeErrorCategory() string } + if errors.As(err, &categorized) && categorized.SafeErrorCategory() != "" { + return categorized.SafeErrorCategory() + } + return fallback +} + +func (builder *RuntimeBuilder) newSecurityEventManager(ctx context.Context, revision identity.Revision, strictRevisionBinding bool) (*securityevents.ConnectionManager, error) { + return securityevents.NewConnectionManager(ctx, builder.store, builder.secrets, builder.securityEventManagerConfig(revision, strictRevisionBinding), builder.metrics) +} + +func (builder *RuntimeBuilder) securityEventManagerConfig(revision identity.Revision, strictRevisionBinding bool) securityevents.ConnectionManagerConfig { + return securityevents.ConnectionManagerConfig{ + AppEnv: builder.config.AppEnv, OIDCEnabled: true, OIDCIssuer: revision.Issuer, OIDCTenantID: revision.TenantID, + ManagementClientID: revision.MachineClientID, PublicBaseURL: revision.PublicBaseURL, + ExpectedTransmitterIssuer: strings.TrimRight(strings.TrimSpace(revision.SecurityEventIssuer), "/"), + ExpectedAudience: revision.SecurityEventAudience, + ExpectedOwnerKey: "identity-pairing-ssf-" + revision.ID, + StrictRevisionBinding: strictRevisionBinding, + HeartbeatInterval: builder.config.HeartbeatInterval, + StaleAfter: builder.config.StaleAfter, + ClockSkew: builder.config.ClockSkew, + } +} + +func (builder *RuntimeBuilder) preparedSecurityRuntime(revisionID string) preparedSecurityRuntime { + builder.mutex.Lock() + defer builder.mutex.Unlock() + return builder.prepared[revisionID] } func secureCookieFor(baseURL string) bool { diff --git a/apps/api/internal/identityruntime/builder_security_events_test.go b/apps/api/internal/identityruntime/builder_security_events_test.go new file mode 100644 index 0000000..b108e54 --- /dev/null +++ b/apps/api/internal/identityruntime/builder_security_events_test.go @@ -0,0 +1,81 @@ +package identityruntime + +import ( + "context" + "strings" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" +) + +func TestRuntimeBuilderRejectsLoopbackHTTPOutsideLocalEnvironments(t *testing.T) { + revision := identity.Revision{ + AuthCenterURL: "https://auth.example.com", Issuer: "https://auth.example.com/issuer/easyai", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", + SecurityEventIssuer: "https://auth.example.com/ssf", SecurityEventConfigURL: "https://auth.example.com/.well-known/ssf-configuration/ssf", + SessionRevocation: true, + } + production := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "production"}} + for _, test := range []struct { + name string + mutate func(*identity.Revision) + }{ + {name: "auth center", mutate: func(value *identity.Revision) { value.AuthCenterURL = "http://localhost:18000" }}, + {name: "OIDC issuer", mutate: func(value *identity.Revision) { value.Issuer = "http://localhost:18003/issuer/easyai" }}, + {name: "public base", mutate: func(value *identity.Revision) { value.PublicBaseURL = "http://127.0.0.1:18089" }}, + {name: "web base", mutate: func(value *identity.Revision) { value.WebBaseURL = "http://localhost:5178" }}, + {name: "SSF issuer", mutate: func(value *identity.Revision) { value.SecurityEventIssuer = "http://localhost:18004/ssf" }}, + {name: "SSF configuration", mutate: func(value *identity.Revision) { + value.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf" + }}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := revision + test.mutate(&candidate) + if _, err := production.Build(context.Background(), candidate); err == nil || !strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("production runtime did not reject loopback HTTP %s URL: %v", test.name, err) + } + }) + } + localRevision := revision + localRevision.AuthCenterURL = "http://localhost:18000" + localRevision.Issuer = "http://localhost:18003/issuer/easyai" + localRevision.PublicBaseURL = "http://127.0.0.1:18089" + localRevision.WebBaseURL = "http://localhost:5178" + localRevision.SecurityEventIssuer = "http://localhost:18004/ssf" + localRevision.SecurityEventConfigURL = "http://localhost:18004/.well-known/ssf-configuration/ssf" + development := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "development"}} + if _, err := development.Build(context.Background(), localRevision); err == nil || strings.Contains(err.Error(), "HTTPS") { + t.Fatalf("development runtime did not pass URL policy before completeness checks: %v", err) + } +} + +func TestSecurityEventManagerConfigBindsRuntimeToTargetRevision(t *testing.T) { + builder := &RuntimeBuilder{config: RuntimeBuilderConfig{AppEnv: "test"}} + revision := identity.Revision{ + ID: "revision-target", + Issuer: "https://auth.example/issuer", + TenantID: "tenant-public-id", + MachineClientID: "gateway-machine", + PublicBaseURL: "https://gateway.example", + SecurityEventIssuer: "https://auth.example/ssf/", + SecurityEventAudience: "urn:easyai:ssf:receiver:target", + } + + runtimeConfig := builder.securityEventManagerConfig(revision, true) + if !runtimeConfig.StrictRevisionBinding || + runtimeConfig.ExpectedTransmitterIssuer != "https://auth.example/ssf" || + runtimeConfig.ExpectedAudience != revision.SecurityEventAudience || + runtimeConfig.ManagementClientID != revision.MachineClientID || + runtimeConfig.ExpectedOwnerKey != "identity-pairing-ssf-revision-target" { + t.Fatalf("runtime SSF binding config=%#v", runtimeConfig) + } + + recoveryConfig := builder.securityEventManagerConfig(revision, false) + if recoveryConfig.StrictRevisionBinding { + t.Fatal("conflict recovery was incorrectly blocked by strict Revision binding") + } + if recoveryConfig.ExpectedOwnerKey != runtimeConfig.ExpectedOwnerKey { + t.Fatal("recovery manager lost the target owner needed for explicit validation") + } +} diff --git a/apps/api/internal/identityruntime/manager.go b/apps/api/internal/identityruntime/manager.go index e43e10a..9060bad 100644 --- a/apps/api/internal/identityruntime/manager.go +++ b/apps/api/internal/identityruntime/manager.go @@ -3,6 +3,8 @@ package identityruntime import ( "context" "errors" + "net/http" + "strings" "sync" "sync/atomic" "time" @@ -27,15 +29,20 @@ type Builder interface { Build(context.Context, identity.Revision) (*Runtime, error) } +type SecurityEventDisconnector interface { + Disconnect(context.Context) (securityevents.ConnectionView, error) +} + type Runtime struct { - Revision identity.Revision - Verifier *auth.OIDCVerifier - PublicClient *auth.OIDCPublicClient - Sessions *oidcsession.Service - SessionCipher *oidcsession.Cipher - SecurityEvents *securityevents.ConnectionManager - CookieSecure bool - close func() + Revision identity.Revision + Verifier *auth.OIDCVerifier + PublicClient *auth.OIDCPublicClient + Sessions *oidcsession.Service + SessionCipher *oidcsession.Cipher + SecurityEvents *securityevents.ConnectionManager + CookieSecure bool + close func() + securityEventDisconnector SecurityEventDisconnector } func (runtime *Runtime) Close() { @@ -45,36 +52,126 @@ func (runtime *Runtime) Close() { } type Manager struct { - repository Repository - builder Builder - operation sync.Mutex - current atomic.Pointer[Runtime] + repository Repository + builder Builder + operation sync.Mutex + current atomic.Pointer[Runtime] + reconciliationRequired atomic.Bool + legacyJWTAllowed atomic.Bool + trustedWebBaseURL atomic.Pointer[string] } +const identityRuntimeReconciliationTimeout = 5 * time.Second + func NewManager(repository Repository, builder Builder) *Manager { - return &Manager{repository: repository, builder: builder} + manager := &Manager{repository: repository, builder: builder} + manager.legacyJWTAllowed.Store(true) + return manager } func (manager *Manager) Current() *Runtime { return manager.current.Load() } +func (manager *Manager) ReconciliationRequired() bool { + return manager.reconciliationRequired.Load() +} + +func (manager *Manager) LegacyJWTEnabled() bool { + return manager.legacyJWTAllowed.Load() +} + +// TrustedWebBaseURL is the persisted Active Revision's exact browser origin. +// It intentionally survives a fail-closed Runtime build so the local +// break-glass manager can still repair or disable a broken Active Revision. +func (manager *Manager) TrustedWebBaseURL() string { + value := manager.trustedWebBaseURL.Load() + if value == nil { + return "" + } + return *value +} + +// SecurityEventReceiver resolves the request-time receiver. During first +// onboarding there is no Active Runtime yet, so the builder-owned prepared +// receiver must remain reachable for SSF Verification callbacks. +func (manager *Manager) SecurityEventReceiver() http.Handler { + provider, ok := manager.builder.(interface{ PreparedSecurityEventReceiver() http.Handler }) + if ok { + if prepared := provider.PreparedSecurityEventReceiver(); prepared != nil { + return prepared + } + } + if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil { + return runtime.SecurityEvents + } + return manager.SecurityEventManager() +} + +// SecurityEventManager resolves the manager used by administrative recovery +// operations. A prepared manager may represent an older persisted connection +// that must be retired before the first identity Runtime can be activated. +func (manager *Manager) SecurityEventManager() *securityevents.ConnectionManager { + if runtime := manager.Current(); runtime != nil && runtime.SecurityEvents != nil { + return runtime.SecurityEvents + } + managerProvider, ok := manager.builder.(interface { + PreparedSecurityEventManager() *securityevents.ConnectionManager + }) + if ok { + return managerProvider.PreparedSecurityEventManager() + } + return nil +} + func (manager *Manager) LoadActive(ctx context.Context) error { manager.operation.Lock() defer manager.operation.Unlock() revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx) if errors.Is(err, identity.ErrRevisionNotFound) { + manager.publishDisabledRuntime() return nil } if err != nil { + manager.failClosedRuntime() return err } + manager.rememberTrustedWebBaseURL(revision.WebBaseURL) + runtime, err := manager.builder.Build(ctx, revision) + if err != nil { + manager.failClosedRuntime() + return err + } + manager.publishRuntime(runtime, revision) + return nil +} + +// ReconcileActive restores a fail-closed Runtime after an uncertain mutation +// outcome. It is safe to call periodically: a matching Active Runtime is left +// untouched, while a changed or missing Active Revision is published atomically. +func (manager *Manager) ReconcileActive(ctx context.Context) error { + manager.operation.Lock() + defer manager.operation.Unlock() + revision, err := manager.repository.ActiveIdentityConfigurationRevision(ctx) + if errors.Is(err, identity.ErrRevisionNotFound) { + manager.publishDisabledRuntime() + return nil + } + if err != nil { + manager.reconciliationRequired.Store(true) + return err + } + manager.rememberTrustedWebBaseURL(revision.WebBaseURL) + if current := manager.current.Load(); current != nil && current.Revision.ID == revision.ID && current.Revision.Version == revision.Version { + manager.reconciliationRequired.Store(false) + return nil + } runtime, err := manager.builder.Build(ctx, revision) if err != nil { + manager.reconciliationRequired.Store(true) return err } - runtime.Revision = revision - manager.current.Store(runtime) + manager.publishRuntime(runtime, revision) return nil } @@ -85,7 +182,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion if err != nil { return identity.Revision{}, err } - if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionSuperseded && revision.State != identity.RevisionActive { + if revision.Version != expectedVersion || revision.State != identity.RevisionDraft && revision.State != identity.RevisionActive { return identity.Revision{}, identity.ErrRevisionConflict } candidate, buildErr := manager.builder.Build(ctx, revision) @@ -101,9 +198,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion candidate.Close() return identity.Revision{}, err } - candidate.Revision = revalidated - old := manager.current.Swap(candidate) - retireRuntime(old) + manager.publishRuntime(candidate, revalidated) return revalidated, nil } candidate.Close() @@ -126,12 +221,9 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion } activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID) if err != nil { - candidate.Close() - return identity.Revision{}, err + return manager.reconcileActivationMutation(ctx, id, candidate, err) } - candidate.Revision = activated - old := manager.current.Swap(candidate) - retireRuntime(old) + manager.publishRuntime(candidate, activated) return activated, nil } @@ -145,46 +237,235 @@ func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded { return identity.Revision{}, identity.ErrRevisionConflict } - candidate, err := manager.builder.Build(ctx, revision) - if err != nil { - return identity.Revision{}, err - } - validated, err := manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID) - if err != nil { - candidate.Close() - return identity.Revision{}, err - } - activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, validated.Version, traceID, auditID) - if err != nil { - candidate.Close() - return identity.Revision{}, err - } - candidate.Revision = activated - old := manager.current.Swap(candidate) - retireRuntime(old) - return activated, nil + // Auth Center currently reuses and mutates OAuth/SSF resources. An older + // local Revision therefore cannot prove that its remote redirect URIs, + // scopes, audiences, or credentials still match. Require a fresh onboarding + // exchange until a versioned remote-resource handoff exists. + return identity.Revision{}, identity.ErrRollbackConfigurationHandoffRequired } func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { manager.operation.Lock() defer manager.operation.Unlock() - disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID) - if err != nil { + if err := manager.retireActiveSecurityEventsBeforeDisable(ctx, expectedVersion); err != nil { return identity.Revision{}, err } - old := manager.current.Swap(nil) - retireRuntime(old) + disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID) + if err != nil { + return manager.reconcileDisableMutation(ctx, expectedVersion, err) + } + manager.publishDisabledRuntime() return disabled, nil } +func (manager *Manager) retireActiveSecurityEventsBeforeDisable(ctx context.Context, expectedVersion int64) error { + active, err := manager.repository.ActiveIdentityConfigurationRevision(ctx) + if err != nil { + return err + } + if active.Version != expectedVersion { + return identity.ErrRevisionConflict + } + if !active.SessionRevocation { + return nil + } + runtime := manager.current.Load() + var disconnector SecurityEventDisconnector + if runtime != nil && runtime.Revision.ID == active.ID { + disconnector = runtime.securityEventDisconnector + if disconnector == nil && runtime.SecurityEvents != nil { + disconnector = runtime.SecurityEvents + } + } + if disconnector == nil { + recoverer, ok := manager.builder.(interface { + RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error) + }) + if !ok { + return identity.ErrSecurityEventRetirementPending + } + var recoverErr error + disconnector, recoverErr = recoverer.RecoverSecurityEventDisconnector(ctx, active) + if recoverErr != nil { + return recoverErr + } + // A missing local connection means there is no bound local Stream or + // credential left to retire. The already-required audit record makes + // this fail-closed recovery decision traceable. + if disconnector == nil { + return nil + } + } + connection, err := disconnector.Disconnect(ctx) + if err != nil { + return err + } + if connection.LifecycleStatus != "retiring" { + return identity.ErrSecurityEventRetirementPending + } + return nil +} + +func (manager *Manager) reconcileActivationMutation(ctx context.Context, targetID string, candidate *Runtime, mutationErr error) (identity.Revision, error) { + reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx) + defer cancel() + active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx) + if err == nil && active.ID == targetID && active.State == identity.RevisionActive { + manager.publishRuntime(candidate, active) + return active, nil + } + candidate.Close() + if errors.Is(err, identity.ErrRevisionNotFound) { + manager.publishDisabledRuntime() + return identity.Revision{}, mutationErr + } + if err != nil { + manager.failClosedRuntime() + return identity.Revision{}, mutationErr + } + current := manager.current.Load() + if current == nil || current.Revision.ID != active.ID { + manager.failClosedRuntime() + } + return identity.Revision{}, mutationErr +} + +func (manager *Manager) reconcileRollbackValidation(ctx context.Context, targetID string, expectedVersion int64, mutationErr error) (identity.Revision, error) { + reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx) + defer cancel() + revision, err := manager.repository.IdentityConfigurationRevision(reconcileCtx, targetID) + if err == nil && revision.State == identity.RevisionValidated && revision.Version == expectedVersion+1 { + return revision, nil + } + return identity.Revision{}, mutationErr +} + +func (manager *Manager) reconcileDisableMutation(ctx context.Context, expectedVersion int64, mutationErr error) (identity.Revision, error) { + previous := manager.current.Load() + reconcileCtx, cancel := identityRuntimeReconciliationContext(ctx) + defer cancel() + active, err := manager.repository.ActiveIdentityConfigurationRevision(reconcileCtx) + if err == nil { + if previous == nil || active.ID != previous.Revision.ID { + manager.failClosedRuntime() + } + return identity.Revision{}, mutationErr + } + if !errors.Is(err, identity.ErrRevisionNotFound) { + manager.failClosedRuntime() + return identity.Revision{}, mutationErr + } + manager.publishDisabledRuntime() + if previous == nil || previous.Revision.ID == "" { + return identity.Revision{}, mutationErr + } + disabled, lookupErr := manager.repository.IdentityConfigurationRevision(reconcileCtx, previous.Revision.ID) + if lookupErr == nil && disabled.State == identity.RevisionSuperseded && disabled.Version == expectedVersion+1 { + return disabled, nil + } + return identity.Revision{}, mutationErr +} + +func identityRuntimeReconciliationContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), identityRuntimeReconciliationTimeout) +} + +func (manager *Manager) publishRuntime(candidate *Runtime, revision identity.Revision) { + candidate.Revision = revision + manager.rememberTrustedWebBaseURL(revision.WebBaseURL) + manager.legacyJWTAllowed.Store(revision.LegacyJWTEnabled) + old := manager.current.Swap(candidate) + manager.adoptPreparedSecurityEvents(candidate, revision.ID) + manager.reconciliationRequired.Store(false) + retireRuntime(old) +} + +func (manager *Manager) publishDisabledRuntime() { + old := manager.current.Swap(nil) + manager.trustedWebBaseURL.Store(nil) + manager.legacyJWTAllowed.Store(true) + manager.reconciliationRequired.Store(false) + retireRuntime(old) +} + +func (manager *Manager) failClosedRuntime() { + manager.legacyJWTAllowed.Store(false) + manager.reconciliationRequired.Store(true) + old := manager.current.Swap(nil) + retireRuntime(old) +} + +func (manager *Manager) rememberTrustedWebBaseURL(value string) { + normalized := strings.TrimSpace(value) + if normalized == "" { + manager.trustedWebBaseURL.Store(nil) + return + } + manager.trustedWebBaseURL.Store(&normalized) +} + func (manager *Manager) PrepareSecurityEvents(ctx context.Context, revision identity.Revision, secret []byte) error { + manager.operation.Lock() + defer manager.operation.Unlock() + current, err := manager.repository.IdentityConfigurationRevision(ctx, revision.ID) + if err != nil { + return err + } + if current.State == identity.RevisionActive { + // Activation already crossed the prepared-manager adoption point. A + // stale restart restore must not insert a second Receiver afterwards. + return nil + } + if current.Version != revision.Version || current.State != identity.RevisionDraft && current.State != identity.RevisionValidated { + return identity.ErrRevisionConflict + } preparer, ok := manager.builder.(interface { PrepareSecurityEvents(context.Context, identity.Revision, []byte) error }) if !ok { return errors.New("security event runtime preparation is unavailable") } - return preparer.PrepareSecurityEvents(ctx, revision, secret) + return preparer.PrepareSecurityEvents(ctx, current, secret) +} + +func (manager *Manager) CleanupPreparedSecurityEvents(ctx context.Context, revision identity.Revision) error { + cleaner, ok := manager.builder.(interface { + CleanupPreparedSecurityEvents(context.Context, identity.Revision) error + }) + if !ok { + return errors.New("security event runtime cleanup is unavailable") + } + return cleaner.CleanupPreparedSecurityEvents(ctx, revision) +} + +func (manager *Manager) RetireConflictingSecurityEvents(ctx context.Context, revision identity.Revision) error { + resolver, ok := manager.builder.(interface { + RetireConflictingSecurityEvents(context.Context, identity.Revision) error + }) + if !ok { + return errors.New("security event conflict recovery is unavailable") + } + return resolver.RetireConflictingSecurityEvents(ctx, revision) +} + +func (manager *Manager) adoptPreparedSecurityEvents(runtime *Runtime, revisionID string) { + adopter, ok := manager.builder.(interface { + AdoptPreparedSecurityEvents(string) context.CancelFunc + }) + if !ok { + return + } + cancel := adopter.AdoptPreparedSecurityEvents(revisionID) + if cancel == nil { + return + } + closeRuntime := runtime.close + runtime.close = func() { + if closeRuntime != nil { + closeRuntime() + } + cancel() + } } func retireRuntime(runtime *Runtime) { diff --git a/apps/api/internal/identityruntime/manager_test.go b/apps/api/internal/identityruntime/manager_test.go index 2d03e99..6ef7dac 100644 --- a/apps/api/internal/identityruntime/manager_test.go +++ b/apps/api/internal/identityruntime/manager_test.go @@ -3,15 +3,25 @@ package identityruntime import ( "context" "errors" + "net/http" "testing" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents" ) type runtimeRepositoryFake struct { - revisions map[string]identity.Revision - active identity.Revision - activateCalled bool + revisions map[string]identity.Revision + active identity.Revision + activateCalled bool + activeLookupErr error + markValidatedErr error + markValidatedErrAfterApply bool + activateErr error + activateErrAfterApply bool + disableErr error + disableErrAfterApply bool } func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, id string) (identity.Revision, error) { @@ -22,18 +32,27 @@ func (f *runtimeRepositoryFake) IdentityConfigurationRevision(_ context.Context, return revision, nil } func (f *runtimeRepositoryFake) ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error) { + if f.activeLookupErr != nil { + return identity.Revision{}, f.activeLookupErr + } if f.active.ID == "" { return identity.Revision{}, identity.ErrRevisionNotFound } return f.active, nil } func (f *runtimeRepositoryFake) MarkIdentityRevisionValidated(_ context.Context, id string, expected int64, _, _ string) (identity.Revision, error) { + if f.markValidatedErr != nil && !f.markValidatedErrAfterApply { + return identity.Revision{}, f.markValidatedErr + } revision := f.revisions[id] if revision.Version != expected { return identity.Revision{}, identity.ErrRevisionConflict } revision.State, revision.Version = identity.RevisionValidated, revision.Version+1 f.revisions[id] = revision + if f.markValidatedErr != nil { + return identity.Revision{}, f.markValidatedErr + } return revision, nil } func (f *runtimeRepositoryFake) RevalidateActiveIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, error) { @@ -56,6 +75,9 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id } func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) { f.activateCalled = true + if f.activateErr != nil && !f.activateErrAfterApply { + return identity.Revision{}, false, f.activateErr + } revision := f.revisions[id] if revision.Version != expected || revision.State != identity.RevisionValidated { return identity.Revision{}, false, identity.ErrRevisionConflict @@ -67,9 +89,15 @@ func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id s } revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID f.active, f.revisions[id] = revision, revision + if f.activateErr != nil { + return identity.Revision{}, false, f.activateErr + } return revision, true, nil } func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) { + if f.disableErr != nil && !f.disableErrAfterApply { + return identity.Revision{}, f.disableErr + } if f.active.Version != expected { return identity.Revision{}, identity.ErrRevisionConflict } @@ -77,22 +105,87 @@ func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID f.revisions[disabled.ID] = disabled f.active = identity.Revision{} + if f.disableErr != nil { + return identity.Revision{}, f.disableErr + } return disabled, nil } type runtimeBuilderFake struct { - err error - builtIDs []string + err error + builtIDs []string + buildStarted chan struct{} + buildRelease chan struct{} + prepareStarted chan struct{} + prepareRelease chan struct{} + prepareCalls int + cleanupCalledFor string + adoptedRevision string + preparedCancel context.CancelFunc + preparedManager *securityevents.ConnectionManager + preparedReceiver http.Handler + recoveredSecurityEventDisconnector SecurityEventDisconnector + recoverSecurityEventErr error +} + +type runtimeSecurityEventDisconnector struct { + lifecycle string + err error + calls int +} + +func (f *runtimeSecurityEventDisconnector) Disconnect(context.Context) (securityevents.ConnectionView, error) { + f.calls++ + return securityevents.ConnectionView{LifecycleStatus: f.lifecycle}, f.err } func (f *runtimeBuilderFake) Build(_ context.Context, revision identity.Revision) (*Runtime, error) { f.builtIDs = append(f.builtIDs, revision.ID) + if f.buildStarted != nil { + close(f.buildStarted) + } + if f.buildRelease != nil { + <-f.buildRelease + } if f.err != nil { return nil, f.err } return &Runtime{Revision: revision}, nil } +func (f *runtimeBuilderFake) PrepareSecurityEvents(_ context.Context, _ identity.Revision, _ []byte) error { + f.prepareCalls++ + if f.prepareStarted != nil { + close(f.prepareStarted) + } + if f.prepareRelease != nil { + <-f.prepareRelease + } + return f.err +} + +func (f *runtimeBuilderFake) CleanupPreparedSecurityEvents(_ context.Context, revision identity.Revision) error { + f.cleanupCalledFor = revision.ID + return f.err +} + +func (f *runtimeBuilderFake) AdoptPreparedSecurityEvents(revisionID string) context.CancelFunc { + f.adoptedRevision = revisionID + return f.preparedCancel +} + +func (f *runtimeBuilderFake) PreparedSecurityEventManager() *securityevents.ConnectionManager { + return f.preparedManager +} + +func (f *runtimeBuilderFake) PreparedSecurityEventReceiver() http.Handler { + return f.preparedReceiver +} + +func (f *runtimeBuilderFake) RecoverSecurityEventDisconnector(context.Context, identity.Revision) (SecurityEventDisconnector, error) { + return f.recoveredSecurityEventDisconnector, f.recoverSecurityEventErr +} + func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) { active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} draft := identity.Revision{ID: "draft", State: identity.RevisionDraft, Version: 1} @@ -112,6 +205,39 @@ func TestValidationFailureKeepsCurrentRuntimeAndDoesNotActivate(t *testing.T) { } } +func TestLoadActiveMarksTransientFailureForAutomaticReconciliation(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"active": active}, + active: active, + activeLookupErr: errors.New("database temporarily unavailable"), + } + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + + if err := manager.LoadActive(context.Background()); err == nil { + t.Fatal("transient active lookup failure was ignored") + } + if !manager.ReconciliationRequired() || manager.Current() != nil { + t.Fatal("failed startup load was not left fail-closed and retryable") + } + repository.activeLookupErr = nil + builder.err = errors.New("SecretStore temporarily unavailable") + if err := manager.ReconcileActive(context.Background()); err == nil { + t.Fatal("transient runtime build failure was ignored") + } + if !manager.ReconciliationRequired() { + t.Fatal("failed runtime build cleared automatic reconciliation") + } + builder.err = nil + if err := manager.ReconcileActive(context.Background()); err != nil { + t.Fatal(err) + } + if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID { + t.Fatalf("startup runtime was not recovered: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired()) + } +} + func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} @@ -128,21 +254,209 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) { } } -func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) { +func TestActivationReconcilesCommitAppliedThenResponseLost(t *testing.T) { + active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"old": active, "new": candidate}, + active: active, + activateErr: errors.New("commit response lost"), + activateErrAfterApply: true, + } + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active}) + + activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit") + if err != nil { + t.Fatal(err) + } + if activated.State != identity.RevisionActive || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID { + t.Fatalf("committed activation was not reconciled: activated=%#v current=%#v", activated, manager.Current()) + } +} + +func TestActivationKeepsCurrentRuntimeWhenMutationWasNotApplied(t *testing.T) { + active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + mutationErr := errors.New("activation rejected before commit") + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"old": active, "new": candidate}, + active: active, + activateErr: mutationErr, + } + manager := NewManager(repository, &runtimeBuilderFake{}) + original := &Runtime{Revision: active} + manager.current.Store(original) + + if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); !errors.Is(err, mutationErr) { + t.Fatalf("activation error = %v, want %v", err, mutationErr) + } + if manager.Current() != original { + t.Fatal("definitely uncommitted activation replaced the current runtime") + } +} + +func TestActivationFailsClosedWhenCommitOutcomeCannotBeReconciled(t *testing.T) { + active := identity.Revision{ID: "old", State: identity.RevisionActive, Version: 2} + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"old": active, "new": candidate}, + active: active, + activateErr: errors.New("commit outcome unknown"), + activeLookupErr: errors.New("database unavailable during reconciliation"), + } + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + manager.current.Store(&Runtime{Revision: active}) + + if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err == nil { + t.Fatal("unknown activation outcome was reported as successful") + } + if manager.Current() != nil { + t.Fatal("unknown activation outcome did not fail OIDC closed") + } + if !manager.ReconciliationRequired() { + t.Fatal("unknown activation outcome did not request background reconciliation") + } + + repository.activeLookupErr = nil + builder.err = errors.New("runtime dependency temporarily unavailable") + if err := manager.ReconcileActive(context.Background()); err == nil { + t.Fatal("runtime reconciliation unexpectedly ignored build failure") + } + if !manager.ReconciliationRequired() || manager.Current() != nil { + t.Fatal("failed runtime rebuild cleared fail-closed reconciliation state") + } + builder.err = nil + if err := manager.ReconcileActive(context.Background()); err != nil { + t.Fatal(err) + } + if manager.ReconciliationRequired() || manager.Current() == nil || manager.Current().Revision.ID != active.ID { + t.Fatalf("runtime did not recover after reconciliation: current=%#v required=%v", manager.Current(), manager.ReconciliationRequired()) + } +} + +func TestRollbackRejectsOIDCOnlyRevisionBeforeRuntimeBuild(t *testing.T) { active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5} previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3} repository := &runtimeRepositoryFake{ revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active, } - manager := NewManager(repository, &runtimeBuilderFake{}) + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) manager.current.Store(&Runtime{Revision: active}) - rolledBack, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit") + if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) { + t.Fatalf("rollback error=%v, want remote resource handoff required", err) + } + if len(builder.builtIDs) != 0 || repository.activateCalled || manager.Current() == nil || manager.Current().Revision.ID != active.ID { + t.Fatalf("unsafe rollback changed runtime: built=%v activate=%t current=%#v", builder.builtIDs, repository.activateCalled, manager.Current()) + } +} + +func TestRollbackRejectsSupersededMachineCredentialBeforeRuntimeBuild(t *testing.T) { + active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5} + previous := identity.Revision{ + ID: "previous", State: identity.RevisionSuperseded, Version: 3, + MachineCredentialRef: "identity-machine-previous", TokenIntrospection: true, + } + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active, + } + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + manager.current.Store(&Runtime{Revision: active}) + + if _, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRollbackConfigurationHandoffRequired) { + t.Fatalf("rollback error=%v, want credential handoff required", err) + } + if len(builder.builtIDs) != 0 || repository.activateCalled { + t.Fatal("unsafe rollback reached runtime build or activation") + } +} + +func TestValidateRejectsSupersededRevisionBeforeRuntimeBuild(t *testing.T) { + previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"previous": previous}} + builder := &runtimeBuilderFake{} + manager := NewManager(repository, builder) + + if _, err := manager.Validate(context.Background(), previous.ID, previous.Version, "trace", "audit"); !errors.Is(err, identity.ErrRevisionConflict) { + t.Fatalf("validate error=%v, want revision conflict", err) + } + if len(builder.builtIDs) != 0 || repository.revisions[previous.ID].State != identity.RevisionSuperseded { + t.Fatalf("superseded revision reached runtime validation: built=%v revision=%#v", builder.builtIDs, repository.revisions[previous.ID]) + } +} + +func TestDisableKeepsActiveRevisionUntilSecurityEventStreamCanRetire(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + disconnector := &runtimeSecurityEventDisconnector{lifecycle: "disconnect_pending"} + manager := NewManager(repository, &runtimeBuilderFake{}) + original := &Runtime{Revision: active, securityEventDisconnector: disconnector} + manager.current.Store(original) + + if _, err := manager.Disable(context.Background(), active.Version, "trace", "audit"); !errors.Is(err, identity.ErrSecurityEventRetirementPending) { + t.Fatalf("disable error=%v, want security event retirement pending", err) + } + if disconnector.calls != 1 || repository.active.ID != active.ID || manager.Current() != original { + t.Fatalf("pending retirement changed Active state: calls=%d repository=%#v current=%#v", disconnector.calls, repository.active, manager.Current()) + } +} + +func TestDisableRecoversSSFWithoutBuildingBrokenFullIdentityRuntime(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"} + builder := &runtimeBuilderFake{ + err: errors.New("OIDC discovery unavailable"), + recoveredSecurityEventDisconnector: disconnector, + } + manager := NewManager(repository, builder) + + disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit") if err != nil { t.Fatal(err) } - if rolledBack.State != identity.RevisionActive || manager.Current().Revision.ID != previous.ID || repository.active.ID != previous.ID { - t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current()) + if disabled.State != identity.RevisionSuperseded || disconnector.calls != 1 || len(builder.builtIDs) != 0 || manager.Current() != nil { + t.Fatalf("SSF-only recovery did not disable safely: disabled=%#v calls=%d built=%v current=%#v", disabled, disconnector.calls, builder.builtIDs, manager.Current()) + } +} + +func TestDisableRetiresSecurityEventStreamBeforeSupersedingActiveRevision(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4, SessionRevocation: true} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + disconnector := &runtimeSecurityEventDisconnector{lifecycle: "retiring"} + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active, securityEventDisconnector: disconnector}) + + disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit") + if err != nil { + t.Fatal(err) + } + if disconnector.calls != 1 || disabled.State != identity.RevisionSuperseded || repository.active.ID != "" || manager.Current() != nil { + t.Fatalf("safe disable state: calls=%d disabled=%#v repository=%#v current=%#v", disconnector.calls, disabled, repository.active, manager.Current()) + } +} + +func TestDisableReconcilesCommitAppliedThenResponseLost(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + repository := &runtimeRepositoryFake{ + revisions: map[string]identity.Revision{"active": active}, + active: active, + disableErr: errors.New("commit response lost"), + disableErrAfterApply: true, + } + manager := NewManager(repository, &runtimeBuilderFake{}) + manager.current.Store(&Runtime{Revision: active}) + + disabled, err := manager.Disable(context.Background(), active.Version, "trace", "audit") + if err != nil { + t.Fatal(err) + } + if disabled.State != identity.RevisionSuperseded || manager.Current() != nil { + t.Fatalf("committed disable was not reconciled: disabled=%#v current=%#v", disabled, manager.Current()) } } @@ -162,3 +476,192 @@ func TestActiveRevalidationSwapsOnlyAfterSuccessfulValidation(t *testing.T) { t.Fatalf("active runtime was not safely revalidated: %#v", revalidated) } } + +func TestLoadFailureKeepsPersistedActiveWebOriginForBreakGlassRecovery(t *testing.T) { + active := identity.Revision{ + ID: "active", State: identity.RevisionActive, Version: 4, + WebBaseURL: "https://gateway.example.com", + } + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"active": active}, active: active} + manager := NewManager(repository, &runtimeBuilderFake{err: errors.New("OIDC discovery unavailable")}) + + if err := manager.LoadActive(context.Background()); err == nil { + t.Fatal("active runtime load unexpectedly succeeded") + } + if manager.Current() != nil || manager.TrustedWebBaseURL() != active.WebBaseURL { + t.Fatalf("failed Active load lost recovery origin: current=%#v origin=%q", manager.Current(), manager.TrustedWebBaseURL()) + } + + repository.active = identity.Revision{} + if err := manager.ReconcileActive(context.Background()); err != nil { + t.Fatal(err) + } + if manager.TrustedWebBaseURL() != "" { + t.Fatalf("confirmed disable retained stale recovery origin %q", manager.TrustedWebBaseURL()) + } +} + +func TestActivationFinishingFirstPreventsStalePreparedSecurityEventRestore(t *testing.T) { + candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}} + builder := &runtimeBuilderFake{buildStarted: make(chan struct{}), buildRelease: make(chan struct{})} + manager := NewManager(repository, builder) + + activationDone := make(chan error, 1) + go func() { + _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit") + activationDone <- err + }() + <-builder.buildStarted + restoreDone := make(chan error, 1) + go func() { + restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret")) + }() + close(builder.buildRelease) + if err := <-activationDone; err != nil { + t.Fatal(err) + } + if err := <-restoreDone; err != nil { + t.Fatal(err) + } + if builder.prepareCalls != 0 || manager.Current() == nil || manager.Current().Revision.ID != candidate.ID { + t.Fatalf("stale restore survived activation: prepare_calls=%d current=%#v", builder.prepareCalls, manager.Current()) + } +} + +func TestPreparedSecurityEventRestoreFinishingFirstIsAdoptedByActivation(t *testing.T) { + candidate := identity.Revision{ID: "candidate", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"candidate": candidate}} + builder := &runtimeBuilderFake{ + buildStarted: make(chan struct{}), buildRelease: make(chan struct{}), + prepareStarted: make(chan struct{}), prepareRelease: make(chan struct{}), + } + manager := NewManager(repository, builder) + + restoreDone := make(chan error, 1) + go func() { + restoreDone <- manager.PrepareSecurityEvents(context.Background(), candidate, []byte("secret")) + }() + <-builder.prepareStarted + activationDone := make(chan error, 1) + go func() { + _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit") + activationDone <- err + }() + select { + case <-builder.buildStarted: + t.Fatal("activation did not wait for prepared Receiver restoration") + case <-time.After(50 * time.Millisecond): + } + close(builder.prepareRelease) + if err := <-restoreDone; err != nil { + t.Fatal(err) + } + <-builder.buildStarted + close(builder.buildRelease) + if err := <-activationDone; err != nil { + t.Fatal(err) + } + if builder.prepareCalls != 1 || builder.adoptedRevision != candidate.ID { + t.Fatalf("prepared restore was not adopted: prepare_calls=%d adopted=%q", builder.prepareCalls, builder.adoptedRevision) + } +} + +func TestManagerDelegatesPreparedSecurityEventCleanupWithoutChangingActiveRuntime(t *testing.T) { + active := identity.Revision{ID: "active", State: identity.RevisionActive, Version: 4} + draft := identity.Revision{ID: "draft", State: identity.RevisionFailed, Version: 2} + builder := &runtimeBuilderFake{} + manager := NewManager(&runtimeRepositoryFake{}, builder) + manager.current.Store(&Runtime{Revision: active}) + + if err := manager.CleanupPreparedSecurityEvents(context.Background(), draft); err != nil { + t.Fatal(err) + } + if builder.cleanupCalledFor != draft.ID || manager.Current().Revision.ID != active.ID { + t.Fatalf("cleanup changed active runtime or skipped the draft: called=%q current=%#v", builder.cleanupCalledFor, manager.Current()) + } +} + +func TestActivationAdoptsPreparedSecurityEventLifetime(t *testing.T) { + candidate := identity.Revision{ID: "new", State: identity.RevisionValidated, Version: 3} + repository := &runtimeRepositoryFake{revisions: map[string]identity.Revision{"new": candidate}} + preparedContext, preparedCancel := context.WithCancel(context.Background()) + builder := &runtimeBuilderFake{preparedCancel: preparedCancel} + manager := NewManager(repository, builder) + + if _, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit"); err != nil { + t.Fatal(err) + } + if builder.adoptedRevision != candidate.ID { + t.Fatalf("prepared security events were not adopted: %q", builder.adoptedRevision) + } + manager.Current().Close() + select { + case <-preparedContext.Done(): + default: + t.Fatal("closing the active runtime did not release its prepared security event manager") + } +} + +func TestPreparedSecurityEventReceiverRemainsAvailableUntilAdoption(t *testing.T) { + cancelled := false + builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{ + "draft": {manager: &securityevents.ConnectionManager{}, cancel: func() { cancelled = true }, receiverReady: true}, + }} + if builder.PreparedSecurityEventReceiver() == nil { + t.Fatal("prepared receiver was unavailable before activation") + } + cancel := builder.AdoptPreparedSecurityEvents("draft") + if cancel == nil || builder.PreparedSecurityEventReceiver() != nil { + t.Fatal("prepared receiver ownership was not transferred exactly once") + } + cancel() + if !cancelled { + t.Fatal("adopted receiver lifetime could not be released") + } +} + +func TestSecurityEventManagerExposesPreparedRecoveryManagerWithoutActiveRuntime(t *testing.T) { + prepared := &securityevents.ConnectionManager{} + manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared}) + + if manager.SecurityEventManager() != prepared || manager.SecurityEventReceiver() != prepared { + t.Fatal("prepared security event manager was unavailable to recovery handlers") + } +} + +func TestSecurityEventManagerPrefersActiveRuntime(t *testing.T) { + active := &securityevents.ConnectionManager{} + prepared := &securityevents.ConnectionManager{} + manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared}) + manager.current.Store(&Runtime{SecurityEvents: active}) + + if manager.SecurityEventManager() != active { + t.Fatal("prepared recovery manager replaced the active runtime manager") + } +} + +func TestSecurityEventReceiverPrefersReadyDraftDuringRepairing(t *testing.T) { + active := &securityevents.ConnectionManager{} + prepared := &securityevents.ConnectionManager{} + manager := NewManager(&runtimeRepositoryFake{}, &runtimeBuilderFake{preparedManager: prepared, preparedReceiver: prepared}) + manager.current.Store(&Runtime{SecurityEvents: active}) + + if manager.SecurityEventReceiver() != prepared { + t.Fatal("verification callback was not routed to the ready draft receiver") + } +} + +func TestConflictingPreparedManagerIsRecoverableButNotUsedAsReceiver(t *testing.T) { + prepared := &securityevents.ConnectionManager{} + builder := &RuntimeBuilder{prepared: map[string]preparedSecurityRuntime{ + "draft": {manager: prepared}, + }} + + if builder.PreparedSecurityEventManager() != prepared { + t.Fatal("conflicting connection manager was unavailable for retirement") + } + if builder.PreparedSecurityEventReceiver() != nil { + t.Fatal("unprepared conflict manager was exposed as a verification receiver") + } +} diff --git a/apps/api/internal/securityevents/connection_manager.go b/apps/api/internal/securityevents/connection_manager.go index 6d572d2..99eee3a 100644 --- a/apps/api/internal/securityevents/connection_manager.go +++ b/apps/api/internal/securityevents/connection_manager.go @@ -34,12 +34,15 @@ type ConnectionRepository interface { CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error) - SetSecurityEventManagementCredential(context.Context, string, string, string) (store.SecurityEventConnection, error) - UpdateSecurityEventConnectionLifecycle(context.Context, string, string, *string) (store.SecurityEventConnection, error) - SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) + SetSecurityEventManagementCredential(context.Context, string, string, string, int64) (store.SecurityEventConnection, error) + TransitionSecurityEventConnectionLifecycle(context.Context, string, string, int64, string, *string) (store.SecurityEventConnection, error) + SetSecurityEventNextCredential(context.Context, string, string, int64) (store.SecurityEventConnection, error) PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) - DeleteSecurityEventConnection(context.Context, string) error + DiscardPreparedSecurityEventConnection(context.Context, string, string, int64) error + FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error) + QueueIdentitySecretCleanup(context.Context, string, time.Time) error + RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error) } type ConnectionManagerConfig struct { @@ -49,6 +52,10 @@ type ConnectionManagerConfig struct { OIDCTenantID string ManagementClientID string ManagementClientSecret string + ExpectedTransmitterIssuer string + ExpectedAudience string + ExpectedOwnerKey string + StrictRevisionBinding bool PublicBaseURL string HeartbeatInterval time.Duration StaleAfter time.Duration @@ -79,15 +86,23 @@ type connectionRuntime struct { cancel context.CancelFunc } +type connectionRetirementAuthorization struct { + connectionID string + credentialRef string + ownerKey string +} + type ConnectionManager struct { - ctx context.Context - repository ConnectionRepository - secrets SecretStore - config ConnectionManagerConfig - metrics *Metrics - mutex sync.RWMutex - operation sync.Mutex - runtime *connectionRuntime + ctx context.Context + repository ConnectionRepository + secrets SecretStore + config ConnectionManagerConfig + metrics *Metrics + mutex sync.RWMutex + operation sync.Mutex + runtime *connectionRuntime + retirement *connectionRetirementAuthorization + bindingMismatch bool } type transmitterConfiguration struct { @@ -118,6 +133,17 @@ func (e remoteHTTPError) Error() string { return fmt.Sprintf("SSF endpoint returned HTTP %d", e.status) } +type safeConnectionError struct { + category string + cause error +} + +func (err safeConnectionError) Error() string { + return "security event operation failed: " + err.category +} +func (err safeConnectionError) Unwrap() error { return err.cause } +func (err safeConnectionError) SafeErrorCategory() string { return err.category } + func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) { if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" { return nil, errors.New("security event connection prerequisites are incomplete") @@ -138,16 +164,35 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository, config.CredentialOverlapDuration = 3 * time.Minute } if config.RetirementDuration == 0 { - config.RetirementDuration = 6 * time.Minute + config.RetirementDuration = defaultSecurityEventRetirementDuration } manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: config, metrics: metrics} connection, err := repository.SecurityEventConnection(ctx) if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + if config.StrictRevisionBinding { + return nil, safeConnectionError{category: "connection_binding_missing", cause: err} + } return manager, nil } if err != nil { return nil, fmt.Errorf("load security event connection: %w", err) } + if config.StrictRevisionBinding { + if err := manager.validateConnectionBinding(connection); err != nil { + return nil, err + } + } else if hasRevisionBindingExpectation(config) { + if err := manager.validateConnectionBinding(connection); err != nil { + // A recovery manager may inspect a foreign singleton connection, but + // it must not activate it: activate() would combine the old persisted + // Secret with the new Revision's token endpoint. + if connection.LifecycleStatus == "retiring" { + go manager.finishRetirement(connection) + } + manager.bindingMismatch = true + return manager, nil + } + } if connection.LifecycleStatus == "retiring" { if connection.StreamID != nil && connection.Audience != nil { _ = manager.activate(ctx, connection) @@ -165,7 +210,9 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository, if connection.StreamID != nil && connection.Audience != nil { if err := manager.activate(ctx, connection); err != nil { category := "credential_unavailable" - _, _ = repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "degraded", &category) + _, _ = repository.TransitionSecurityEventConnectionLifecycle( + ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "degraded", &category, + ) } } if connection.LifecycleStatus == "bootstrap" { @@ -174,6 +221,42 @@ func NewConnectionManager(ctx context.Context, repository ConnectionRepository, return manager, nil } +func hasRevisionBindingExpectation(config ConnectionManagerConfig) bool { + return strings.TrimSpace(config.ExpectedTransmitterIssuer) != "" || + strings.TrimSpace(config.ExpectedAudience) != "" || + strings.TrimSpace(config.ExpectedOwnerKey) != "" +} + +// ValidateConfiguredConnectionBinding verifies that the singleton persisted +// SSF connection belongs to the identity Revision represented by this manager. +// Recovery managers call this explicitly only when they are about to become an +// Active Runtime; cleanup and retirement remain able to inspect old bindings. +func (m *ConnectionManager) ValidateConfiguredConnectionBinding(ctx context.Context) error { + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return safeConnectionError{category: "connection_binding_missing", cause: err} + } + if err != nil { + return safeConnectionError{category: "connection_binding_unavailable", cause: err} + } + return m.validateConnectionBinding(connection) +} + +func (m *ConnectionManager) validateConnectionBinding(connection store.SecurityEventConnection) error { + expectedIssuer := strings.TrimRight(strings.TrimSpace(m.config.ExpectedTransmitterIssuer), "/") + expectedAudience := strings.TrimSpace(m.config.ExpectedAudience) + expectedClientID := strings.TrimSpace(m.config.ManagementClientID) + expectedOwner := strings.TrimSpace(m.config.ExpectedOwnerKey) + if expectedIssuer == "" || expectedAudience == "" || expectedClientID == "" || expectedOwner == "" { + return safeConnectionError{category: "connection_binding_invalid", cause: errors.New("security event Revision binding is incomplete")} + } + if connection.TransmitterIssuer != expectedIssuer || connection.Audience == nil || *connection.Audience != expectedAudience || + connection.ManagementClientID != expectedClientID || connection.IdempotencyKey != expectedOwner { + return safeConnectionError{category: "connection_binding_mismatch", cause: store.ErrSecurityEventConnectionConflict} + } + return nil +} + func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) { connection, err := m.repository.SecurityEventConnection(ctx) if err != nil { @@ -183,6 +266,9 @@ func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) { } func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) { + if err := m.requireOperationalBinding(); err != nil { + return "", nil, err + } connection, err := m.repository.SecurityEventConnection(ctx) if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" { @@ -212,6 +298,10 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien if (managementClientID == "") != (len(managementSecret) == 0) { return ConnectionView{}, errors.New("machine client id and secret must be provided together") } + if err == nil && (existing.LifecycleStatus == "retiring" || + existing.LifecycleStatus == "disconnect_pending" && existing.IdempotencyKey == idempotencyKey) { + return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict} + } if !credentialsProvided { if err == nil { managementClientID, managementSecret, _ = m.managementCredential(ctx, existing) @@ -225,10 +315,46 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien return ConnectionView{}, err } if err == nil { + if m.bindingMismatch && existing.IdempotencyKey == idempotencyKey { + return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict} + } if existing.TransmitterIssuer != issuer { - return ConnectionView{}, store.ErrSecurityEventConnectionConflict + return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict} } if credentialsProvided { + if existing.IdempotencyKey != idempotencyKey { + if existing.ManagementClientID == "" || existing.ManagementClientID != managementClientID { + return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict} + } + if existing.StreamID == nil { + m.retirement = &connectionRetirementAuthorization{ + connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey, + } + return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict} + } + if m.retirement == nil || m.retirement.connectionID != existing.ConnectionID || + m.retirement.credentialRef != valueOrEmpty(existing.ManagementCredentialRef) || + m.retirement.ownerKey != idempotencyKey { + if err := m.proveManagementCredentialHandoff(ctx, existing, managementClientID, managementSecret); err != nil { + return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: err} + } + existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret) + if err != nil { + return ConnectionView{}, err + } + m.retirement = &connectionRetirementAuthorization{ + connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey, + } + } + if existing.LifecycleStatus == "disconnect_pending" { + go m.retryDisconnect(existing.ConnectionID) + return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict} + } + return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict} + } + if existing.ManagementClientID != "" && existing.ManagementClientID != managementClientID { + return ConnectionView{}, store.ErrSecurityEventConnectionConflict + } existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret) if err != nil { return ConnectionView{}, err @@ -247,17 +373,17 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien connectionID := uuid.NewString() reference := "ssf-push-" + connectionID managementReference := "ssf-management-" + connectionID - if err := m.secrets.Put(ctx, managementReference, managementSecret); err != nil { + if err := m.stageIdentitySecret(ctx, managementReference, managementSecret); err != nil { return ConnectionView{}, err } secret, err := randomPushBearer() if err != nil { - _ = m.secrets.Delete(ctx, managementReference) + _ = m.retireIdentitySecret(ctx, managementReference) return ConnectionView{}, err } defer clear(secret) - if err := m.secrets.Put(ctx, reference, secret); err != nil { - _ = m.secrets.Delete(ctx, managementReference) + if err := m.stageIdentitySecret(ctx, reference, secret); err != nil { + _ = m.retireIdentitySecret(ctx, managementReference) return ConnectionView{}, err } endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf" @@ -267,8 +393,9 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey, }) if err != nil { - _ = m.secrets.Delete(ctx, reference) - _ = m.secrets.Delete(ctx, managementReference) + // Commit outcome can be ambiguous: rollback keeps the staging rows, + // while commit atomically removes them. Requeueing here could delete + // credentials already referenced by the committed connection. return ConnectionView{}, err } return m.resumeConnecting(ctx, connection) @@ -276,17 +403,14 @@ func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClien func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) { reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString() - if err := m.secrets.Put(ctx, reference, secret); err != nil { + if err := m.stageIdentitySecret(ctx, reference, secret); err != nil { return store.SecurityEventConnection{}, err } - updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference) + updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference, connection.Version) if err != nil { - _ = m.secrets.Delete(ctx, reference) + // The staging row is the source of truth when commit outcome is unknown. return store.SecurityEventConnection{}, err } - if connection.ManagementCredentialRef != nil { - _ = m.secrets.Delete(ctx, *connection.ManagementCredentialRef) - } return updated, nil } @@ -337,6 +461,9 @@ func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection sto } func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) { + if err := m.requireOperationalBinding(); err != nil { + return ConnectionView{}, err + } connection, err := m.repository.SecurityEventConnection(ctx) if err != nil { return ConnectionView{}, err @@ -366,6 +493,9 @@ func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionView, error) { m.operation.Lock() defer m.operation.Unlock() + if err := m.requireOperationalBinding(); err != nil { + return ConnectionView{}, err + } connection, err := m.repository.SecurityEventConnection(ctx) if err != nil { return ConnectionView{}, err @@ -381,14 +511,14 @@ func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionVie if err != nil { return ConnectionView{}, err } - if err := m.secrets.Put(ctx, nextReference, next); err != nil { + if err := m.stageIdentitySecret(ctx, nextReference, next); err != nil { clear(next) return ConnectionView{}, err } - connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference) + connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference, connection.Version) if err != nil { clear(next) - _ = m.secrets.Delete(ctx, nextReference) + // Never recreate cleanup intent after an ambiguous adoption commit. return ConnectionView{}, err } } else { @@ -424,10 +554,56 @@ func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionVie func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, error) { m.operation.Lock() defer m.operation.Unlock() + if err := m.requireOperationalBinding(); err != nil { + return ConnectionView{}, err + } connection, err := m.repository.SecurityEventConnection(ctx) if err != nil { return ConnectionView{}, err } + return m.disconnect(ctx, connection) +} + +// RetireConflictingConnection is a pairing-scoped recovery operation. It may +// retire only a connection owned by another workflow; a stale request becomes +// a no-op once the current pairing owns the singleton connection. +func (m *ConnectionManager) RetireConflictingConnection(ctx context.Context, currentOwnerKey string) error { + m.operation.Lock() + defer m.operation.Unlock() + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return nil + } + if err != nil { + return err + } + if strings.TrimSpace(currentOwnerKey) == "" { + return errors.New("current security event owner is required") + } + if connection.IdempotencyKey == currentOwnerKey { + return nil + } + if connection.LifecycleStatus == "retiring" { + go m.finishRetirement(connection) + return nil + } + if m.retirement == nil || m.retirement.connectionID != connection.ConnectionID || + m.retirement.credentialRef != valueOrEmpty(connection.ManagementCredentialRef) || + m.retirement.ownerKey != currentOwnerKey { + return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict} + } + if connection.LifecycleStatus == "disconnect_pending" { + go m.retryDisconnect(connection.ConnectionID) + return nil + } + _, err = m.disconnect(ctx, connection) + return err +} + +func (m *ConnectionManager) disconnect(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) { + if connection.LifecycleStatus == "retiring" || connection.LifecycleStatus == "disconnect_pending" { + return m.view(connection), nil + } if connection.StreamID != nil { configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer) if discoverErr == nil { @@ -441,12 +617,20 @@ func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, err } if discoverErr != nil { category := "remote_disconnect_failed" - connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "disconnect_pending", &category) + transitioned, transitionErr := m.repository.TransitionSecurityEventConnectionLifecycle( + ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "disconnect_pending", &category, + ) + if transitionErr != nil { + return ConnectionView{}, transitionErr + } + connection = transitioned go m.retryDisconnect(connection.ConnectionID) return m.view(connection), nil } } - connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "retiring", nil) + connection, err := m.repository.TransitionSecurityEventConnectionLifecycle( + ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "retiring", nil, + ) if err != nil { return ConnectionView{}, err } @@ -454,6 +638,51 @@ func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, err return m.view(connection), nil } +// DiscardPreparedConnection removes only the connection created by one identity +// pairing. Bound streams continue through the normal retirement window so +// revocation watermarks and introspection fallback are never bypassed. +func (m *ConnectionManager) DiscardPreparedConnection(ctx context.Context, ownerKey string) error { + m.operation.Lock() + defer m.operation.Unlock() + connection, err := m.repository.SecurityEventConnection(ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return nil + } + if err != nil { + return safeConnectionError{category: "connection_cleanup_failed", cause: err} + } + if strings.TrimSpace(ownerKey) == "" || connection.IdempotencyKey != ownerKey { + return nil + } + if connection.StreamID != nil { + if connection.LifecycleStatus != "retiring" && connection.LifecycleStatus != "disconnect_pending" { + if _, err := m.disconnect(ctx, connection); err != nil { + return safeConnectionError{category: "connection_cleanup_failed", cause: err} + } + } + return safeConnectionError{category: "retirement_pending", cause: errors.New("security event retirement is pending")} + } + err = m.repository.DiscardPreparedSecurityEventConnection( + ctx, connection.ConnectionID, ownerKey, connection.Version, + ) + if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return safeConnectionError{category: "connection_cleanup_failed", cause: err} + } + m.stopRuntime() + return nil +} + +func connectionSecretReferences(connection store.SecurityEventConnection) []string { + references := []string{connection.CredentialRef} + if connection.ManagementCredentialRef != nil { + references = append(references, *connection.ManagementCredentialRef) + } + if connection.NextCredentialRef != nil { + references = append(references, *connection.NextCredentialRef) + } + return references +} + func (m *ConnectionManager) retryDisconnect(connectionID string) { delay := time.Second for { @@ -478,7 +707,9 @@ func (m *ConnectionManager) retryDisconnect(connectionID string) { } } if err == nil { - connection, err = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "retiring", nil) + connection, err = m.repository.TransitionSecurityEventConnectionLifecycle( + m.ctx, connectionID, "disconnect_pending", connection.Version, "retiring", nil, + ) if err == nil { go m.finishRetirement(connection) } @@ -625,49 +856,57 @@ func (m *ConnectionManager) finishAutomaticVerification(connectionID string, con defer cancel() ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() + expectedLifecycle := "verifying" + if rotating { + expectedLifecycle = "rotating" + } for { connection, err := m.repository.SecurityEventConnection(ctx) - if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil { + if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil || connection.LifecycleStatus != expectedLifecycle { return } _, verifiedAt, _ := m.repository.SecurityEventMetrics(ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience)) if verifiedAt != nil && !verifiedAt.Before(started) { if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "enabled", "verification_succeeded"); err != nil { category := "stream_enable_failed" - lifecycle := "verifying" - if rotating { - lifecycle = "rotating" - } - _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, lifecycle, &category) + _, _ = m.repository.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category, + ) return } if rotating && connection.NextCredentialRef != nil { - oldReference, nextReference := connection.CredentialRef, *connection.NextCredentialRef + nextReference := *connection.NextCredentialRef select { case <-m.ctx.Done(): return case <-time.After(m.config.CredentialOverlapDuration): } + current, currentErr := m.repository.SecurityEventConnection(m.ctx) + if currentErr != nil || current.ConnectionID != connectionID || current.LifecycleStatus != "rotating" || + current.NextCredentialRef == nil || *current.NextCredentialRef != nextReference { + return + } connection, err = m.repository.PromoteSecurityEventCredential(m.ctx, connectionID, nextReference) if err == nil { - _ = m.secrets.Delete(m.ctx, oldReference) _ = m.activate(m.ctx, connection) go m.finishBootstrap(connectionID) } return } - connection, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connectionID, "bootstrap", nil) - go m.finishBootstrap(connectionID) + connection, err = m.repository.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, expectedLifecycle, connection.Version, "bootstrap", nil, + ) + if err == nil { + go m.finishBootstrap(connectionID) + } return } select { case <-ctx.Done(): category := "verification_timeout" - lifecycle := "verifying" - if rotating { - lifecycle = "rotating" - } - _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, lifecycle, &category) + _, _ = m.repository.TransitionSecurityEventConnectionLifecycle( + m.ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category, + ) return case <-ticker.C: } @@ -691,46 +930,89 @@ func (m *ConnectionManager) finishBootstrap(connectionID string) { if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" { mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience)) if metricsErr == nil && mode == "push_healthy" { - _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "enabled", nil) + _, _ = m.repository.TransitionSecurityEventConnectionLifecycle( + m.ctx, connectionID, "bootstrap", connection.Version, "enabled", nil, + ) return } category := "bootstrap_verification_stale" - _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(m.ctx, connectionID, "degraded", &category) + _, _ = m.repository.TransitionSecurityEventConnectionLifecycle( + m.ctx, connectionID, "bootstrap", connection.Version, "degraded", &category, + ) } } -func (m *ConnectionManager) finishRetirement(connection store.SecurityEventConnection) { - elapsed := time.Since(connection.UpdatedAt) - delay := m.config.RetirementDuration - elapsed - if delay > 0 { - select { - case <-m.ctx.Done(): - return - case <-time.After(delay): - } - } - delay = time.Second +func (m *ConnectionManager) finishRetirement(retirement store.SecurityEventConnection) { + retryDelay := time.Second for { - if err := m.repository.DeleteSecurityEventConnection(m.ctx, connection.ConnectionID); err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) { - break - } - select { - case <-m.ctx.Done(): + connection, err := m.repository.SecurityEventConnection(m.ctx) + if errors.Is(err, store.ErrSecurityEventConnectionNotFound) { return - case <-time.After(delay): } - if delay < time.Minute { - delay *= 2 + if err != nil { + if !waitForConnectionRetry(m.ctx, retryDelay) { + return + } + retryDelay = nextConnectionRetryDelay(retryDelay) + continue } + if connection.ConnectionID != retirement.ConnectionID || connection.LifecycleStatus != "retiring" { + return + } + if remaining := m.config.RetirementDuration - time.Since(connection.UpdatedAt); remaining > 0 { + select { + case <-m.ctx.Done(): + return + case <-time.After(remaining): + } + continue + } + m.stopRuntime() + err = m.repository.FinalizeRetiringSecurityEventConnection(m.ctx, connection.ConnectionID, connection.Version) + if err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return + } + if !waitForConnectionRetry(m.ctx, retryDelay) { + return + } + retryDelay = nextConnectionRetryDelay(retryDelay) } - m.stopRuntime() - _ = m.secrets.Delete(m.ctx, connection.CredentialRef) - if connection.ManagementCredentialRef != nil { - _ = m.secrets.Delete(m.ctx, *connection.ManagementCredentialRef) +} + +func waitForConnectionRetry(ctx context.Context, delay time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(delay): + return true } - if connection.NextCredentialRef != nil { - _ = m.secrets.Delete(m.ctx, *connection.NextCredentialRef) +} + +func nextConnectionRetryDelay(delay time.Duration) time.Duration { + if delay < time.Minute { + return delay * 2 } + return delay +} + +const identitySecretStagingTTL = 10 * time.Minute + +func (m *ConnectionManager) stageIdentitySecret(ctx context.Context, reference string, value []byte) error { + if err := m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(identitySecretStagingTTL)); err != nil { + return err + } + if err := m.secrets.Put(ctx, reference, value); err != nil { + deleteErr := m.secrets.Delete(ctx, reference) + if deleteErr == nil || errors.Is(deleteErr, ErrSecretNotFound) { + _, _ = m.repository.RemovePendingIdentitySecretCleanup(ctx, reference) + } + return err + } + return nil +} + +func (m *ConnectionManager) retireIdentitySecret(ctx context.Context, reference string) error { + return m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC()) } func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error { @@ -794,6 +1076,10 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl return "", err } defer clear(managementSecret) + return m.requestManagementToken(ctx, client, scopes, managementClientID, managementSecret) +} + +func (m *ConnectionManager) requestManagementToken(ctx context.Context, client *http.Client, scopes, managementClientID string, managementSecret []byte) (string, error) { form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}} request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode())) if err != nil { @@ -820,6 +1106,44 @@ func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Cl return payload.AccessToken, nil } +func (m *ConnectionManager) proveManagementCredentialHandoff(ctx context.Context, connection store.SecurityEventConnection, managementClientID string, managementSecret []byte) error { + if connection.StreamID == nil || connection.Audience == nil { + return store.ErrSecurityEventConnectionConflict + } + configuration, client, err := m.discover(ctx, connection.TransmitterIssuer) + if err != nil { + return err + } + token, err := m.requestManagementToken(ctx, client, "ssf.stream.read ssf.stream.manage", managementClientID, managementSecret) + if err != nil { + return err + } + var streams []streamConfiguration + if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil { + return err + } + for _, stream := range streams { + if stream.StreamID != *connection.StreamID { + continue + } + if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil || stream.Audience != *connection.Audience { + return store.ErrSecurityEventConnectionConflict + } + return nil + } + // An authenticated empty list proves that the new credential belongs to a + // token issuer trusted by the old transmitter. Disconnect treats the absent + // Stream as an idempotent remote deletion. + return nil +} + +func (m *ConnectionManager) requireOperationalBinding() error { + if m.bindingMismatch { + return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict} + } + return nil +} + func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) { if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" { secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef) @@ -882,8 +1206,10 @@ func (m *ConnectionManager) deleteStream(ctx context.Context, client *http.Clien } func (m *ConnectionManager) connectionError(ctx context.Context, connection store.SecurityEventConnection, category string, cause error) error { - _, _ = m.repository.UpdateSecurityEventConnectionLifecycle(ctx, connection.ConnectionID, "error", &category) - return cause + _, _ = m.repository.TransitionSecurityEventConnectionLifecycle( + ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "error", &category, + ) + return safeConnectionError{category: category, cause: cause} } func (m *ConnectionManager) view(connection store.SecurityEventConnection) ConnectionView { @@ -1054,7 +1380,12 @@ var blockedNetworkPrefixes = []netip.Prefix{ } func isLocalHostname(value string) bool { - return value == "localhost" || value == "127.0.0.1" || value == "::1" + value = strings.ToLower(strings.TrimSpace(value)) + if value == "localhost" { + return true + } + ip := net.ParseIP(value) + return ip != nil && ip.IsLoopback() } func isLocalEnvironment(value string) bool { diff --git a/apps/api/internal/securityevents/connection_manager_test.go b/apps/api/internal/securityevents/connection_manager_test.go index 6b1fbef..be67479 100644 --- a/apps/api/internal/securityevents/connection_manager_test.go +++ b/apps/api/internal/securityevents/connection_manager_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "net/http" "net/http/httptest" @@ -20,11 +21,81 @@ import ( ) type memoryConnectionRepository struct { - mutex sync.Mutex - connection *store.SecurityEventConnection - mode string - verifiedAt *time.Time - evaluation store.SecurityEventEvaluation + mutex sync.Mutex + connection *store.SecurityEventConnection + beforeDiscard func(*store.SecurityEventConnection) + beforeFinalize func(*store.SecurityEventConnection) + finalizeCalls int + cleanupDue map[string]time.Time + cleanupClaims map[string]string + cleanupClaimed chan struct{} + mode string + verifiedAt *time.Time + evaluation store.SecurityEventEvaluation +} + +func (r *memoryConnectionRepository) QueueIdentitySecretCleanup(_ context.Context, reference string, notBefore time.Time) error { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.cleanupDue == nil { + r.cleanupDue = map[string]time.Time{} + } + if r.cleanupClaims[reference] != "" { + return store.ErrIdentitySecretCleanupConflict + } + current, exists := r.cleanupDue[reference] + if !exists || notBefore.Before(current) { + r.cleanupDue[reference] = notBefore + } + return nil +} +func (r *memoryConnectionRepository) RemovePendingIdentitySecretCleanup(_ context.Context, reference string) (bool, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.cleanupClaims[reference] != "" { + return false, nil + } + _, exists := r.cleanupDue[reference] + delete(r.cleanupDue, reference) + return exists, nil +} +func (r *memoryConnectionRepository) ClaimIdentitySecretCleanups(_ context.Context, limit int, lease time.Duration) ([]store.IdentitySecretCleanupClaim, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.cleanupClaims == nil { + r.cleanupClaims = map[string]string{} + } + if r.cleanupClaimed != nil { + select { + case r.cleanupClaimed <- struct{}{}: + default: + } + } + now := time.Now().UTC() + claims := make([]store.IdentitySecretCleanupClaim, 0) + for reference, notBefore := range r.cleanupDue { + if !notBefore.After(now) && r.cleanupClaims[reference] == "" { + token := uuid.NewString() + r.cleanupClaims[reference] = token + claims = append(claims, store.IdentitySecretCleanupClaim{ + Reference: reference, ClaimToken: token, LeaseExpiresAt: now.Add(lease), + }) + if len(claims) == limit { + break + } + } + } + return claims, nil +} +func (r *memoryConnectionRepository) CompleteIdentitySecretCleanup(_ context.Context, reference, claimToken string) (bool, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.cleanupClaims[reference] != claimToken { + return false, nil + } + delete(r.cleanupClaims, reference) + delete(r.cleanupDue, reference) + return true, nil } func (*memoryConnectionRepository) ApplySessionRevoked(context.Context, store.ApplySessionRevokedInput) (store.ApplySecurityEventResult, error) { @@ -70,15 +141,29 @@ func (r *memoryConnectionRepository) CreateSecurityEventConnection(_ context.Con ManagementCredentialRef: &input.ManagementCredentialRef, LifecycleStatus: "connecting", Version: 1, IdempotencyKey: input.IdempotencyKey, CreatedAt: now, UpdatedAt: now, HealthMode: "disabled"} r.connection = &value + delete(r.cleanupDue, input.CredentialRef) + delete(r.cleanupDue, input.ManagementCredentialRef) return value, nil } -func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference string) (store.SecurityEventConnection, error) { +func (r *memoryConnectionRepository) SetSecurityEventManagementCredential(_ context.Context, id, clientID, reference string, expectedVersion int64) (store.SecurityEventConnection, error) { r.mutex.Lock() defer r.mutex.Unlock() if r.connection == nil || r.connection.ConnectionID != id { return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound } + if r.connection.Version != expectedVersion || r.connection.LifecycleStatus == "retiring" || r.connection.LifecycleStatus == "disconnect_pending" { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict + } + oldReference := r.connection.ManagementCredentialRef r.connection.ManagementClientID, r.connection.ManagementCredentialRef = clientID, &reference + r.connection.Version++ + delete(r.cleanupDue, reference) + if oldReference != nil && *oldReference != reference { + if r.cleanupDue == nil { + r.cleanupDue = map[string]time.Time{} + } + r.cleanupDue[*oldReference] = time.Now().UTC() + } return *r.connection, nil } func (r *memoryConnectionRepository) SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) { @@ -101,19 +186,35 @@ func (r *memoryConnectionRepository) BindSecurityEventConnection(_ context.Conte r.connection.LifecycleStatus, r.connection.Version = lifecycle, r.connection.Version+1 return *r.connection, nil } -func (r *memoryConnectionRepository) UpdateSecurityEventConnectionLifecycle(_ context.Context, id, lifecycle string, category *string) (store.SecurityEventConnection, error) { +func (r *memoryConnectionRepository) TransitionSecurityEventConnectionLifecycle(_ context.Context, id, expectedLifecycle string, expectedVersion int64, lifecycle string, category *string) (store.SecurityEventConnection, error) { r.mutex.Lock() defer r.mutex.Unlock() - if r.connection == nil || r.connection.ConnectionID != id { - return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound + if r.connection == nil || r.connection.ConnectionID != id || r.connection.LifecycleStatus != expectedLifecycle || r.connection.Version != expectedVersion { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict + } + if (r.connection.LifecycleStatus == "retiring" || r.connection.LifecycleStatus == "disconnect_pending") && + lifecycle != "retiring" && lifecycle != "disconnect_pending" { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict } r.connection.LifecycleStatus, r.connection.LastErrorCategory = lifecycle, category r.connection.Version++ r.connection.UpdatedAt = time.Now().UTC() return *r.connection, nil } -func (r *memoryConnectionRepository) SetSecurityEventNextCredential(context.Context, string, string) (store.SecurityEventConnection, error) { - return store.SecurityEventConnection{}, errors.New("not used") +func (r *memoryConnectionRepository) SetSecurityEventNextCredential(_ context.Context, id, reference string, expectedVersion int64) (store.SecurityEventConnection, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil || r.connection.ConnectionID != id { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionNotFound + } + if r.connection.Version != expectedVersion || r.connection.NextCredentialRef != nil || r.connection.LifecycleStatus == "retiring" || r.connection.LifecycleStatus == "disconnect_pending" { + return store.SecurityEventConnection{}, store.ErrSecurityEventConnectionConflict + } + r.connection.NextCredentialRef = &reference + r.connection.LifecycleStatus = "rotating" + r.connection.Version++ + delete(r.cleanupDue, reference) + return *r.connection, nil } func TestConnectionManagerBootstrapOnlyBecomesEnabledWhenPushIsHealthy(t *testing.T) { @@ -164,12 +265,510 @@ func TestConnectionManagerResumesBootstrapAfterRestart(t *testing.T) { t.Fatal("bootstrap lifecycle did not resume after process restart") } +func TestNewConnectionManagerStrictRevisionBindingRejectsDifferentPersistedConnection(t *testing.T) { + audience := "urn:easyai:ssf:receiver:target" + streamID := uuid.NewString() + base := store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), + TransmitterIssuer: "https://auth.example/ssf", + Audience: &audience, + StreamID: &streamID, + CredentialRef: "missing-credential-must-not-be-read", + ManagementClientID: "gateway-machine", + LifecycleStatus: "enabled", + IdempotencyKey: "identity-pairing-ssf-target-revision", + Version: 1, + } + config := ConnectionManagerConfig{ + OIDCEnabled: true, + OIDCIssuer: "https://auth.example/issuer", + OIDCTenantID: uuid.NewString(), + ManagementClientID: "gateway-machine", + ExpectedTransmitterIssuer: "https://auth.example/ssf", + ExpectedAudience: audience, + ExpectedOwnerKey: "identity-pairing-ssf-target-revision", + StrictRevisionBinding: true, + } + + tests := []struct { + name string + mutate func(*store.SecurityEventConnection) + }{ + {name: "transmitter issuer", mutate: func(connection *store.SecurityEventConnection) { + connection.TransmitterIssuer = "https://old-auth.example/ssf" + }}, + {name: "audience", mutate: func(connection *store.SecurityEventConnection) { + other := "urn:easyai:ssf:receiver:old" + connection.Audience = &other + }}, + {name: "missing audience", mutate: func(connection *store.SecurityEventConnection) { + connection.Audience = nil + }}, + {name: "management client", mutate: func(connection *store.SecurityEventConnection) { + connection.ManagementClientID = "old-machine" + }}, + {name: "owner", mutate: func(connection *store.SecurityEventConnection) { + connection.IdempotencyKey = "identity-pairing-ssf-old-revision" + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + connection := base + test.mutate(&connection) + repository := &memoryConnectionRepository{connection: &connection} + + manager, err := NewConnectionManager(context.Background(), repository, &memorySecretStore{}, config, &Metrics{}) + if manager != nil { + t.Fatal("strict runtime construction returned a manager for a different Revision binding") + } + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "connection_binding_mismatch" { + t.Fatalf("error=%v category=%v", err, categorized) + } + }) + } +} + +func TestNewConnectionManagerStrictRevisionBindingRequiresPersistedConnection(t *testing.T) { + manager, err := NewConnectionManager(context.Background(), &memoryConnectionRepository{}, &memorySecretStore{}, ConnectionManagerConfig{ + OIDCEnabled: true, + OIDCIssuer: "https://auth.example/issuer", + OIDCTenantID: uuid.NewString(), + ManagementClientID: "gateway-machine", + ExpectedTransmitterIssuer: "https://auth.example/ssf", + ExpectedAudience: "urn:easyai:ssf:receiver:target", + ExpectedOwnerKey: "identity-pairing-ssf-target-revision", + StrictRevisionBinding: true, + }, &Metrics{}) + if manager != nil { + t.Fatal("strict runtime construction succeeded without a persisted SSF connection") + } + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "connection_binding_missing" { + t.Fatalf("error=%v category=%v", err, categorized) + } +} + +func TestNewConnectionManagerStrictRevisionBindingAcceptsExactPersistedConnection(t *testing.T) { + audience := "urn:easyai:ssf:receiver:target" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), + TransmitterIssuer: "https://auth.example/ssf", + Audience: &audience, + ManagementClientID: "gateway-machine", + LifecycleStatus: "enabled", + IdempotencyKey: "identity-pairing-ssf-target-revision", + Version: 1, + }} + + manager, err := NewConnectionManager(context.Background(), repository, &memorySecretStore{}, ConnectionManagerConfig{ + OIDCEnabled: true, + OIDCIssuer: "https://auth.example/issuer", + OIDCTenantID: uuid.NewString(), + ManagementClientID: "gateway-machine", + ExpectedTransmitterIssuer: "https://auth.example/ssf/", + ExpectedAudience: audience, + ExpectedOwnerKey: "identity-pairing-ssf-target-revision", + StrictRevisionBinding: true, + }, &Metrics{}) + if err != nil || manager == nil { + t.Fatalf("exact Revision binding was rejected: manager=%v err=%v", manager != nil, err) + } +} + +func TestConnectionManagerRecoveryCanLoadDifferentPersistedConnection(t *testing.T) { + oldAudience := "urn:easyai:ssf:receiver:old" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), + TransmitterIssuer: "https://old-auth.example/ssf", + Audience: &oldAudience, + ManagementClientID: "old-machine", + LifecycleStatus: "error", + IdempotencyKey: "identity-pairing-ssf-old-revision", + Version: 1, + }} + config := ConnectionManagerConfig{ + OIDCEnabled: true, + OIDCIssuer: "https://auth.example/issuer", + OIDCTenantID: uuid.NewString(), + ManagementClientID: "gateway-machine", + ExpectedTransmitterIssuer: "https://auth.example/ssf", + ExpectedAudience: "urn:easyai:ssf:receiver:target", + ExpectedOwnerKey: "identity-pairing-ssf-target-revision", + } + + manager, err := NewConnectionManager(context.Background(), repository, &memorySecretStore{}, config, &Metrics{}) + if err != nil || manager == nil { + t.Fatalf("recovery manager could not load the conflicting connection: manager=%v err=%v", manager != nil, err) + } + err = manager.ValidateConfiguredConnectionBinding(context.Background()) + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "connection_binding_mismatch" { + t.Fatalf("explicit runtime binding validation did not reject recovery connection: err=%v category=%v", err, categorized) + } +} + +func TestForeignRevisionRecoveryNeverUsesPersistedCredentialOnGenericOperations(t *testing.T) { + var requests atomic.Int32 + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer endpoint.Close() + + audience, streamID := "urn:easyai:ssf:receiver:old", uuid.NewString() + managementReference := "ssf-management-old" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: endpoint.URL + "/old-ssf", EndpointURL: endpoint.URL + "/receiver", + Audience: &audience, StreamID: &streamID, CredentialRef: "ssf-push-old", ManagementClientID: "shared-machine", + ManagementCredentialRef: &managementReference, LifecycleStatus: "enabled", Version: 3, + IdempotencyKey: "identity-pairing-ssf-old-revision", + }} + secrets := &memorySecretStore{values: map[string][]byte{ + managementReference: []byte("old-machine-secret-must-never-leave"), + "ssf-push-old": []byte("old-push-secret-value-000000000000"), + }} + manager, err := NewConnectionManager(context.Background(), repository, secrets, ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: endpoint.URL + "/new-oidc", OIDCTenantID: uuid.NewString(), + ManagementClientID: "shared-machine", ExpectedTransmitterIssuer: endpoint.URL + "/new-ssf", + ExpectedAudience: "urn:easyai:ssf:receiver:new", ExpectedOwnerKey: "identity-pairing-ssf-new-revision", + PublicBaseURL: endpoint.URL, HTTPClient: endpoint.Client(), + }, &Metrics{}) + if err != nil { + t.Fatal(err) + } + + assertUnsafe := func(name string, err error) { + t.Helper() + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "credential_handoff_unsafe" { + t.Fatalf("%s error=%v", name, err) + } + } + _, _, credentialErr := manager.IntrospectionCredential(context.Background()) + assertUnsafe("introspection credential", credentialErr) + _, verifyErr := manager.Verify(context.Background()) + assertUnsafe("verify", verifyErr) + _, rotateErr := manager.RotateCredential(context.Background()) + assertUnsafe("rotate", rotateErr) + _, disconnectErr := manager.Disconnect(context.Background()) + assertUnsafe("disconnect", disconnectErr) + _, connectErr := manager.Connect(context.Background(), endpoint.URL+"/new-ssf", "shared-machine", []byte("new-machine-secret-value-000000000"), "identity-pairing-ssf-new-revision") + assertUnsafe("cross-issuer connect", connectErr) + assertUnsafe("retire without proved handoff", manager.RetireConflictingConnection(context.Background(), "identity-pairing-ssf-new-revision")) + + if requests.Load() != 0 { + t.Fatalf("foreign recovery sent %d authenticated or discovery requests", requests.Load()) + } + connection, err := repository.SecurityEventConnection(context.Background()) + if err != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference { + t.Fatalf("foreign recovery changed persisted credential: connection=%#v err=%v", connection, err) + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if queued != 0 || !secrets.Has(managementReference) { + t.Fatalf("foreign recovery queued=%d old_secret_present=%t", queued, secrets.Has(managementReference)) + } +} + +func TestPairingProvesRotatedCredentialBeforeRetiringLegacyStream(t *testing.T) { + const oldSecret = "old-machine-secret-value-0000000000" + const newSecret = "new-machine-secret-value-0000000000" + audience, streamID := "urn:easyai:ssf:receiver:legacy", uuid.NewString() + managementReference := "ssf-management-legacy" + var oldSecretSeen atomic.Bool + var newSecretRequests atomic.Int32 + var transmitter *httptest.Server + transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/ssf-configuration/ssf": + _ = json.NewEncoder(w).Encode(map[string]any{ + "spec_version": "1_0", "issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json", + "configuration_endpoint": transmitter.URL + "/ssf/v1/stream", "status_endpoint": transmitter.URL + "/ssf/v1/status", + "verification_endpoint": transmitter.URL + "/ssf/v1/verify", "delivery_methods_supported": []string{pushDeliveryMethod}, + }) + case "/oidc/token": + _, secret, _ := r.BasicAuth() + if secret == oldSecret { + oldSecretSeen.Store(true) + } + if secret != newSecret { + http.Error(w, "invalid client", http.StatusUnauthorized) + return + } + newSecretRequests.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "proved-management-token", "token_type": "Bearer"}) + case "/ssf/v1/stream": + if r.Header.Get("Authorization") != "Bearer proved-management-token" { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + description := "easyai-gateway:legacy" + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "stream_id": streamID, "iss": transmitter.URL + "/ssf", "aud": audience, + "events_requested": []string{sessionRevokedEvent}, + "delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": transmitter.URL + "/receiver"}, + "description": description, + }}) + default: + http.NotFound(w, r) + } + })) + defer transmitter.Close() + + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: "legacy", TransmitterIssuer: transmitter.URL + "/ssf", EndpointURL: transmitter.URL + "/receiver", + Audience: &audience, StreamID: &streamID, CredentialRef: "ssf-push-legacy", ManagementClientID: "shared-machine", + ManagementCredentialRef: &managementReference, LifecycleStatus: "enabled", Version: 2, IdempotencyKey: "manual-legacy-owner", + }} + secrets := &memorySecretStore{values: map[string][]byte{ + managementReference: []byte(oldSecret), "ssf-push-legacy": []byte("legacy-push-secret-value-000000000"), + }} + manager, err := NewConnectionManager(context.Background(), repository, secrets, ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: transmitter.URL + "/oidc", OIDCTenantID: uuid.NewString(), + ManagementClientID: "shared-machine", ExpectedTransmitterIssuer: transmitter.URL + "/ssf", + ExpectedAudience: "urn:easyai:ssf:receiver:new", ExpectedOwnerKey: "identity-pairing-ssf-new-revision", + PublicBaseURL: transmitter.URL, HTTPClient: transmitter.Client(), RetirementDuration: time.Hour, + }, &Metrics{}) + if err != nil { + t.Fatal(err) + } + + _, err = manager.Connect(context.Background(), transmitter.URL+"/ssf", "shared-machine", []byte(newSecret), "identity-pairing-ssf-new-revision") + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "connection_conflict" { + t.Fatalf("proved handoff error=%v", err) + } + connection, err := repository.SecurityEventConnection(context.Background()) + if err != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef == managementReference { + t.Fatalf("rotated credential was not adopted: connection=%#v err=%v", connection, err) + } + rotatedSecret, err := secrets.Get(context.Background(), *connection.ManagementCredentialRef) + if err != nil || string(rotatedSecret) != newSecret { + t.Fatalf("adopted credential invalid: present=%t err=%v", len(rotatedSecret) > 0, err) + } + clear(rotatedSecret) + if err := manager.RetireConflictingConnection(context.Background(), "identity-pairing-ssf-new-revision"); err != nil { + t.Fatal(err) + } + connection, err = repository.SecurityEventConnection(context.Background()) + if err != nil || connection.LifecycleStatus != "retiring" { + t.Fatalf("legacy connection was not retired: connection=%#v err=%v", connection, err) + } + if oldSecretSeen.Load() || newSecretRequests.Load() < 2 { + t.Fatalf("old_secret_sent=%t new_secret_requests=%d", oldSecretSeen.Load(), newSecretRequests.Load()) + } +} + +func TestFailedCredentialProofDoesNotReplaceLegacyCredential(t *testing.T) { + const oldSecret = "old-machine-secret-value-0000000000" + const newSecret = "new-machine-secret-value-0000000000" + var oldSecretSeen atomic.Bool + var tokenRequests atomic.Int32 + evilIssuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, secret, _ := r.BasicAuth() + if secret == oldSecret { + oldSecretSeen.Store(true) + } + if secret == newSecret { + tokenRequests.Add(1) + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "untrusted-token", "token_type": "Bearer"}) + })) + defer evilIssuer.Close() + audience, streamID := "urn:easyai:ssf:receiver:legacy", uuid.NewString() + managementReference := "ssf-management-legacy" + var transmitter *httptest.Server + transmitter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/ssf-configuration/ssf": + _ = json.NewEncoder(w).Encode(map[string]any{ + "spec_version": "1_0", "issuer": transmitter.URL + "/ssf", "jwks_uri": transmitter.URL + "/ssf/jwks.json", + "configuration_endpoint": transmitter.URL + "/ssf/v1/stream", "status_endpoint": transmitter.URL + "/ssf/v1/status", + "verification_endpoint": transmitter.URL + "/ssf/v1/verify", "delivery_methods_supported": []string{pushDeliveryMethod}, + }) + case "/ssf/v1/stream": + http.Error(w, "untrusted token issuer", http.StatusUnauthorized) + default: + http.NotFound(w, r) + } + })) + defer transmitter.Close() + + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: transmitter.URL + "/ssf", EndpointURL: transmitter.URL + "/receiver", + Audience: &audience, StreamID: &streamID, CredentialRef: "ssf-push-legacy", ManagementClientID: "shared-machine", + ManagementCredentialRef: &managementReference, LifecycleStatus: "enabled", Version: 2, IdempotencyKey: "manual-legacy-owner", + }} + secrets := &memorySecretStore{values: map[string][]byte{managementReference: []byte(oldSecret)}} + manager, err := NewConnectionManager(context.Background(), repository, secrets, ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: evilIssuer.URL, OIDCTenantID: uuid.NewString(), + ManagementClientID: "shared-machine", ExpectedTransmitterIssuer: transmitter.URL + "/ssf", + ExpectedAudience: "urn:easyai:ssf:receiver:new", ExpectedOwnerKey: "identity-pairing-ssf-new-revision", + PublicBaseURL: transmitter.URL, HTTPClient: &http.Client{}, + }, &Metrics{}) + if err != nil { + t.Fatal(err) + } + _, err = manager.Connect(context.Background(), transmitter.URL+"/ssf", "shared-machine", []byte(newSecret), "identity-pairing-ssf-new-revision") + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "credential_handoff_unsafe" { + t.Fatalf("unproved handoff error=%v", err) + } + connection, getErr := repository.SecurityEventConnection(context.Background()) + if getErr != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference { + t.Fatalf("failed proof replaced credential: connection=%#v err=%v", connection, getErr) + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if queued != 0 || !secrets.Has(managementReference) || oldSecretSeen.Load() || tokenRequests.Load() != 1 { + t.Fatalf("queued=%d old_present=%t old_sent=%t new_requests=%d", queued, secrets.Has(managementReference), oldSecretSeen.Load(), tokenRequests.Load()) + } +} + func TestRemoteNotFoundIsRecognizedForIdempotentDisconnect(t *testing.T) { if !isRemoteHTTPStatus(remoteHTTPError{status: http.StatusNotFound}, http.StatusNotFound) { t.Fatal("remote 404 was not recognized as an already-deleted Stream") } } +func TestDiscardPreparedConnectionOnlyDeletesOwnedUnboundResources(t *testing.T) { + for _, test := range []struct { + name string + owner string + wantGone bool + }{ + {name: "owned", owner: "identity-pairing-ssf-revision", wantGone: true}, + {name: "different owner", owner: "identity-pairing-ssf-other", wantGone: false}, + } { + t.Run(test.name, func(t *testing.T) { + managementReference := "ssf-management-connection" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-connection", ManagementCredentialRef: &managementReference, + LifecycleStatus: "error", LastErrorCategory: pointerTo("discovery_failed"), + IdempotencyKey: "identity-pairing-ssf-revision", Version: 2, + }} + secrets := &memorySecretStore{values: map[string][]byte{ + "ssf-push-connection": []byte("push-secret-value-for-cleanup-00000000"), + "ssf-management-connection": []byte("machine-secret-value-for-cleanup-00000"), + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: secrets} + + if err := manager.DiscardPreparedConnection(context.Background(), test.owner); err != nil { + t.Fatal(err) + } + _, getErr := repository.SecurityEventConnection(context.Background()) + gone := errors.Is(getErr, store.ErrSecurityEventConnectionNotFound) + if gone != test.wantGone { + t.Fatalf("connection gone=%t want=%t", gone, test.wantGone) + } + if test.wantGone && secrets.Len() != 2 { + t.Fatalf("owned connection Secrets were deleted before durable cleanup: %d", secrets.Len()) + } + if !test.wantGone && secrets.Len() != 2 { + t.Fatal("a different pairing deleted shared security event resources") + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if test.wantGone && queued != 2 { + t.Fatalf("owned connection queued %d Secrets for cleanup, want 2", queued) + } + if !test.wantGone && queued != 0 { + t.Fatal("a different pairing queued shared security event Secrets for cleanup") + } + }) + } +} + +func TestDiscardPreparedConnectionKeepsBoundRetirementResumable(t *testing.T) { + audience, streamID := "urn:easyai:ssf:receiver:"+uuid.NewString(), uuid.NewString() + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + Audience: &audience, StreamID: &streamID, CredentialRef: "ssf-push-connection", + LifecycleStatus: "retiring", IdempotencyKey: "identity-pairing-ssf-revision", Version: 3, + }} + secrets := &memorySecretStore{values: map[string][]byte{"ssf-push-connection": []byte("push-secret-value-for-retirement-00000")}} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: secrets} + + err := manager.DiscardPreparedConnection(context.Background(), "identity-pairing-ssf-revision") + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "retirement_pending" { + t.Fatalf("retirement did not return a safe resumable category: %v", err) + } + if _, getErr := repository.SecurityEventConnection(context.Background()); getErr != nil || secrets.Len() != 1 { + t.Fatal("retirement cleanup deleted local state before the safety window completed") + } +} + +func TestDiscardPreparedConnectionCannotDeleteConnectionBoundAfterSnapshot(t *testing.T) { + managementReference := "ssf-management-connection" + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-connection", ManagementCredentialRef: &managementReference, + LifecycleStatus: "error", LastErrorCategory: pointerTo("discovery_failed"), + IdempotencyKey: "identity-pairing-ssf-revision", Version: 2, + } + repository := &memoryConnectionRepository{connection: connection} + repository.beforeDiscard = func(current *store.SecurityEventConnection) { + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + streamID := uuid.NewString() + current.Audience = &audience + current.StreamID = &streamID + current.LifecycleStatus = "verifying" + current.Version++ + } + secrets := &memorySecretStore{values: map[string][]byte{ + "ssf-push-connection": []byte("push-secret-value-for-cleanup-00000000"), + "ssf-management-connection": []byte("machine-secret-value-for-cleanup-00000"), + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: secrets} + + err := manager.DiscardPreparedConnection(context.Background(), "identity-pairing-ssf-revision") + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "connection_cleanup_failed" { + t.Fatalf("stale cleanup error=%v, want safe connection_cleanup_failed", err) + } + persisted, getErr := repository.SecurityEventConnection(context.Background()) + if getErr != nil || persisted.StreamID == nil || persisted.Version != 3 { + t.Fatalf("stale cleanup removed the newly bound connection: connection=%+v err=%v", persisted, getErr) + } + if secrets.Len() != 2 { + t.Fatalf("stale cleanup removed Secrets used by the newly bound connection: %d", secrets.Len()) + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if queued != 0 { + t.Fatalf("stale cleanup queued %d Secrets used by the newly bound connection", queued) + } +} + +func TestConnectionFailureReturnsOnlySafeCategory(t *testing.T) { + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", LifecycleStatus: "connecting", Version: 1, + }} + manager := &ConnectionManager{repository: repository} + err := manager.connectionError(context.Background(), *repository.connection, "discovery_failed", errors.New("sensitive-marker token-value https://internal.example")) + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "discovery_failed" { + t.Fatalf("connection failure category=%v", err) + } + if strings.Contains(err.Error(), "sensitive-marker") || strings.Contains(err.Error(), "internal.example") || strings.Contains(err.Error(), "token-value") { + t.Fatalf("connection failure exposed its cause: %q", err.Error()) + } +} + +func pointerTo(value string) *string { return &value } + func TestConnectionLifecycleCanResumeOnlyIncompleteOrFailedConnections(t *testing.T) { for _, test := range []struct { status string @@ -207,6 +806,198 @@ func TestExistingConnectionCredentialRepairDoesNotRequirePublicBaseURL(t *testin } } +func TestTransmitterIssuerAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { + for _, appEnv := range []string{"", "production", "staging"} { + if _, err := validatedIssuerURL("http://127.0.0.2:18004/ssf", appEnv); err == nil { + t.Fatalf("%s accepted loopback HTTP transmitter issuer", appEnv) + } + } + for _, appEnv := range []string{"local", "development", "dev", "test"} { + if _, err := validatedIssuerURL("http://127.0.0.2:18004/ssf", appEnv); err != nil { + t.Fatalf("%s rejected loopback HTTP transmitter issuer: %v", appEnv, err) + } + } +} + +func TestDraftConnectionCannotReplaceCredentialOwnedByAnotherRevision(t *testing.T) { + managementReference := "ssf-management-active" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-active", ManagementClientID: "active-machine", + ManagementCredentialRef: &managementReference, LifecycleStatus: "error", Version: 2, + IdempotencyKey: "identity-pairing-ssf-active", + }} + secrets := &memorySecretStore{values: map[string][]byte{ + "ssf-push-active": []byte("push-secret-value-for-active-000000000"), + "ssf-management-active": []byte("active-machine-secret-value-000000000"), + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: secrets, config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + }} + newSecret := []byte("draft-machine-secret-value-0000000000") + defer clear(newSecret) + + _, err := manager.Connect(context.Background(), "https://auth.example/ssf", "draft-machine", newSecret, "identity-pairing-ssf-draft") + if !errors.Is(err, store.ErrSecurityEventConnectionConflict) { + t.Fatalf("draft credential replacement error=%v", err) + } + connection, getErr := repository.SecurityEventConnection(context.Background()) + if getErr != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference || connection.ManagementClientID != "active-machine" { + t.Fatalf("active connection credential metadata changed: %#v err=%v", connection, getErr) + } + if !secrets.Has(managementReference) || secrets.Len() != 2 { + t.Fatal("active connection credential was deleted or a draft credential was retained") + } +} + +func TestDraftConnectionReportsExistingRetirementAsResumable(t *testing.T) { + managementReference := "ssf-management-active" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-active", ManagementClientID: "active-machine", + ManagementCredentialRef: &managementReference, LifecycleStatus: "retiring", Version: 3, + IdempotencyKey: "identity-pairing-ssf-active", + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}, config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + }} + secret := []byte("draft-machine-secret-value-0000000000") + defer clear(secret) + + _, err := manager.Connect(context.Background(), "https://other-auth.example/ssf", "draft-machine", secret, "identity-pairing-ssf-draft") + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "retirement_pending" { + t.Fatalf("retirement conflict was not classified as resumable: %v", err) + } +} + +func TestRetiringConnectionRejectsSameOwnerReconnectBeforeReadingOrReplacingSecrets(t *testing.T) { + for _, status := range []string{"retiring", "disconnect_pending"} { + for _, credentialsProvided := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/credentials=%t", status, credentialsProvided), func(t *testing.T) { + managementReference := "ssf-management-retiring" + owner := "identity-pairing-ssf-current" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-retiring", ManagementClientID: "machine-client", + ManagementCredentialRef: &managementReference, LifecycleStatus: status, Version: 3, + IdempotencyKey: owner, + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}, config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + }} + var clientID string + var secret []byte + if credentialsProvided { + clientID = "machine-client" + secret = []byte("replacement-machine-secret-value-000000") + defer clear(secret) + } + + _, err := manager.Connect(context.Background(), "https://auth.example/ssf", clientID, secret, owner) + var categorized interface{ SafeErrorCategory() string } + if !errors.As(err, &categorized) || categorized.SafeErrorCategory() != "retirement_pending" { + t.Fatalf("same-owner reconnect during retirement error=%v", err) + } + connection, getErr := repository.SecurityEventConnection(context.Background()) + if getErr != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference { + t.Fatalf("retiring connection credential metadata changed: %v", getErr) + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if queued != 0 { + t.Fatal("retiring reconnect staged or retired a Secret") + } + }) + } + } +} + +func TestConflictingConnectionRetirementNeverRetiresCurrentPairingOwner(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + oldOwner := "identity-pairing-ssf-active" + currentOwner := "identity-pairing-ssf-draft" + repository := &memoryConnectionRepository{connection: &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + CredentialRef: "ssf-push-active", ManagementClientID: "machine-client", LifecycleStatus: "enabled", Version: 3, + IdempotencyKey: oldOwner, UpdatedAt: time.Now().UTC(), + }} + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: &memorySecretStore{}, config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + RetirementDuration: time.Hour, + }} + secret := []byte("replacement-machine-secret-value-000000") + defer clear(secret) + _, connectErr := manager.Connect(ctx, "https://auth.example/ssf", "machine-client", secret, currentOwner) + var categorized interface{ SafeErrorCategory() string } + if !errors.As(connectErr, &categorized) || categorized.SafeErrorCategory() != "connection_conflict" { + t.Fatalf("local-only retirement handoff error=%v", connectErr) + } + + if err := manager.RetireConflictingConnection(ctx, currentOwner); err != nil { + t.Fatal(err) + } + connection, err := repository.SecurityEventConnection(ctx) + if err != nil || connection.LifecycleStatus != "retiring" { + t.Fatalf("old owner was not safely retired: connection=%#v err=%v", connection, err) + } + + repository.connection.IdempotencyKey = currentOwner + repository.connection.LifecycleStatus = "enabled" + if err := manager.RetireConflictingConnection(ctx, currentOwner); err != nil { + t.Fatal(err) + } + connection, err = repository.SecurityEventConnection(ctx) + if err != nil || connection.LifecycleStatus != "enabled" { + t.Fatalf("current pairing connection was retired by a stale recovery action: connection=%#v err=%v", connection, err) + } +} + +func TestDisconnectDoesNotReturnSuccessWhenLifecycleCASFails(t *testing.T) { + streamID := uuid.NewString() + current := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "://invalid", StreamID: &streamID, + CredentialRef: "ssf-push", LifecycleStatus: "verifying", Version: 4, + } + repository := &memoryConnectionRepository{connection: current} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}, config: ConnectionManagerConfig{AppEnv: "test"}} + stale := *current + stale.LifecycleStatus = "enabled" + + if _, err := manager.disconnect(context.Background(), stale); !errors.Is(err, store.ErrSecurityEventConnectionConflict) { + t.Fatalf("disconnect CAS conflict was reported as success: %v", err) + } + connection, err := repository.SecurityEventConnection(context.Background()) + if err != nil || connection.LifecycleStatus != "verifying" { + t.Fatalf("disconnect CAS conflict changed lifecycle: connection=%#v err=%v", connection, err) + } +} + +func TestDisconnectTreatsRetirementStatesAsIdempotent(t *testing.T) { + for _, lifecycle := range []string{"disconnect_pending", "retiring"} { + t.Run(lifecycle, func(t *testing.T) { + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-disconnect-idempotent", + LifecycleStatus: lifecycle, Version: 9, + } + repository := &memoryConnectionRepository{connection: connection} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}} + + view, err := manager.disconnect(context.Background(), *connection) + if err != nil { + t.Fatal(err) + } + current, getErr := repository.SecurityEventConnection(context.Background()) + if getErr != nil || current.LifecycleStatus != lifecycle || current.Version != 9 || view.Version != 9 { + t.Fatalf("idempotent disconnect changed retirement generation: lifecycle=%q version=%d view=%d err=%v", + current.LifecycleStatus, current.Version, view.Version, getErr) + } + }) + } +} + func TestTransmitterSSRFProtectionBlocksReservedNetworks(t *testing.T) { for _, address := range []string{ "127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", @@ -268,10 +1059,445 @@ func TestRetiringConnectionStillAppliesRevocationWatermark(t *testing.T) { t.Fatalf("retiring evaluation=%#v err=%v", evaluation, err) } } + +func TestRetirementDurablyQueuesReferencesBeforeRemovingConnection(t *testing.T) { + ctx := context.Background() + connection := store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-retiring", LifecycleStatus: "retiring", + Version: 1, UpdatedAt: time.Now().Add(-time.Minute), + } + repository := &memoryConnectionRepository{connection: &connection} + secrets := &memorySecretStore{values: map[string][]byte{ + connection.CredentialRef: []byte("push-secret-value-for-retirement-00000"), + }} + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: ConnectionManagerConfig{RetirementDuration: time.Millisecond}} + + manager.finishRetirement(connection) + + if _, err := repository.SecurityEventConnection(context.Background()); !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + t.Fatalf("finalized retiring connection still exists: %v", err) + } + repository.mutex.Lock() + _, queued := repository.cleanupDue[connection.CredentialRef] + repository.mutex.Unlock() + if !queued { + t.Fatal("retirement removed the connection before durably queueing its Secret reference") + } + if !secrets.Has(connection.CredentialRef) { + t.Fatal("retirement bypassed the durable Secret cleanup worker") + } +} + +type retireDuringSecretReadStore struct { + once sync.Once + onRetire func() +} + +func (*retireDuringSecretReadStore) Put(context.Context, string, []byte) error { return nil } +func (s *retireDuringSecretReadStore) Get(context.Context, string) ([]byte, error) { + s.once.Do(s.onRetire) + return nil, ErrSecretNotFound +} +func (*retireDuringSecretReadStore) Delete(context.Context, string) error { return nil } + +func retireMemoryConnection(repository *memoryConnectionRepository, connectionID string) { + repository.mutex.Lock() + defer repository.mutex.Unlock() + if repository.connection == nil || repository.connection.ConnectionID != connectionID { + return + } + repository.connection.LifecycleStatus = "retiring" + repository.connection.Version++ + repository.connection.UpdatedAt = time.Now().UTC() +} + +func TestConnectionManagerConstructionFailureCannotReviveConcurrentRetirement(t *testing.T) { + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + streamID := uuid.NewString() + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + EndpointURL: "https://gateway.example/api/v1/security-events/ssf", Audience: &audience, StreamID: &streamID, + CredentialRef: "ssf-push-constructor-race", LifecycleStatus: "enabled", Version: 7, + } + initialVersion := connection.Version + repository := &memoryConnectionRepository{connection: connection} + secrets := &retireDuringSecretReadStore{onRetire: func() { + retireMemoryConnection(repository, connection.ConnectionID) + }} + + if _, err := NewConnectionManager(context.Background(), repository, secrets, ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + }, &Metrics{}); err != nil { + t.Fatal(err) + } + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.LifecycleStatus != "retiring" || current.Version != initialVersion+1 { + t.Fatalf("constructor failure revived concurrent retirement: lifecycle=%q version=%d err=%v", + current.LifecycleStatus, current.Version, err) + } +} + +func TestConnectionOperationFailureCannotReviveConcurrentRetirement(t *testing.T) { + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-operation-race", + LifecycleStatus: "verifying", Version: 11, + } + repository := &memoryConnectionRepository{connection: connection} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}} + staleOperationSnapshot := *connection + retireMemoryConnection(repository, connection.ConnectionID) + + _ = manager.connectionError(context.Background(), staleOperationSnapshot, "discovery_failed", errors.New("remote operation failed")) + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.LifecycleStatus != "retiring" || current.Version != staleOperationSnapshot.Version+1 { + t.Fatalf("operation failure revived concurrent retirement: lifecycle=%q version=%d err=%v", + current.LifecycleStatus, current.Version, err) + } +} + +func TestConnectionErrorCannotMoveCurrentRetirementGenerationBackToError(t *testing.T) { + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-current-retirement", + LifecycleStatus: "retiring", Version: 5, + } + repository := &memoryConnectionRepository{connection: connection} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: &memorySecretStore{}} + + _ = manager.connectionError(context.Background(), *connection, "discovery_failed", errors.New("late remote operation failed")) + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.LifecycleStatus != "retiring" || current.Version != connection.Version { + t.Fatalf("late operation failure revived current retirement generation: lifecycle=%q version=%d err=%v", + current.LifecycleStatus, current.Version, err) + } +} + +func TestFinishRetirementDoesNotDeleteAConnectionRevivedAfterItsSnapshot(t *testing.T) { + connection := store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-retirement-generation", + LifecycleStatus: "retiring", Version: 3, UpdatedAt: time.Now().Add(-time.Minute), + } + staleRetirementSnapshot := connection + connection.LifecycleStatus = "enabled" + connection.Version++ + repository := &memoryConnectionRepository{connection: &connection} + secrets := &memorySecretStore{values: map[string][]byte{ + connection.CredentialRef: []byte("push-secret-value-for-retirement-generation"), + }} + manager := &ConnectionManager{ctx: context.Background(), repository: repository, secrets: secrets, + config: ConnectionManagerConfig{RetirementDuration: time.Millisecond}} + + manager.finishRetirement(staleRetirementSnapshot) + + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.LifecycleStatus != "enabled" || current.Version != connection.Version { + t.Fatalf("stale retirement deleted or changed the current connection: lifecycle=%q version=%d err=%v", + current.LifecycleStatus, current.Version, err) + } + if !secrets.Has(connection.CredentialRef) { + t.Fatal("stale retirement deleted a Secret referenced by the current connection") + } +} + +func TestIdentitySecretCleanupWorkerResumesWithoutConnectionManager(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reference := "ssf-management-staged-restart" + repository := &memoryConnectionRepository{cleanupDue: map[string]time.Time{reference: time.Now().Add(-time.Minute)}} + secrets := &memorySecretStore{values: map[string][]byte{ + reference: []byte("staged-machine-secret-value-for-restart"), + }} + go RunIdentitySecretCleanupWorker(ctx, repository, secrets) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + repository.mutex.Lock() + _, queued := repository.cleanupDue[reference] + repository.mutex.Unlock() + secretExists := secrets.Has(reference) + if !queued && !secretExists { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("queued identity Secret cleanup did not resume after restart") +} + +func TestIdentitySecretCleanupWorkerKeepsClaimWhenDeleteIsUncertain(t *testing.T) { + reference := "ssf-management-worker-delete-uncertain" + repository := &memoryConnectionRepository{cleanupDue: map[string]time.Time{reference: time.Now().Add(-time.Minute)}} + secrets := &failedStagingSecretStore{deleteErr: errors.New("SecretStore delete result is uncertain")} + + cleanupClaimedIdentitySecrets(context.Background(), repository, secrets) + + repository.mutex.Lock() + _, pending := repository.cleanupDue[reference] + claimToken := repository.cleanupClaims[reference] + repository.mutex.Unlock() + if !pending || claimToken == "" { + t.Fatal("uncertain worker deletion released its durable claim") + } +} + +func TestConnectionManagerDoesNotStartIdentitySecretCleanupWorker(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reference := "ssf-management-manager-must-not-clean" + claimed := make(chan struct{}, 1) + repository := &memoryConnectionRepository{ + cleanupDue: map[string]time.Time{reference: time.Now().Add(-time.Minute)}, + cleanupClaimed: claimed, + } + if _, err := NewConnectionManager(ctx, repository, &memorySecretStore{}, ConnectionManagerConfig{ + OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + }, &Metrics{}); err != nil { + t.Fatal(err) + } + select { + case <-claimed: + t.Fatal("ConnectionManager started an identity Secret cleanup worker") + case <-time.After(50 * time.Millisecond): + } +} + +type failedStagingSecretStore struct { + deleteErr error + deletes atomic.Int64 +} + +func (*failedStagingSecretStore) Put(context.Context, string, []byte) error { + return errors.New("SecretStore write result is uncertain") +} +func (*failedStagingSecretStore) Get(context.Context, string) ([]byte, error) { + return nil, ErrSecretNotFound +} +func (s *failedStagingSecretStore) Delete(context.Context, string) error { + s.deletes.Add(1) + return s.deleteErr +} + +func TestStageIdentitySecretKeepsPendingCleanupWhenPutAndDeleteAreUncertain(t *testing.T) { + reference := "ssf-management-uncertain-put" + repository := &memoryConnectionRepository{} + secrets := &failedStagingSecretStore{deleteErr: errors.New("SecretStore delete result is uncertain")} + manager := &ConnectionManager{repository: repository, secrets: secrets} + + if err := manager.stageIdentitySecret(context.Background(), reference, []byte("machine-secret-value-for-uncertain-put")); err == nil { + t.Fatal("uncertain SecretStore Put unexpectedly succeeded") + } + if secrets.deletes.Load() != 1 { + t.Fatalf("cleanup delete attempts=%d want=1", secrets.deletes.Load()) + } + repository.mutex.Lock() + _, pending := repository.cleanupDue[reference] + repository.mutex.Unlock() + if !pending { + t.Fatal("uncertain SecretStore result lost the durable cleanup intent") + } +} + +func TestStageIdentitySecretRemovesPendingCleanupAfterConfirmedDelete(t *testing.T) { + reference := "ssf-management-confirmed-delete" + repository := &memoryConnectionRepository{} + secrets := &failedStagingSecretStore{} + manager := &ConnectionManager{repository: repository, secrets: secrets} + + if err := manager.stageIdentitySecret(context.Background(), reference, []byte("machine-secret-value-for-confirmed-delete")); err == nil { + t.Fatal("failed SecretStore Put unexpectedly succeeded") + } + repository.mutex.Lock() + _, pending := repository.cleanupDue[reference] + repository.mutex.Unlock() + if pending { + t.Fatal("confirmed Secret deletion retained a stale cleanup intent") + } +} + +type ambiguousAdoptionRepository struct { + *memoryConnectionRepository + failCreate bool + failManagement bool + failNext bool +} + +func (r *ambiguousAdoptionRepository) CreateSecurityEventConnection(ctx context.Context, input store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error) { + connection, err := r.memoryConnectionRepository.CreateSecurityEventConnection(ctx, input) + if err == nil && r.failCreate { + return store.SecurityEventConnection{}, errors.New("database commit result is uncertain") + } + return connection, err +} + +func (r *ambiguousAdoptionRepository) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string, expectedVersion int64) (store.SecurityEventConnection, error) { + connection, err := r.memoryConnectionRepository.SetSecurityEventManagementCredential(ctx, connectionID, clientID, reference, expectedVersion) + if err == nil && r.failManagement { + return store.SecurityEventConnection{}, errors.New("database commit result is uncertain") + } + return connection, err +} + +func (r *ambiguousAdoptionRepository) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string, expectedVersion int64) (store.SecurityEventConnection, error) { + connection, err := r.memoryConnectionRepository.SetSecurityEventNextCredential(ctx, connectionID, reference, expectedVersion) + if err == nil && r.failNext { + return store.SecurityEventConnection{}, errors.New("database commit result is uncertain") + } + return connection, err +} + +func TestConnectDoesNotRequeueSecretsAfterAmbiguousAdoptionCommit(t *testing.T) { + ctx := context.Background() + base := &memoryConnectionRepository{} + repository := &ambiguousAdoptionRepository{memoryConnectionRepository: base, failCreate: true} + secrets := &memorySecretStore{} + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: ConnectionManagerConfig{ + AppEnv: "test", OIDCEnabled: true, OIDCIssuer: "https://auth.example/issuer", OIDCTenantID: uuid.NewString(), + PublicBaseURL: "http://localhost:18089", + }} + managementSecret := []byte("machine-secret-value-for-ambiguous-create") + defer clear(managementSecret) + + if _, err := manager.Connect(ctx, "https://auth.example/ssf", "machine-client", managementSecret, "ambiguous-create"); err == nil { + t.Fatal("ambiguous connection commit unexpectedly reported success") + } + connection, err := base.SecurityEventConnection(ctx) + if err != nil || connection.ManagementCredentialRef == nil { + t.Fatalf("simulated committed connection was not retained: %v", err) + } + base.mutex.Lock() + _, pushQueued := base.cleanupDue[connection.CredentialRef] + _, managementQueued := base.cleanupDue[*connection.ManagementCredentialRef] + base.mutex.Unlock() + if pushQueued || managementQueued { + t.Fatal("ambiguous committed connection credentials were requeued for deletion") + } + if !secrets.Has(connection.CredentialRef) || !secrets.Has(*connection.ManagementCredentialRef) { + t.Fatal("ambiguous committed connection lost an active Secret") + } +} + +func TestManagementCredentialDoesNotRequeueSecretAfterAmbiguousAdoptionCommit(t *testing.T) { + ctx := context.Background() + oldReference := "ssf-management-before-ambiguous-commit" + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), CredentialRef: "ssf-push-active", ManagementCredentialRef: &oldReference, + ManagementClientID: "machine-client", LifecycleStatus: "enabled", + } + base := &memoryConnectionRepository{connection: connection} + repository := &ambiguousAdoptionRepository{memoryConnectionRepository: base, failManagement: true} + secrets := &memorySecretStore{values: map[string][]byte{oldReference: []byte("old-machine-secret-value-before-commit")}} + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets} + newSecret := []byte("new-machine-secret-value-after-commit") + defer clear(newSecret) + + if _, err := manager.persistManagementCredential(ctx, *connection, "machine-client", newSecret); err == nil { + t.Fatal("ambiguous management credential commit unexpectedly reported success") + } + committed, err := base.SecurityEventConnection(ctx) + if err != nil || committed.ManagementCredentialRef == nil || *committed.ManagementCredentialRef == oldReference { + t.Fatalf("simulated committed management credential was not retained: %v", err) + } + newReference := *committed.ManagementCredentialRef + base.mutex.Lock() + _, newQueued := base.cleanupDue[newReference] + _, oldQueued := base.cleanupDue[oldReference] + base.mutex.Unlock() + if newQueued || !oldQueued { + t.Fatal("ambiguous commit queued the active credential or lost retirement of the old credential") + } + if !secrets.Has(newReference) { + t.Fatal("ambiguous committed management credential lost its Secret") + } +} + +func TestNextCredentialDoesNotRequeueSecretAfterAmbiguousAdoptionCommit(t *testing.T) { + ctx := context.Background() + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + streamID := uuid.NewString() + connection := &store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), TransmitterIssuer: "https://auth.example/ssf", + EndpointURL: "https://gateway.example/api/v1/security-events/ssf", Audience: &audience, StreamID: &streamID, + CredentialRef: "ssf-push-active", LifecycleStatus: "enabled", + } + base := &memoryConnectionRepository{connection: connection} + repository := &ambiguousAdoptionRepository{memoryConnectionRepository: base, failNext: true} + secrets := &memorySecretStore{} + manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets} + + if _, err := manager.RotateCredential(ctx); err == nil { + t.Fatal("ambiguous next credential commit unexpectedly reported success") + } + committed, err := base.SecurityEventConnection(ctx) + if err != nil || committed.NextCredentialRef == nil { + t.Fatalf("simulated committed next credential was not retained: %v", err) + } + nextReference := *committed.NextCredentialRef + base.mutex.Lock() + _, queued := base.cleanupDue[nextReference] + base.mutex.Unlock() + if queued { + t.Fatal("ambiguous commit requeued the active next credential") + } + if !secrets.Has(nextReference) { + t.Fatal("ambiguous commit deleted the active next credential") + } +} + func (r *memoryConnectionRepository) PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error) { return store.SecurityEventConnection{}, errors.New("not used") } -func (r *memoryConnectionRepository) DeleteSecurityEventConnection(context.Context, string) error { +func (r *memoryConnectionRepository) DiscardPreparedSecurityEventConnection(_ context.Context, id, ownerKey string, expectedVersion int64) error { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil { + return store.ErrSecurityEventConnectionConflict + } + if r.beforeDiscard != nil { + r.beforeDiscard(r.connection) + } + if r.connection.ConnectionID != id || r.connection.IdempotencyKey != ownerKey || + r.connection.Version != expectedVersion || r.connection.StreamID != nil { + return store.ErrSecurityEventConnectionConflict + } + if r.cleanupDue == nil { + r.cleanupDue = map[string]time.Time{} + } + references := connectionSecretReferences(*r.connection) + for _, reference := range references { + if r.cleanupClaims[reference] != "" { + return store.ErrIdentitySecretCleanupConflict + } + } + now := time.Now().UTC() + for _, reference := range references { + r.cleanupDue[reference] = now + } + r.connection = nil + return nil +} +func (r *memoryConnectionRepository) FinalizeRetiringSecurityEventConnection(_ context.Context, id string, expectedVersion int64) error { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.connection == nil { + return store.ErrSecurityEventConnectionNotFound + } + if r.beforeFinalize != nil { + r.beforeFinalize(r.connection) + } + if r.connection.ConnectionID != id || r.connection.LifecycleStatus != "retiring" || r.connection.Version != expectedVersion { + return store.ErrSecurityEventConnectionConflict + } + if r.cleanupDue == nil { + r.cleanupDue = map[string]time.Time{} + } + for _, reference := range connectionSecretReferences(*r.connection) { + if r.cleanupClaims[reference] != "" { + return store.ErrIdentitySecretCleanupConflict + } + } + now := time.Now().UTC() + for _, reference := range connectionSecretReferences(*r.connection) { + r.cleanupDue[reference] = now + } + r.finalizeCalls++ r.connection = nil return nil } @@ -280,10 +1506,13 @@ func (r *memoryConnectionRepository) SecurityEventMetrics(context.Context, strin } type memorySecretStore struct { + mutex sync.Mutex values map[string][]byte } func (s *memorySecretStore) Put(_ context.Context, reference string, value []byte) error { + s.mutex.Lock() + defer s.mutex.Unlock() if s.values == nil { s.values = map[string][]byte{} } @@ -291,6 +1520,8 @@ func (s *memorySecretStore) Put(_ context.Context, reference string, value []byt return nil } func (s *memorySecretStore) Get(_ context.Context, reference string) ([]byte, error) { + s.mutex.Lock() + defer s.mutex.Unlock() value, ok := s.values[reference] if !ok { return nil, ErrSecretNotFound @@ -298,10 +1529,25 @@ func (s *memorySecretStore) Get(_ context.Context, reference string) ([]byte, er return append([]byte(nil), value...), nil } func (s *memorySecretStore) Delete(_ context.Context, reference string) error { + s.mutex.Lock() + defer s.mutex.Unlock() delete(s.values, reference) return nil } +func (s *memorySecretStore) Has(reference string) bool { + s.mutex.Lock() + defer s.mutex.Unlock() + _, ok := s.values[reference] + return ok +} + +func (s *memorySecretStore) Len() int { + s.mutex.Lock() + defer s.mutex.Unlock() + return len(s.values) +} + func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testing.T) { repository := &memoryConnectionRepository{} secrets := &memorySecretStore{} @@ -384,8 +1630,8 @@ func TestConnectionManagerCreatesPausedStreamWithBackendGeneratedBearer(t *testi if strings.Contains(string(encoded), "credential") || strings.Contains(string(encoded), "machine-secret-for-test") || strings.Contains(string(encoded), "ssf-push-") { t.Fatalf("secret metadata escaped in response: %s", encoded) } - if len(secrets.values) != 2 { - t.Fatalf("secret count=%d", len(secrets.values)) + if secrets.Len() != 2 { + t.Fatalf("secret count=%d", secrets.Len()) } restarted := &ConnectionManager{repository: repository, secrets: secrets, config: ConnectionManagerConfig{}} restartedClientID, restartedSecret, err := restarted.IntrospectionCredential(ctx) diff --git a/apps/api/internal/securityevents/identity_secret_cleanup_worker.go b/apps/api/internal/securityevents/identity_secret_cleanup_worker.go new file mode 100644 index 0000000..a67fa94 --- /dev/null +++ b/apps/api/internal/securityevents/identity_secret_cleanup_worker.go @@ -0,0 +1,52 @@ +package securityevents + +import ( + "context" + "errors" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +const ( + identitySecretCleanupInterval = time.Minute + identitySecretCleanupLease = 5 * time.Minute + identitySecretCleanupBatch = 100 +) + +type IdentitySecretCleanupRepository interface { + ClaimIdentitySecretCleanups(context.Context, int, time.Duration) ([]store.IdentitySecretCleanupClaim, error) + CompleteIdentitySecretCleanup(context.Context, string, string) (bool, error) +} + +// RunIdentitySecretCleanupWorker owns cleanup at the server lifecycle rather +// than at any individual Active or Prepared identity Runtime. PostgreSQL claim +// tokens make running one worker per server replica safe. +func RunIdentitySecretCleanupWorker(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) { + if repository == nil || secrets == nil { + return + } + ticker := time.NewTicker(identitySecretCleanupInterval) + defer ticker.Stop() + for { + cleanupClaimedIdentitySecrets(ctx, repository, secrets) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func cleanupClaimedIdentitySecrets(ctx context.Context, repository IdentitySecretCleanupRepository, secrets SecretStore) { + claims, err := repository.ClaimIdentitySecretCleanups(ctx, identitySecretCleanupBatch, identitySecretCleanupLease) + if err != nil { + return + } + for _, claim := range claims { + if err := secrets.Delete(ctx, claim.Reference); err != nil && !errors.Is(err, ErrSecretNotFound) { + continue + } + _, _ = repository.CompleteIdentitySecretCleanup(ctx, claim.Reference, claim.ClaimToken) + } +} diff --git a/apps/api/internal/securityevents/retirement_worker.go b/apps/api/internal/securityevents/retirement_worker.go new file mode 100644 index 0000000..fe142af --- /dev/null +++ b/apps/api/internal/securityevents/retirement_worker.go @@ -0,0 +1,79 @@ +package securityevents + +import ( + "context" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +const ( + defaultSecurityEventRetirementDuration = 6 * time.Minute + securityEventRetirementWorkerInterval = time.Minute +) + +type SecurityEventRetirementRepository interface { + SecurityEventConnection(context.Context) (store.SecurityEventConnection, error) + FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error +} + +// RunSecurityEventRetirementWorker owns the local end of SSF retirement at the +// server lifecycle. It intentionally does not share an Active identity +// Runtime's context, because that Runtime is closed before the RFC 8935 overlap +// period expires. Store-level generation CAS makes one worker per replica safe. +func RunSecurityEventRetirementWorker(ctx context.Context, repository SecurityEventRetirementRepository) { + runSecurityEventRetirementWorker( + ctx, + repository, + defaultSecurityEventRetirementDuration, + securityEventRetirementWorkerInterval, + ) +} + +func runSecurityEventRetirementWorker( + ctx context.Context, + repository SecurityEventRetirementRepository, + retirementDuration time.Duration, + interval time.Duration, +) { + if repository == nil { + return + } + if retirementDuration <= 0 { + retirementDuration = defaultSecurityEventRetirementDuration + } + if interval <= 0 { + interval = securityEventRetirementWorkerInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + finalizeDueSecurityEventRetirement(ctx, repository, retirementDuration) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func finalizeDueSecurityEventRetirement( + ctx context.Context, + repository SecurityEventRetirementRepository, + retirementDuration time.Duration, +) { + connection, err := repository.SecurityEventConnection(ctx) + if err != nil || connection.LifecycleStatus != "retiring" { + return + } + if retirementDuration <= 0 { + retirementDuration = defaultSecurityEventRetirementDuration + } + if time.Since(connection.UpdatedAt) < retirementDuration { + return + } + // Finalization atomically queues every Secret reference and removes only the + // exact retiring generation. A concurrent worker or changed generation is a + // benign CAS miss and will be observed on the next loop. + _ = repository.FinalizeRetiringSecurityEventConnection(ctx, connection.ConnectionID, connection.Version) +} diff --git a/apps/api/internal/securityevents/retirement_worker_test.go b/apps/api/internal/securityevents/retirement_worker_test.go new file mode 100644 index 0000000..df39085 --- /dev/null +++ b/apps/api/internal/securityevents/retirement_worker_test.go @@ -0,0 +1,178 @@ +package securityevents + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" + "github.com/google/uuid" +) + +func TestSecurityEventRetirementWorkerOutlivesCanceledRuntime(t *testing.T) { + connection := retiringWorkerTestConnection(time.Now().UTC()) + repository := &memoryConnectionRepository{connection: &connection} + secrets := &memorySecretStore{values: retirementWorkerTestSecrets(connection)} + + runtimeCtx, cancelRuntime := context.WithCancel(context.Background()) + manager := &ConnectionManager{ + ctx: runtimeCtx, repository: repository, secrets: secrets, + config: ConnectionManagerConfig{RetirementDuration: 40 * time.Millisecond}, + } + go manager.finishRetirement(connection) + cancelRuntime() + time.Sleep(10 * time.Millisecond) + if _, err := repository.SecurityEventConnection(context.Background()); err != nil { + t.Fatalf("runtime cancellation unexpectedly finalized retirement: %v", err) + } + + serverCtx, cancelServer := context.WithCancel(context.Background()) + defer cancelServer() + go runSecurityEventRetirementWorker(serverCtx, repository, 40*time.Millisecond, 5*time.Millisecond) + waitForRetirementFinalization(t, repository) + + for reference := range retirementWorkerTestSecrets(connection) { + repository.mutex.Lock() + _, queued := repository.cleanupDue[reference] + repository.mutex.Unlock() + if !queued { + t.Fatalf("retired Secret %q was not durably queued", reference) + } + if !secrets.Has(reference) { + t.Fatalf("retirement worker directly deleted Secret %q before the durable cleanup worker claimed it", reference) + } + } +} + +func TestSecurityEventRetirementWorkerResumesAfterServerRestart(t *testing.T) { + connection := retiringWorkerTestConnection(time.Now().UTC()) + repository := &memoryConnectionRepository{connection: &connection} + + firstCtx, stopFirst := context.WithCancel(context.Background()) + go runSecurityEventRetirementWorker(firstCtx, repository, 80*time.Millisecond, 5*time.Millisecond) + time.Sleep(15 * time.Millisecond) + stopFirst() + if _, err := repository.SecurityEventConnection(context.Background()); err != nil { + t.Fatalf("not-yet-due retirement disappeared before restart: %v", err) + } + + time.Sleep(75 * time.Millisecond) + secondCtx, stopSecond := context.WithCancel(context.Background()) + defer stopSecond() + go runSecurityEventRetirementWorker(secondCtx, repository, 80*time.Millisecond, 5*time.Millisecond) + waitForRetirementFinalization(t, repository) +} + +func TestConcurrentSecurityEventRetirementWorkersFinalizeOneGenerationOnce(t *testing.T) { + connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute)) + repository := &memoryConnectionRepository{connection: &connection} + + start := make(chan struct{}) + done := make(chan struct{}, 2) + for range 2 { + go func() { + <-start + finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond) + done <- struct{}{} + }() + } + close(start) + <-done + <-done + + if _, err := repository.SecurityEventConnection(context.Background()); !errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + t.Fatalf("current retiring generation still exists: %v", err) + } + repository.mutex.Lock() + finalizeCalls := repository.finalizeCalls + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if finalizeCalls != 1 { + t.Fatalf("retirement generation finalized %d times, want 1", finalizeCalls) + } + if queued != len(retirementWorkerTestSecrets(connection)) { + t.Fatalf("queued Secret references=%d want=%d", queued, len(retirementWorkerTestSecrets(connection))) + } +} + +func TestSecurityEventRetirementWorkerPreservesChangedGeneration(t *testing.T) { + connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute)) + initialVersion := connection.Version + repository := &memoryConnectionRepository{connection: &connection} + repository.beforeFinalize = func(current *store.SecurityEventConnection) { + current.LifecycleStatus = "enabled" + current.Version++ + } + + finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond) + + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.LifecycleStatus != "enabled" || current.Version != initialVersion+1 { + t.Fatalf("changed generation was not preserved: lifecycle=%q version=%d err=%v", current.LifecycleStatus, current.Version, err) + } + repository.mutex.Lock() + queued := len(repository.cleanupDue) + repository.mutex.Unlock() + if queued != 0 { + t.Fatalf("changed generation queued %d active Secret references", queued) + } +} + +func TestSecurityEventRetirementWorkerKeepsConnectionWhenCleanupQueueCannotAdoptReferences(t *testing.T) { + connection := retiringWorkerTestConnection(time.Now().UTC().Add(-time.Minute)) + repository := &memoryConnectionRepository{ + connection: &connection, + cleanupClaims: map[string]string{connection.CredentialRef: uuid.NewString()}, + } + + finalizeDueSecurityEventRetirement(context.Background(), repository, time.Millisecond) + + current, err := repository.SecurityEventConnection(context.Background()) + if err != nil || current.ConnectionID != connection.ConnectionID || current.LifecycleStatus != "retiring" { + t.Fatalf("failed durable handoff lost retiring connection: lifecycle=%q err=%v", current.LifecycleStatus, err) + } + repository.mutex.Lock() + finalizeCalls := repository.finalizeCalls + repository.mutex.Unlock() + if finalizeCalls != 0 { + t.Fatalf("failed durable handoff finalized generation %d times", finalizeCalls) + } +} + +func retiringWorkerTestConnection(updatedAt time.Time) store.SecurityEventConnection { + managementReference := "ssf-management-retirement-worker-" + uuid.NewString() + nextReference := "ssf-push-next-retirement-worker-" + uuid.NewString() + return store.SecurityEventConnection{ + ConnectionID: uuid.NewString(), + CredentialRef: "ssf-push-retirement-worker-" + uuid.NewString(), + NextCredentialRef: &nextReference, + ManagementCredentialRef: &managementReference, + LifecycleStatus: "retiring", + Version: 9, + UpdatedAt: updatedAt, + } +} + +func retirementWorkerTestSecrets(connection store.SecurityEventConnection) map[string][]byte { + secrets := map[string][]byte{connection.CredentialRef: []byte("retired-push-secret-value")} + if connection.NextCredentialRef != nil { + secrets[*connection.NextCredentialRef] = []byte("retired-next-push-secret-value") + } + if connection.ManagementCredentialRef != nil { + secrets[*connection.ManagementCredentialRef] = []byte("retired-management-secret-value") + } + return secrets +} + +func waitForRetirementFinalization(t *testing.T, repository *memoryConnectionRepository) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if _, err := repository.SecurityEventConnection(context.Background()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("retiring connection was not finalized by the server-lifecycle worker") +} diff --git a/apps/api/internal/store/identity_configuration_secret_cleanup_integration_test.go b/apps/api/internal/store/identity_configuration_secret_cleanup_integration_test.go new file mode 100644 index 0000000..aafb44e --- /dev/null +++ b/apps/api/internal/store/identity_configuration_secret_cleanup_integration_test.go @@ -0,0 +1,141 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/google/uuid" +) + +func TestDisableActiveIdentityRevisionQueuesSecretsAfterRuntimeRetirementAndClearsReferences(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + seedIdentityActivationPrerequisites(t, ctx, db, "default") + revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default") + machineReference := "identity-machine-disable-" + uuid.NewString() + sessionReference := "identity-session-disable-" + uuid.NewString() + if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions +SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`, + revisionID, machineReference, sessionReference); err != nil { + t.Fatalf("seed active identity Secret references: %v", err) + } + if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil { + t.Fatalf("seed prematurely due machine Secret cleanup: %v", err) + } + + disableStartedAt := time.Now().UTC() + disabled, err := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-secrets", "audit-disable-secrets") + if err != nil { + t.Fatalf("disable active identity revision: %v", err) + } + if disabled.State != identity.RevisionSuperseded || disabled.Version != 2 || + disabled.MachineCredentialRef != "" || disabled.SessionEncryptionKeyRef != "" { + t.Fatalf("disabled revision retained Secret references: %#v", disabled) + } + + persisted, err := db.IdentityConfigurationRevision(ctx, revisionID) + if err != nil { + t.Fatalf("read disabled identity revision: %v", err) + } + if persisted.State != identity.RevisionSuperseded || persisted.MachineCredentialRef != "" || + persisted.SessionEncryptionKeyRef != "" { + t.Fatalf("persisted disabled revision retained Secret references: %#v", persisted) + } + + type queuedSecret struct { + reference string + status string + notBefore time.Time + } + rows, err := db.pool.Query(ctx, `SELECT secret_ref,status,not_before +FROM gateway_identity_secret_cleanup_queue ORDER BY secret_ref`) + if err != nil { + t.Fatalf("read disabled identity Secret cleanup queue: %v", err) + } + defer rows.Close() + queued := make(map[string]queuedSecret, 2) + for rows.Next() { + var secret queuedSecret + if err := rows.Scan(&secret.reference, &secret.status, &secret.notBefore); err != nil { + t.Fatalf("scan disabled identity Secret cleanup: %v", err) + } + queued[secret.reference] = secret + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate disabled identity Secret cleanup queue: %v", err) + } + if len(queued) != 2 { + t.Fatalf("disabled identity cleanup queue entries=%v, want both Secret references", queued) + } + minimumSafeCleanup := disableStartedAt.Add(45 * time.Second) + for _, reference := range []string{machineReference, sessionReference} { + secret, ok := queued[reference] + if !ok || secret.status != "pending" || secret.notBefore.Before(minimumSafeCleanup) { + t.Fatalf("queued Secret %q=%#v present=%t, want pending cleanup no earlier than %s", + reference, secret, ok, minimumSafeCleanup) + } + } + claims, err := db.ClaimIdentitySecretCleanups(ctx, 10, time.Minute) + if err != nil { + t.Fatalf("claim not-yet-due disabled identity Secrets: %v", err) + } + if len(claims) != 0 { + t.Fatalf("disabled identity Secrets became claimable before Runtime retirement: %#v", claims) + } +} + +func TestDisableActiveIdentityRevisionCleanupConflictRollsBackStateReferencesAndQueue(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + seedIdentityActivationPrerequisites(t, ctx, db, "default") + revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default") + machineReference := "identity-machine-disable-conflict-" + uuid.NewString() + sessionReference := "identity-session-disable-conflict-" + uuid.NewString() + if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_configuration_revisions +SET machine_credential_ref=$2,session_encryption_key_ref=$3 WHERE id=$1::uuid`, + revisionID, machineReference, sessionReference); err != nil { + t.Fatalf("seed conflicting active identity Secret references: %v", err) + } + if err := db.QueueIdentitySecretCleanup(ctx, machineReference, time.Now().Add(-time.Minute)); err != nil { + t.Fatalf("queue machine Secret before conflict claim: %v", err) + } + claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute) + if err != nil || len(claims) != 1 || claims[0].Reference != machineReference { + t.Fatalf("claim machine Secret before disable: claims=%#v error=%v", claims, err) + } + + _, err = db.DisableActiveIdentityRevision(ctx, 1, "trace-disable-conflict", "audit-disable-conflict") + if !errors.Is(err, ErrIdentitySecretCleanupConflict) { + t.Fatalf("disable with claimed Secret cleanup error=%v, want cleanup conflict", err) + } + persisted, err := db.IdentityConfigurationRevision(ctx, revisionID) + if err != nil { + t.Fatalf("read active identity revision after cleanup conflict: %v", err) + } + if persisted.State != identity.RevisionActive || persisted.Version != 1 || + persisted.MachineCredentialRef != machineReference || persisted.SessionEncryptionKeyRef != sessionReference { + t.Fatalf("cleanup conflict partially disabled identity revision: %#v", persisted) + } + + var status, claimToken string + if err := db.pool.QueryRow(ctx, `SELECT status,claim_token::text FROM gateway_identity_secret_cleanup_queue +WHERE secret_ref=$1`, machineReference).Scan(&status, &claimToken); err != nil { + t.Fatalf("read claimed machine Secret after disable rollback: %v", err) + } + if status != "claimed" || claimToken != claims[0].ClaimToken { + t.Fatalf("disable rollback changed claimed cleanup status=%q token=%q", status, claimToken) + } + var sessionQueueEntries int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_secret_cleanup_queue +WHERE secret_ref=$1`, sessionReference).Scan(&sessionQueueEntries); err != nil { + t.Fatalf("count session Secret cleanup after disable rollback: %v", err) + } + if sessionQueueEntries != 0 { + t.Fatalf("disable cleanup conflict left %d partial session cleanup entries", sessionQueueEntries) + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go index ec52884..2ef31ad 100644 --- a/apps/api/internal/store/identity_configurations.go +++ b/apps/api/internal/store/identity_configurations.go @@ -29,6 +29,55 @@ COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),sess session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''), validated_at,activated_at,superseded_at,created_at,updated_at` +// identityConfigurationLifecycleLockID serializes the database-side boundary +// between onboarding reservations and Active Revision mutations. A transaction +// must hold this lock before deciding that no Active configuration or foreign +// pairing reservation exists. +const identityConfigurationLifecycleLockID int64 = 0x4541494944454e54 + +// identityDisabledSecretCleanupDelay is intentionally longer than the +// IdentityRuntimeManager's 30-second old-Runtime retirement window. Starting +// cleanup one minute after the database handoff leaves an additional 30 +// seconds for commit, Runtime publication, timer scheduling, and worker jitter. +const identityDisabledSecretCleanupDelay = time.Minute + +func lockIdentityConfigurationLifecycle(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, identityConfigurationLifecycleLockID) + return err +} + +// beginIdentityConfigurationLifecycleTx deliberately uses READ COMMITTED. +// PostgreSQL fixes the transaction snapshot when a SERIALIZABLE transaction +// executes the advisory-lock SELECT, before that statement finishes waiting. +// A later state check can therefore miss the preceding lock holder's commit. +// READ COMMITTED gives every statement after lock acquisition a fresh snapshot, +// while the transaction-scoped advisory lock serializes all lifecycle decisions. +func (s *Store) beginIdentityConfigurationLifecycleTx(ctx context.Context) (pgx.Tx, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return nil, err + } + if err := lockIdentityConfigurationLifecycle(ctx, tx); err != nil { + _ = tx.Rollback(ctx) + return nil, err + } + return tx, nil +} + +func queueDisabledIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error { + tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) +VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE +SET not_before=GREATEST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now() +WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrIdentitySecretCleanupConflict + } + return nil +} + func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) { scopes, _ := json.Marshal(revision.Scopes) capabilities, _ := json.Marshal(revision.Capabilities) @@ -58,7 +107,16 @@ FROM gateway_identity_configuration_revisions WHERE state='active'`)) func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` -FROM gateway_identity_configuration_revisions WHERE state IN ('draft','validated','failed') ORDER BY created_at DESC LIMIT 1`)) +FROM gateway_identity_configuration_revisions +WHERE id=( + SELECT revision.id + FROM gateway_identity_configuration_revisions revision + JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id + WHERE revision.state IN ('draft','validated','failed') + ORDER BY NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') DESC, + revision.created_at DESC,exchange.created_at DESC + LIMIT 1 +)`)) return revision, normalizeIdentityRevisionError(err) } @@ -108,10 +166,16 @@ VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, } func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) { - current, err := s.IdentityConfigurationRevision(ctx, id) + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) if err != nil { return identity.Revision{}, err } + defer tx.Rollback(ctx) + current, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, id)) + if err != nil { + return identity.Revision{}, normalizeIdentityRevisionError(err) + } if current.Version != expectedVersion { return identity.Revision{}, identity.ErrRevisionConflict } @@ -119,9 +183,20 @@ func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVe if err != nil { return identity.Revision{}, err } + newReferences := make([]string, 0, 2) + for _, reference := range []string{updated.MachineCredentialRef, updated.SessionEncryptionKeyRef} { + if reference != "" { + newReferences = append(newReferences, reference) + } + } + if len(newReferences) > 0 { + if err := adoptPendingIdentitySecrets(ctx, tx, newReferences...); err != nil { + return identity.Revision{}, err + } + } scopes, _ := json.Marshal(updated.Scopes) capabilities, _ := json.Marshal(updated.Capabilities) - revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` + revision, err := scanIdentityRevision(tx.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''), scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12, @@ -138,14 +213,35 @@ RETURNING `+identityRevisionColumns, if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } - return revision, err + if err != nil { + return identity.Revision{}, err + } + activeReferences := map[string]struct{}{} + for _, reference := range newReferences { + activeReferences[reference] = struct{}{} + } + for _, reference := range []string{current.MachineCredentialRef, current.SessionEncryptionKeyRef} { + if reference == "" { + continue + } + if _, stillActive := activeReferences[reference]; stillActive { + continue + } + if err := queueIdentitySecretCleanupTx(ctx, tx, reference, time.Now().UTC()); err != nil { + return identity.Revision{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return identity.Revision{}, err + } + return revision, nil } func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL, last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() -WHERE id=$1::uuid AND version=$2 AND state IN ('draft','superseded') +WHERE id=$1::uuid AND version=$2 AND state='draft' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict @@ -178,11 +274,22 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, aud } func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) if err != nil { return identity.Revision{}, false, err } defer tx.Rollback(ctx) + var foreignPairingReservation bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway_identity_pairing_start_reservation + WHERE (state='starting' AND expires_at > now()) + OR (state='paired' AND revision_id IS DISTINCT FROM $1::uuid) +)`, id).Scan(&foreignPairingReservation); err != nil { + return identity.Revision{}, false, err + } + if foreignPairingReservation { + return identity.Revision{}, false, identity.ErrPairingInProgress + } if ok, err := hasBreakGlassManager(ctx, tx); err != nil { return identity.Revision{}, false, err } else if !ok { @@ -197,9 +304,16 @@ SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$ return identity.Revision{}, false, identity.ErrLocalTenantInvalid } var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string - _ = tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), + if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions -WHERE state='active' FOR UPDATE`).Scan(&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef) +WHERE state='active' FOR UPDATE`).Scan( + &previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef, + ); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, false, err + } + if previousID != "" && previousID != id { + return identity.Revision{}, false, identity.ErrActiveConfigurationHandoffRequired + } var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions @@ -228,6 +342,9 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis return identity.Revision{}, false, err } } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, id); err != nil { + return identity.Revision{}, false, err + } if err := tx.Commit(ctx); err != nil { return identity.Revision{}, false, err } @@ -235,7 +352,7 @@ WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevis } func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { - tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) if err != nil { return identity.Revision{}, err } @@ -245,9 +362,33 @@ func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersi } else if !ok { return identity.Revision{}, identity.ErrBreakGlassRequired } + var activeID, machineReference, sessionReference string + if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,'') +FROM gateway_identity_configuration_revisions +WHERE state='active' AND version=$1 FOR UPDATE`, expectedVersion).Scan( + &activeID, &machineReference, &sessionReference, + ); errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } else if err != nil { + return identity.Revision{}, err + } + cleanupNotBefore := time.Now().UTC().Add(identityDisabledSecretCleanupDelay) + references := make(map[string]struct{}, 2) + for _, reference := range []string{machineReference, sessionReference} { + if reference != "" { + references[reference] = struct{}{} + } + } + for reference := range references { + if err := queueDisabledIdentitySecretCleanupTx(ctx, tx, reference, cleanupNotBefore); err != nil { + return identity.Revision{}, err + } + } revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded', -superseded_at=now(),last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),version=version+1,updated_at=now() -WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion, traceID, auditID)) +superseded_at=now(),machine_credential_ref=NULL,session_encryption_key_ref=NULL, +last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND state='active' AND version=$2 +RETURNING `+identityRevisionColumns, activeID, expectedVersion, traceID, auditID)) if errors.Is(err, pgx.ErrNoRows) { return identity.Revision{}, identity.ErrRevisionConflict } diff --git a/apps/api/internal/store/identity_pairing.go b/apps/api/internal/store/identity_pairing.go index 6930ebc..d5de66d 100644 --- a/apps/api/internal/store/identity_pairing.go +++ b/apps/api/internal/store/identity_pairing.go @@ -2,24 +2,156 @@ package store import ( "context" + "encoding/json" "errors" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" "github.com/jackc/pgx/v5" ) const identityPairingColumns = ` -id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,remote_version,expires_at,version, -COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),created_at,updated_at` +id::text,revision_id::text,remote_exchange_id::text,exchange_token_ref,status,cleanup_status,remote_version,expires_at,version, +COALESCE(last_error_category,''),COALESCE(auth_center_audit_id,''),COALESCE(last_trace_id,''),cancelled_at,cleanup_completed_at, +created_at,updated_at` + +func (s *Store) ReserveIdentityPairingStart(ctx context.Context, attemptID string, expiresAt time.Time) error { + tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation +WHERE state='starting' AND expires_at <= now()`); err != nil { + return err + } + var activeConfigurationExists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway_identity_configuration_revisions + WHERE state='active' +)`).Scan(&activeConfigurationExists); err != nil { + return err + } + if activeConfigurationExists { + return identity.ErrActiveConfigurationHandoffRequired + } + var blocked bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway_identity_configuration_revisions revision + JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id + WHERE revision.state IN ('draft','validated','failed') + AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') +)`).Scan(&blocked); err != nil { + return err + } + if blocked { + return identity.ErrPairingInProgress + } + if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,expires_at) +VALUES(true,$1::uuid,'starting',$2)`, attemptID, expiresAt); err != nil { + if isUniqueViolation(err) { + return identity.ErrPairingInProgress + } + return err + } + return tx.Commit(ctx) +} + +func (s *Store) ReleaseIdentityPairingStart(ctx context.Context, attemptID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation +WHERE attempt_id=$1::uuid AND state='starting'`, attemptID) + return err +} + +func (s *Store) CommitIdentityPairingStart(ctx context.Context, revision identity.Revision, exchange identity.PairingExchange) (identity.PairingExchange, error) { + tx, err := s.beginIdentityConfigurationLifecycleTx(ctx) + if err != nil { + return identity.PairingExchange{}, err + } + defer tx.Rollback(ctx) + var reserved bool + if err := tx.QueryRow(ctx, `SELECT true FROM gateway_identity_pairing_start_reservation +WHERE singleton=true AND attempt_id=$1::uuid AND state='starting' FOR UPDATE`, exchange.ID).Scan(&reserved); errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrPairingInProgress + } else if err != nil { + return identity.PairingExchange{}, err + } + if !reserved { + return identity.PairingExchange{}, identity.ErrPairingInProgress + } + var activeConfigurationExists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway_identity_configuration_revisions WHERE state='active' +)`).Scan(&activeConfigurationExists); err != nil { + return identity.PairingExchange{}, err + } + if activeConfigurationExists { + return identity.PairingExchange{}, identity.ErrActiveConfigurationHandoffRequired + } + if err := adoptPendingIdentitySecrets(ctx, tx, exchange.ExchangeTokenRef); err != nil { + return identity.PairingExchange{}, err + } + scopes, _ := json.Marshal(revision.Scopes) + capabilities, _ := json.Marshal(revision.Capabilities) + if _, err := tx.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( + id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, + jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds,last_trace_id +) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15,NULLIF($16,''))`, + revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix, + revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled, + string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, + revision.SessionRefreshSeconds, revision.LastTraceID, + ); err != nil { + return identity.PairingExchange{}, err + } + created, err := scanIdentityPairing(tx.QueryRow(ctx, `INSERT INTO gateway_identity_onboarding_exchanges ( + id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,'')) +RETURNING `+identityPairingColumns, + exchange.ID, revision.ID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef, + exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, + )) + if err != nil { + return identity.PairingExchange{}, err + } + tag, err := tx.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation +SET state='paired',revision_id=$2::uuid,expires_at=$3,updated_at=now() +WHERE attempt_id=$1::uuid AND state='starting'`, exchange.ID, revision.ID, exchange.ExpiresAt) + if err != nil { + return identity.PairingExchange{}, err + } + if tag.RowsAffected() != 1 { + return identity.PairingExchange{}, identity.ErrPairingInProgress + } + if err := tx.Commit(ctx); err != nil { + return identity.PairingExchange{}, err + } + return created, nil +} + +func (s *Store) IdentityPairingStartBlocked(ctx context.Context) (bool, error) { + var blocked bool + err := s.pool.QueryRow(ctx, ` +SELECT EXISTS( + SELECT 1 FROM gateway_identity_configuration_revisions revision + JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id + WHERE revision.state IN ('draft','validated','failed') + AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') + ) OR EXISTS ( + SELECT 1 FROM gateway_identity_pairing_start_reservation + WHERE state='paired' OR expires_at > now() +)`).Scan(&blocked) + return blocked, err +} func (s *Store) CreateIdentityPairingExchange(ctx context.Context, exchange identity.PairingExchange) (identity.PairingExchange, error) { return scanIdentityPairing(s.pool.QueryRow(ctx, ` INSERT INTO gateway_identity_onboarding_exchanges ( - id,revision_id,remote_exchange_id,exchange_token_ref,status,remote_version,expires_at,last_trace_id -) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,NULLIF($8,'')) + id,revision_id,remote_exchange_id,exchange_token_ref,status,cleanup_status,remote_version,expires_at,last_trace_id +) VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,NULLIF($9,'')) RETURNING `+identityPairingColumns, exchange.ID, exchange.RevisionID, exchange.RemoteExchangeID, exchange.ExchangeTokenRef, - exchange.Status, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, + exchange.Status, exchange.CleanupStatus, exchange.RemoteVersion, exchange.ExpiresAt, exchange.LastTraceID, )) } @@ -43,8 +175,67 @@ FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revision func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) { rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges exchange +WHERE exchange.id=( + SELECT candidate_exchange.id + FROM gateway_identity_onboarding_exchanges candidate_exchange + JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id + WHERE revision.state IN ('draft','validated','failed') + ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC, + revision.created_at DESC,candidate_exchange.created_at DESC + LIMIT 1 +) +AND exchange.status IN ('metadata_pending','preparing','ready','credentials_saved')`) + if err != nil { + return nil, err + } + defer rows.Close() + exchanges := make([]identity.PairingExchange, 0) + for rows.Next() { + exchange, scanErr := scanIdentityPairing(rows) + if scanErr != nil { + return nil, scanErr + } + exchanges = append(exchanges, exchange) + } + return exchanges, rows.Err() +} + +func (s *Store) PendingIdentityPairingCleanups(ctx context.Context) ([]identity.PairingExchange, error) { + rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` FROM gateway_identity_onboarding_exchanges -WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORDER BY created_at`) +WHERE status='cancelled' AND cleanup_status='pending' ORDER BY updated_at`) + if err != nil { + return nil, err + } + defer rows.Close() + exchanges := make([]identity.PairingExchange, 0) + for rows.Next() { + exchange, scanErr := scanIdentityPairing(rows) + if scanErr != nil { + return nil, scanErr + } + exchanges = append(exchanges, exchange) + } + return exchanges, rows.Err() +} + +func (s *Store) CompletedIdentityPairingsAwaitingActivation(ctx context.Context) ([]identity.PairingExchange, error) { + rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges exchange +WHERE exchange.id=( + SELECT candidate_exchange.id + FROM gateway_identity_onboarding_exchanges candidate_exchange + JOIN gateway_identity_configuration_revisions revision ON revision.id=candidate_exchange.revision_id + WHERE revision.state IN ('draft','validated','failed') + ORDER BY NOT (candidate_exchange.status='cancelled' AND candidate_exchange.cleanup_status='completed') DESC, + revision.created_at DESC,candidate_exchange.created_at DESC + LIMIT 1 +) +AND exchange.status='completed' AND EXISTS ( + SELECT 1 FROM gateway_identity_configuration_revisions revision + WHERE revision.id=exchange.revision_id AND revision.state IN ('draft','validated') +)`) if err != nil { return nil, err } @@ -61,13 +252,139 @@ WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORD } func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) { - exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, ` + tx, err := s.pool.Begin(ctx) + if err != nil { + return identity.PairingExchange{}, err + } + defer tx.Rollback(ctx) + exchange, err := scanIdentityPairing(tx.QueryRow(ctx, ` UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''), auth_center_audit_id=COALESCE(NULLIF($6,''),auth_center_audit_id),version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 RETURNING `+identityPairingColumns, id, expectedVersion, update.Status, update.RemoteVersion, update.LastErrorCategory, update.AuthCenterAuditID, )) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + if err != nil { + return identity.PairingExchange{}, err + } + if update.Status == identity.PairingCompleted || update.Status == identity.PairingFailed || update.Status == identity.PairingExpired { + if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil { + return identity.PairingExchange{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return identity.PairingExchange{}, err + } + return exchange, nil +} + +func (s *Store) RecordIdentityPairingRetryFailure(ctx context.Context, id string, status identity.PairingStatus, category string) (identity.PairingExchange, error) { + exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_onboarding_exchanges SET last_error_category=$3,updated_at=now() +WHERE id=$1::uuid AND status=$2 AND cleanup_status='none' +RETURNING `+identityPairingColumns, id, status, category)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + return exchange, err +} + +func (s *Store) CancelIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.PairingExchange, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.PairingExchange{}, err + } + defer tx.Rollback(ctx) + exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `SELECT `+identityPairingColumns+` +FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid FOR UPDATE`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionNotFound + } + if err != nil { + return identity.PairingExchange{}, err + } + if exchange.Status == identity.PairingCancelled { + return exchange, tx.Commit(ctx) + } + if exchange.Version != expectedVersion { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + revision, err := scanIdentityRevision(tx.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE id=$1::uuid FOR UPDATE`, exchange.RevisionID)) + if err != nil { + return identity.PairingExchange{}, normalizeIdentityRevisionError(err) + } + if revision.State != identity.RevisionDraft && revision.State != identity.RevisionValidated && revision.State != identity.RevisionFailed { + return identity.PairingExchange{}, identity.ErrPairingNotCancellable + } + if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='failed', +last_error_category='pairing_cancelled',last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''), +version=version+1,updated_at=now() WHERE id=$1::uuid`, exchange.RevisionID, traceID, auditID); err != nil { + return identity.PairingExchange{}, err + } + exchange, err = scanIdentityPairing(tx.QueryRow(ctx, ` +UPDATE gateway_identity_onboarding_exchanges SET status='cancelled',cleanup_status='pending',cancelled_at=now(), +cleanup_completed_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 +RETURNING `+identityPairingColumns, id, expectedVersion)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + if err != nil { + return identity.PairingExchange{}, err + } + if err := queueIdentitySecretCleanupTx(ctx, tx, exchange.ExchangeTokenRef, time.Now().UTC()); err != nil { + return identity.PairingExchange{}, err + } + if err := tx.Commit(ctx); err != nil { + return identity.PairingExchange{}, err + } + return exchange, nil +} + +func (s *Store) CompleteIdentityPairingCleanup(ctx context.Context, id string, expectedVersion int64) (identity.PairingExchange, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.PairingExchange{}, err + } + defer tx.Rollback(ctx) + var revisionID string + if err := tx.QueryRow(ctx, `SELECT revision_id::text FROM gateway_identity_onboarding_exchanges +WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' FOR UPDATE`, id, expectedVersion).Scan(&revisionID); errors.Is(err, pgx.ErrNoRows) { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } else if err != nil { + return identity.PairingExchange{}, err + } + tag, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET machine_credential_ref=NULL, + session_encryption_key_ref=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND state='failed'`, revisionID) + if err != nil { + return identity.PairingExchange{}, err + } + if tag.RowsAffected() != 1 { + return identity.PairingExchange{}, identity.ErrRevisionConflict + } + exchange, err := scanIdentityPairing(tx.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET cleanup_status='completed',cleanup_completed_at=now(),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND status='cancelled' AND cleanup_status='pending' +RETURNING `+identityPairingColumns, id, expectedVersion)) + if err != nil { + return identity.PairingExchange{}, err + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_identity_pairing_start_reservation WHERE revision_id=$1::uuid`, revisionID); err != nil { + return identity.PairingExchange{}, err + } + if err := tx.Commit(ctx); err != nil { + return identity.PairingExchange{}, err + } + return exchange, nil +} + +func (s *Store) RecordIdentityPairingCleanupFailure(ctx context.Context, id, category string) (identity.PairingExchange, error) { + exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `UPDATE gateway_identity_onboarding_exchanges +SET last_error_category=$2,updated_at=now() WHERE id=$1::uuid AND status='cancelled' AND cleanup_status='pending' +RETURNING `+identityPairingColumns, id, category)) if errors.Is(err, pgx.ErrNoRows) { return identity.PairingExchange{}, identity.ErrRevisionConflict } @@ -78,9 +395,10 @@ func scanIdentityPairing(row scanner) (identity.PairingExchange, error) { var exchange identity.PairingExchange var status string if err := row.Scan( - &exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, + &exchange.ID, &exchange.RevisionID, &exchange.RemoteExchangeID, &exchange.ExchangeTokenRef, &status, &exchange.CleanupStatus, &exchange.RemoteVersion, &exchange.ExpiresAt, &exchange.Version, &exchange.LastErrorCategory, - &exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CreatedAt, &exchange.UpdatedAt, + &exchange.AuthCenterAuditID, &exchange.LastTraceID, &exchange.CancelledAt, &exchange.CleanupCompletedAt, + &exchange.CreatedAt, &exchange.UpdatedAt, ); err != nil { return identity.PairingExchange{}, err } diff --git a/apps/api/internal/store/identity_pairing_integration_test.go b/apps/api/internal/store/identity_pairing_integration_test.go new file mode 100644 index 0000000..976cfb2 --- /dev/null +++ b/apps/api/internal/store/identity_pairing_integration_test.go @@ -0,0 +1,715 @@ +package store + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestIdentityPairingCancellationKeepsOlderInactivePairingBlockedUntilEveryCleanupCompletes(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx := context.Background() + + type pairingFixture struct { + revisionID string + pairingID string + machineRef string + sessionRef string + createdAt time.Time + displayName string + } + now := time.Now().UTC() + fixtures := []pairingFixture{ + { + revisionID: uuid.NewString(), pairingID: uuid.NewString(), + machineRef: "identity-machine-older", sessionRef: "identity-session-older", + createdAt: now.Add(-time.Minute), displayName: "older", + }, + { + revisionID: uuid.NewString(), pairingID: uuid.NewString(), + machineRef: "identity-machine-latest", sessionRef: "identity-session-latest", + createdAt: now, displayName: "latest", + }, + } + + for _, fixture := range fixtures { + if _, err := db.pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url, + machine_credential_ref,session_encryption_key_ref,created_at,updated_at +) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example', + 'https://gateway-web.test.example',$2,$3,$4,$4)`, + fixture.revisionID, fixture.machineRef, fixture.sessionRef, fixture.createdAt); err != nil { + t.Fatalf("seed %s inactive revision: %v", fixture.displayName, err) + } + if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{ + ID: fixture.pairingID, RevisionID: fixture.revisionID, RemoteExchangeID: uuid.NewString(), + ExchangeTokenRef: "identity-exchange-" + fixture.pairingID, + Status: identity.PairingCredentialsSaved, CleanupStatus: identity.PairingCleanupNone, + RemoteVersion: 4, ExpiresAt: now.Add(time.Hour), LastTraceID: "trace-start-" + fixture.displayName, + }); err != nil { + t.Fatalf("seed %s pairing: %v", fixture.displayName, err) + } + } + orphanRevisionID := uuid.NewString() + if _, err := db.pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at +) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://orphan-api.test.example', + 'https://orphan-web.test.example',$2,$2)`, orphanRevisionID, now.Add(time.Minute)); err != nil { + t.Fatalf("seed orphan revision: %v", err) + } + canonical, err := db.LatestInactiveIdentityConfigurationRevision(ctx) + if err != nil || canonical.ID != fixtures[1].revisionID { + t.Fatalf("canonical inactive revision=%#v error=%v, want latest exchange-backed revision", canonical, err) + } + + blocked, err := db.IdentityPairingStartBlocked(ctx) + if err != nil || !blocked { + t.Fatalf("two inactive pairings blocked=%t error=%v, want blocked", blocked, err) + } + + latest := fixtures[1] + latestCancelled, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-cancel-latest", "audit-cancel-latest") + if err != nil { + t.Fatalf("cancel latest pairing: %v", err) + } + latestReplay, err := db.CancelIdentityPairingExchange(ctx, latest.pairingID, 1, "trace-replayed", "audit-replayed") + if err != nil { + t.Fatalf("replay latest cancellation with original version: %v", err) + } + if latestReplay.Status != identity.PairingCancelled || latestReplay.CleanupStatus != identity.PairingCleanupPending || + latestReplay.Version != latestCancelled.Version || latestCancelled.CancelledAt == nil || latestReplay.CancelledAt == nil || + !latestReplay.CancelledAt.Equal(*latestCancelled.CancelledAt) { + t.Fatalf("cancellation replay changed persisted intent: first=%#v replay=%#v", latestCancelled, latestReplay) + } + latestCleaned, err := db.CompleteIdentityPairingCleanup(ctx, latest.pairingID, latestCancelled.Version) + if err != nil { + t.Fatalf("complete latest cleanup: %v", err) + } + if latestCleaned.CleanupStatus != identity.PairingCleanupCompleted || latestCleaned.CleanupCompletedAt == nil { + t.Fatalf("latest cleanup state=%#v", latestCleaned) + } + assertIdentityRevisionSecretRefs(t, ctx, db, latest.revisionID, "", "") + assertIdentityRevisionSecretRefs(t, ctx, db, fixtures[0].revisionID, fixtures[0].machineRef, fixtures[0].sessionRef) + + blocked, err = db.IdentityPairingStartBlocked(ctx) + if err != nil || !blocked { + t.Fatalf("older pairing was ignored after latest cleanup: blocked=%t error=%v", blocked, err) + } + canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx) + if err != nil || canonical.ID != fixtures[0].revisionID { + t.Fatalf("canonical inactive revision after latest cleanup=%#v error=%v, want older blocker", canonical, err) + } + + older := fixtures[0] + olderCancelled, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-cancel-older", "audit-cancel-older") + if err != nil { + t.Fatalf("cancel older pairing: %v", err) + } + olderReplay, err := db.CancelIdentityPairingExchange(ctx, older.pairingID, 1, "trace-replayed", "audit-replayed") + if err != nil || olderReplay.Version != olderCancelled.Version { + t.Fatalf("replay older cancellation: replay=%#v error=%v", olderReplay, err) + } + if _, err := db.CompleteIdentityPairingCleanup(ctx, older.pairingID, olderCancelled.Version); err != nil { + t.Fatalf("complete older cleanup: %v", err) + } + assertIdentityRevisionSecretRefs(t, ctx, db, older.revisionID, "", "") + + blocked, err = db.IdentityPairingStartBlocked(ctx) + if err != nil || blocked { + t.Fatalf("all inactive pairing cleanup blocked=%t error=%v, want unblocked", blocked, err) + } + canonical, err = db.LatestInactiveIdentityConfigurationRevision(ctx) + if err != nil || canonical.ID != fixtures[1].revisionID || canonical.ID == orphanRevisionID { + t.Fatalf("canonical cleaned revision=%#v error=%v, orphan draft must stay hidden", canonical, err) + } +} + +func TestIdentityPairingStartReservationSerializesAndRejectsStaleCommit(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx := context.Background() + firstAttempt := uuid.NewString() + secondAttempt := uuid.NewString() + + if err := db.ReserveIdentityPairingStart(ctx, firstAttempt, time.Now().Add(time.Minute)); err != nil { + t.Fatalf("reserve first pairing start: %v", err) + } + if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); !errors.Is(err, identity.ErrPairingInProgress) { + t.Fatalf("second pairing reservation error=%v, want in-progress conflict", err) + } + if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_pairing_start_reservation SET expires_at=now()-interval '1 second' +WHERE attempt_id=$1::uuid`, firstAttempt); err != nil { + t.Fatalf("expire first pairing reservation: %v", err) + } + if err := db.ReserveIdentityPairingStart(ctx, secondAttempt, time.Now().Add(time.Minute)); err != nil { + t.Fatalf("take over expired pairing reservation: %v", err) + } + staleRevision := identity.Revision{ + ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example", + RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example", + WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{}, + SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60, + } + if _, err := db.CommitIdentityPairingStart(ctx, staleRevision, identity.PairingExchange{ + ID: firstAttempt, RevisionID: staleRevision.ID, RemoteExchangeID: uuid.NewString(), + ExchangeTokenRef: "identity-exchange-" + firstAttempt, Status: identity.PairingMetadataPending, + CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute), + }); !errors.Is(err, identity.ErrPairingInProgress) { + t.Fatalf("stale start commit error=%v, want in-progress conflict", err) + } + validRevision := staleRevision + validRevision.ID = uuid.NewString() + tokenReference := "identity-exchange-" + secondAttempt + if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatalf("stage replacement exchange token reference: %v", err) + } + committed, err := db.CommitIdentityPairingStart(ctx, validRevision, identity.PairingExchange{ + ID: secondAttempt, RevisionID: validRevision.ID, RemoteExchangeID: uuid.NewString(), + ExchangeTokenRef: tokenReference, Status: identity.PairingMetadataPending, + CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 1, ExpiresAt: time.Now().Add(time.Minute), + }) + if err != nil { + t.Fatalf("commit replacement pairing: %v", err) + } + if err := db.ReleaseIdentityPairingStart(ctx, secondAttempt); err != nil { + t.Fatalf("release paired reservation: %v", err) + } + var reservationState string + if err := db.pool.QueryRow(ctx, `SELECT state FROM gateway_identity_pairing_start_reservation +WHERE attempt_id=$1::uuid`, secondAttempt).Scan(&reservationState); err != nil || reservationState != "paired" { + t.Fatalf("paired reservation state=%q error=%v", reservationState, err) + } + blocked, err := db.IdentityPairingStartBlocked(ctx) + if err != nil || !blocked { + t.Fatalf("committed pairing did not retain singleton reservation: blocked=%t error=%v", blocked, err) + } + cancelled, err := db.CancelIdentityPairingExchange(ctx, committed.ID, committed.Version, "trace-cancel", "audit-cancel") + if err != nil { + t.Fatalf("cancel replacement pairing: %v", err) + } + if _, err := db.CompleteIdentityPairingCleanup(ctx, committed.ID, cancelled.Version); err != nil { + t.Fatalf("complete replacement pairing cleanup: %v", err) + } + blocked, err = db.IdentityPairingStartBlocked(ctx) + if err != nil || blocked { + t.Fatalf("completed cleanup retained singleton reservation: blocked=%t error=%v", blocked, err) + } +} + +func TestIdentityPairingStartRejectsAnyActiveConfigurationBeforeReservation(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx := context.Background() + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'active','https://auth.test.example','default','https://gateway.test.example', + 'https://gateway-web.test.example')`, uuid.NewString()); err != nil { + t.Fatalf("seed active identity revision: %v", err) + } + + err := db.ReserveIdentityPairingStart(ctx, uuid.NewString(), time.Now().Add(time.Minute)) + if !errors.Is(err, identity.ErrActiveConfigurationHandoffRequired) { + t.Fatalf("active identity configuration reservation error=%v", err) + } + var reservations int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_identity_pairing_start_reservation`).Scan(&reservations); err != nil { + t.Fatal(err) + } + if reservations != 0 { + t.Fatalf("active credential guard left %d start reservations", reservations) + } +} + +func TestConcurrentIdentityPairingStartReservationsHaveSingleWinner(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + attempts := []string{uuid.NewString(), uuid.NewString()} + ready := make(chan struct{}, len(attempts)) + start := make(chan struct{}) + type reservationResult struct { + attemptID string + err error + } + results := make(chan reservationResult, len(attempts)) + for _, attemptID := range attempts { + go func() { + ready <- struct{}{} + <-start + results <- reservationResult{ + attemptID: attemptID, + err: db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)), + } + }() + } + for range attempts { + <-ready + } + close(start) + + var winner string + conflicts := 0 + for range attempts { + result := <-results + switch { + case result.err == nil: + if winner != "" { + t.Fatalf("multiple concurrent reservations succeeded: %s and %s", winner, result.attemptID) + } + winner = result.attemptID + case errors.Is(result.err, identity.ErrPairingInProgress): + conflicts++ + default: + t.Fatalf("reserve concurrent pairing start %s: %v", result.attemptID, result.err) + } + } + if winner == "" || conflicts != 1 { + t.Fatalf("concurrent reservation winner=%q conflicts=%d, want one winner and one conflict", winner, conflicts) + } + + var persistedAttemptID, state string + var reservations int + if err := db.pool.QueryRow(ctx, `SELECT count(*),COALESCE(max(attempt_id::text),''),COALESCE(max(state),'') +FROM gateway_identity_pairing_start_reservation`).Scan(&reservations, &persistedAttemptID, &state); err != nil { + t.Fatalf("read concurrent reservation result: %v", err) + } + if reservations != 1 || persistedAttemptID != winner || state != "starting" { + t.Fatalf("persisted reservations=%d attempt=%q state=%q, want winner %q in starting state", + reservations, persistedAttemptID, state, winner) + } +} + +func TestConcurrentIdentityActivationAndPairingReservationRemainMutuallyExclusive(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + seedIdentityActivationPrerequisites(t, ctx, db, "default") + revisionID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default") + attemptID := uuid.NewString() + + gate, err := db.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin identity lifecycle gate: %v", err) + } + defer gate.Rollback(context.Background()) + if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil { + t.Fatalf("lock identity lifecycle gate: %v", err) + } + + reserveResult := make(chan error, 1) + go func() { + reserveResult <- db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)) + }() + waitForIdentityLifecycleLockWaiters(t, ctx, db, 1) + + type activationResult struct { + revision identity.Revision + err error + } + activateResult := make(chan activationResult, 1) + go func() { + revision, _, activateErr := db.ActivateIdentityRevision(ctx, revisionID, 1, "trace-activate", "audit-activate") + activateResult <- activationResult{revision: revision, err: activateErr} + }() + waitForIdentityLifecycleLockWaiters(t, ctx, db, 2) + if err := gate.Commit(ctx); err != nil { + t.Fatalf("release identity lifecycle gate: %v", err) + } + + reserveErr := <-reserveResult + activated := <-activateResult + if reserveErr != nil { + t.Fatalf("first queued pairing reservation failed: %v", reserveErr) + } + + var activeRevisions, startingReservations int + if err := db.pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'), + (SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE state='starting')`).Scan( + &activeRevisions, &startingReservations, + ); err != nil { + t.Fatalf("read activation and reservation invariant: %v", err) + } + if !errors.Is(activated.err, identity.ErrPairingInProgress) || activeRevisions != 0 || startingReservations != 1 { + t.Fatalf("racing activation error=%v revision_state=%q left active=%d starting reservations=%d; "+ + "want pairing-in-progress, active=0, reservation=1", + activated.err, activated.revision.State, activeRevisions, startingReservations) + } +} + +func TestConcurrentIdentityPairingCommitRejectsActiveRevisionCommittedAheadOfLifecycleLock(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + attemptID := uuid.NewString() + if err := db.ReserveIdentityPairingStart(ctx, attemptID, time.Now().Add(5*time.Minute)); err != nil { + t.Fatalf("reserve pairing start before commit race: %v", err) + } + tokenReference := "identity-exchange-" + attemptID + if err := db.QueueIdentitySecretCleanup(ctx, tokenReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatalf("stage exchange token reference before commit race: %v", err) + } + draft := identity.Revision{ + ID: uuid.NewString(), State: identity.RevisionDraft, SchemaVersion: 1, AuthCenterURL: "https://auth.test.example", + RolePrefix: "gateway.", LocalTenantKey: "default", PublicBaseURL: "https://gateway.test.example", + WebBaseURL: "https://gateway-web.test.example", Scopes: []string{}, Capabilities: []string{}, + SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, SessionRefreshSeconds: 60, + } + exchange := identity.PairingExchange{ + ID: attemptID, RevisionID: draft.ID, RemoteExchangeID: uuid.NewString(), ExchangeTokenRef: tokenReference, + Status: identity.PairingMetadataPending, CleanupStatus: identity.PairingCleanupNone, + RemoteVersion: 1, ExpiresAt: time.Now().Add(5 * time.Minute), + } + + gate, err := db.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin identity lifecycle gate: %v", err) + } + defer gate.Rollback(context.Background()) + if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil { + t.Fatalf("lock identity lifecycle gate: %v", err) + } + + commitResult := make(chan error, 1) + go func() { + _, commitErr := db.CommitIdentityPairingStart(ctx, draft, exchange) + commitResult <- commitErr + }() + waitForIdentityLifecycleLockWaiters(t, ctx, db, 1) + activeID := uuid.NewString() + if _, err := gate.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'active','https://auth.test.example','default','https://active-gateway.test.example', + 'https://active-gateway-web.test.example')`, activeID); err != nil { + t.Fatalf("commit active revision ahead of pairing commit: %v", err) + } + if err := gate.Commit(ctx); err != nil { + t.Fatalf("release identity lifecycle gate with active revision: %v", err) + } + + commitErr := <-commitResult + if !errors.Is(commitErr, identity.ErrActiveConfigurationHandoffRequired) { + t.Fatalf("pairing commit after active revision error=%v, want active handoff required", commitErr) + } + var activeRevisions, draftRevisions, exchanges, startingReservations int + if err := db.pool.QueryRow(ctx, `SELECT + (SELECT count(*) FROM gateway_identity_configuration_revisions WHERE state='active'), + (SELECT count(*) FROM gateway_identity_configuration_revisions WHERE id=$1::uuid), + (SELECT count(*) FROM gateway_identity_onboarding_exchanges WHERE id=$2::uuid), + (SELECT count(*) FROM gateway_identity_pairing_start_reservation WHERE attempt_id=$2::uuid AND state='starting')`, + draft.ID, attemptID).Scan(&activeRevisions, &draftRevisions, &exchanges, &startingReservations); err != nil { + t.Fatalf("read pairing commit lifecycle invariant: %v", err) + } + if activeRevisions != 1 || draftRevisions != 0 || exchanges != 0 || startingReservations != 1 { + t.Fatalf("pairing commit race left active=%d draft=%d exchanges=%d starting reservations=%d; "+ + "want active=1 draft=0 exchanges=0 reservation=1", + activeRevisions, draftRevisions, exchanges, startingReservations) + } +} + +func TestConcurrentIdentityDisableAndActivationPreserveSingleActiveRevision(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + seedIdentityActivationPrerequisites(t, ctx, db, "default") + previousID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionActive, "default") + nextID := seedIdentityLifecycleRevision(t, ctx, db, identity.RevisionValidated, "default") + + gate, err := db.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin identity lifecycle gate: %v", err) + } + defer gate.Rollback(context.Background()) + if err := lockIdentityConfigurationLifecycle(ctx, gate); err != nil { + t.Fatalf("lock identity lifecycle gate: %v", err) + } + + disableResult := make(chan error, 1) + go func() { + _, disableErr := db.DisableActiveIdentityRevision(ctx, 1, "trace-disable", "audit-disable") + disableResult <- disableErr + }() + waitForIdentityLifecycleLockWaiters(t, ctx, db, 1) + + activateResult := make(chan error, 1) + go func() { + _, _, activateErr := db.ActivateIdentityRevision(ctx, nextID, 1, "trace-activate", "audit-activate") + activateResult <- activateErr + }() + waitForIdentityLifecycleLockWaiters(t, ctx, db, 2) + if err := gate.Commit(ctx); err != nil { + t.Fatalf("release identity lifecycle gate: %v", err) + } + + if err := <-disableResult; err != nil { + t.Fatalf("disable first queued active revision: %v", err) + } + activateErr := <-activateResult + if activateErr != nil { + t.Fatalf("activate revision after first queued disable: %v", activateErr) + } + + var activeIDs []string + rows, err := db.pool.Query(ctx, `SELECT id::text FROM gateway_identity_configuration_revisions +WHERE state='active' ORDER BY id`) + if err != nil { + t.Fatalf("read active revisions after disable/activate race: %v", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatalf("scan active revision after disable/activate race: %v", err) + } + activeIDs = append(activeIDs, id) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate active revisions after disable/activate race: %v", err) + } + if len(activeIDs) != 1 || activeIDs[0] != nextID { + t.Fatalf("disable/activate race left active revisions=%v; previous=%q next=%q", + activeIDs, previousID, nextID) + } +} + +func TestCompletedIdentityPairingRestoreUsesCanonicalVisibleRevision(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + var latestRevisionID string + + for index, createdAt := range []time.Time{now.Add(-time.Minute), now} { + revisionID, pairingID := uuid.NewString(), uuid.NewString() + if _, err := db.pool.Exec(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url,created_at,updated_at +) VALUES ($1::uuid,'validated','https://auth.test.example','default','https://gateway.test.example', + 'https://gateway-web.test.example',$2,$2)`, revisionID, createdAt); err != nil { + t.Fatalf("seed completed revision %d: %v", index, err) + } + if _, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{ + ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(), + ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCompleted, + CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: now.Add(time.Hour), + }); err != nil { + t.Fatalf("seed completed pairing %d: %v", index, err) + } + latestRevisionID = revisionID + } + + completed, err := db.CompletedIdentityPairingsAwaitingActivation(ctx) + if err != nil { + t.Fatal(err) + } + if len(completed) != 1 || completed[0].RevisionID != latestRevisionID { + t.Fatalf("restored completed pairings=%#v, want only canonical latest revision %s", completed, latestRevisionID) + } + visible, err := db.LatestInactiveIdentityConfigurationRevision(ctx) + if err != nil || visible.ID != latestRevisionID { + t.Fatalf("visible inactive revision=%#v error=%v, want same canonical revision", visible, err) + } +} + +func TestIdentityPairingPersistsBindingErrorCategoryAllowedByLatestMigration(t *testing.T) { + db := newIdentityPairingPostgresTestStore(t) + ctx := context.Background() + revisionID := uuid.NewString() + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,local_tenant_key,public_base_url,web_base_url +) VALUES ($1::uuid,'draft','https://auth.test.example','default','https://gateway.test.example', + 'https://gateway-web.test.example')`, revisionID); err != nil { + t.Fatalf("seed revision for binding error category: %v", err) + } + pairingID := uuid.NewString() + created, err := db.CreateIdentityPairingExchange(ctx, identity.PairingExchange{ + ID: pairingID, RevisionID: revisionID, RemoteExchangeID: uuid.NewString(), + ExchangeTokenRef: "identity-exchange-" + pairingID, Status: identity.PairingCredentialsSaved, + CleanupStatus: identity.PairingCleanupNone, RemoteVersion: 4, ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatalf("create pairing for binding error category: %v", err) + } + + const category = "security_event_connection_binding_mismatch" + updated, err := db.RecordIdentityPairingRetryFailure(ctx, created.ID, created.Status, category) + if err != nil { + t.Fatalf("persist binding error category: %v", err) + } + if updated.LastErrorCategory != category { + t.Fatalf("binding error category=%q, want %q", updated.LastErrorCategory, category) + } + var persisted string + if err := db.pool.QueryRow(ctx, `SELECT last_error_category FROM gateway_identity_onboarding_exchanges +WHERE id=$1::uuid`, created.ID).Scan(&persisted); err != nil { + t.Fatalf("read persisted binding error category: %v", err) + } + if persisted != category { + t.Fatalf("persisted binding error category=%q, want %q", persisted, category) + } +} + +func assertIdentityRevisionSecretRefs(t *testing.T, ctx context.Context, db *Store, revisionID, wantMachine, wantSession string) { + t.Helper() + revision, err := db.IdentityConfigurationRevision(ctx, revisionID) + if err != nil { + t.Fatalf("read revision %s: %v", revisionID, err) + } + if revision.MachineCredentialRef != wantMachine || revision.SessionEncryptionKeyRef != wantSession { + t.Fatalf("revision %s secret refs machine=%q session=%q, want machine=%q session=%q", + revisionID, revision.MachineCredentialRef, revision.SessionEncryptionKeyRef, wantMachine, wantSession) + } +} + +func seedIdentityActivationPrerequisites(t *testing.T, ctx context.Context, db *Store, tenantKey string) { + t.Helper() + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_tenants(tenant_key,name,status) +VALUES($1,$2,'active')`, tenantKey, "Identity lifecycle test tenant"); err != nil { + t.Fatalf("seed identity lifecycle tenant: %v", err) + } + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_users(user_key,username,password_hash,roles,source,status) +VALUES($1,$2,$3,'["manager"]'::jsonb,'gateway','active')`, + "identity-lifecycle-manager", "identity-lifecycle-manager", "test-only-password-hash"); err != nil { + t.Fatalf("seed identity lifecycle break-glass manager: %v", err) + } +} + +func seedIdentityLifecycleRevision( + t *testing.T, + ctx context.Context, + db *Store, + state identity.RevisionState, + tenantKey string, +) string { + t.Helper() + revisionID := uuid.NewString() + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_configuration_revisions ( + id,state,auth_center_url,issuer,tenant_id,application_id,audience,browser_client_id, + local_tenant_key,public_base_url,web_base_url,validated_at,activated_at +) VALUES ( + $1::uuid,$2,'https://auth.test.example','https://issuer.test.example',$3,$4,$5,$6, + $7,'https://gateway.test.example','https://gateway-web.test.example', + CASE WHEN $2 IN ('validated','active') THEN now() ELSE NULL END, + CASE WHEN $2='active' THEN now() ELSE NULL END +)`, revisionID, state, uuid.NewString(), uuid.NewString(), "urn:easyai:resource:"+revisionID, + "browser-"+revisionID, tenantKey); err != nil { + t.Fatalf("seed %s identity lifecycle revision: %v", state, err) + } + return revisionID +} + +func waitForIdentityLifecycleLockWaiters(t *testing.T, ctx context.Context, db *Store, want int) { + t.Helper() + classID := int64(uint64(identityConfigurationLifecycleLockID) >> 32) + objectID := int64(uint64(identityConfigurationLifecycleLockID) & 0xffffffff) + deadline := time.Now().Add(5 * time.Second) + for { + var waiters int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM pg_locks +WHERE locktype='advisory' AND database=(SELECT oid FROM pg_database WHERE datname=current_database()) + AND classid::bigint=$1 AND objid::bigint=$2 AND objsubid=1 AND NOT granted`, classID, objectID).Scan(&waiters); err != nil { + t.Fatalf("read identity lifecycle lock waiters: %v", err) + } + if waiters >= want { + return + } + if time.Now().After(deadline) { + t.Fatalf("identity lifecycle lock waiters=%d, want at least %d", waiters, want) + } + time.Sleep(10 * time.Millisecond) + } +} + +func newIdentityPairingPostgresTestStore(t *testing.T) *Store { + t.Helper() + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity pairing PostgreSQL integration tests") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect identity pairing test database: %v", err) + } + var databaseName string + if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil { + admin.Close() + t.Fatalf("read identity pairing test database name: %v", err) + } + if !strings.Contains(strings.ToLower(databaseName), "test") { + admin.Close() + t.Fatalf("refusing to use non-test database %q", databaseName) + } + + schemaName := "gateway_identity_pairing_" + strings.ReplaceAll(uuid.NewString(), "-", "") + schemaIdentifier := pgx.Identifier{schemaName}.Sanitize() + if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil { + admin.Close() + t.Fatalf("create identity pairing test schema: %v", err) + } + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("parse identity pairing test database URL: %v", err) + } + config.ConnConfig.RuntimeParams["search_path"] = schemaName + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("connect identity pairing test schema: %v", err) + } + t.Cleanup(func() { + pool.Close() + if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil { + t.Errorf("drop identity pairing test schema: %v", err) + } + admin.Close() + }) + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate identity pairing integration test") + } + migrationDirectory := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") + for _, migrationName := range []string{ + "0001_init.sql", + "0061_oidc_server_sessions.sql", + "0067_identity_configuration_revisions.sql", + "0068_identity_onboarding_exchanges.sql", + "0069_identity_pairing_cancellation.sql", + "0070_identity_secret_cleanup_queue.sql", + "0071_identity_pairing_start_reservation.sql", + "0072_identity_secret_cleanup_claim_lifecycle.sql", + "0073_identity_pairing_start_reservation_upgrade.sql", + "0074_identity_pairing_error_categories.sql", + } { + migration, err := os.ReadFile(filepath.Join(migrationDirectory, migrationName)) + if err != nil { + t.Fatalf("read %s: %v", migrationName, err) + } + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin %s: %v", migrationName, err) + } + if _, err := tx.Exec(ctx, string(migration)); err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("execute %s: %v", migrationName, err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit %s: %v", migrationName, err) + } + } + return &Store{pool: pool} +} diff --git a/apps/api/internal/store/identity_secret_cleanup_migration_integration_test.go b/apps/api/internal/store/identity_secret_cleanup_migration_integration_test.go new file mode 100644 index 0000000..c750df2 --- /dev/null +++ b/apps/api/internal/store/identity_secret_cleanup_migration_integration_test.go @@ -0,0 +1,206 @@ +package store + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestIdentitySecretCleanupClaimLifecycleUpgradeMigrationExists(t *testing.T) { + payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, "0072_identity_secret_cleanup_claim_lifecycle.sql")) + if err != nil { + t.Fatal(err) + } + content := strings.ToLower(string(payload)) + for _, required := range []string{ + "add column if not exists status text", + "add column if not exists claim_token uuid", + "add column if not exists lease_expires_at timestamptz", + "alter column status set default 'pending'", + "alter column status set not null", + "gateway_identity_secret_cleanup_status_check", + "gateway_identity_secret_cleanup_claim_check", + "drop index if exists idx_gateway_identity_secret_cleanup_due", + "create index idx_gateway_identity_secret_cleanup_due", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity Secret cleanup lifecycle migration is missing %q", required) + } + } + for _, forbidden := range []string{"secret_value", "client_secret", "machine_secret", "token text"} { + if strings.Contains(content, forbidden) { + t.Fatalf("identity Secret cleanup lifecycle migration stores forbidden value field %q", forbidden) + } + } +} + +func TestIdentitySecretCleanupClaimLifecycleUpgradeMigratesLegacyQueue(t *testing.T) { + db := newIdentitySecretCleanupMigrationPostgresStore(t) + ctx := context.Background() + reference := "identity-cleanup-legacy-" + uuid.NewString() + + if _, err := db.pool.Exec(ctx, ` +CREATE TABLE gateway_identity_secret_cleanup_queue ( + secret_ref text PRIMARY KEY CHECK (secret_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'), + not_before timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +);`); err != nil { + t.Fatalf("create legacy identity Secret cleanup queue: %v", err) + } + if _, err := db.pool.Exec(ctx, `CREATE INDEX idx_gateway_identity_secret_cleanup_due + ON gateway_identity_secret_cleanup_queue(not_before,updated_at)`); err != nil { + t.Fatalf("create legacy identity Secret cleanup due index: %v", err) + } + if _, err := db.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) +VALUES($1,now()-interval '1 minute')`, reference); err != nil { + t.Fatalf("seed legacy identity Secret cleanup row: %v", err) + } + + applyIdentitySecretCleanupMigration(t, ctx, db.pool, "0072_identity_secret_cleanup_claim_lifecycle.sql") + + var status string + var claimToken, leaseExpiresAt *string + if err := db.pool.QueryRow(ctx, ` +SELECT status,claim_token::text,lease_expires_at::text +FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference). + Scan(&status, &claimToken, &leaseExpiresAt); err != nil { + t.Fatalf("read migrated cleanup row: %v", err) + } + if status != "pending" || claimToken != nil || leaseExpiresAt != nil { + t.Fatalf("legacy cleanup row was not backfilled to an unclaimed pending lifecycle") + } + + claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute) + if err != nil { + t.Fatalf("claim migrated cleanup row: %v", err) + } + if len(claims) != 1 || claims[0].Reference != reference || claims[0].ClaimToken == "" { + t.Fatalf("claim migrated cleanup row returned unexpected claim metadata") + } + completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, claims[0].ClaimToken) + if err != nil || !completed { + t.Fatalf("complete migrated cleanup claim: completed=%t err=%v", completed, err) + } + + invalidReference := "identity-cleanup-invalid-" + uuid.NewString() + _, err = db.pool.Exec(ctx, ` +INSERT INTO gateway_identity_secret_cleanup_queue( + secret_ref,not_before,status,claim_token,lease_expires_at +) VALUES($1,now(),'claimed',NULL,NULL)`, invalidReference) + requireIdentitySecretCleanupMigrationCheckViolation(t, err) + + var indexDefinition string + if err := db.pool.QueryRow(ctx, ` +SELECT pg_get_indexdef(indexrelid) +FROM pg_index +WHERE indexrelid='idx_gateway_identity_secret_cleanup_due'::regclass`).Scan(&indexDefinition); err != nil { + t.Fatalf("read upgraded cleanup due index: %v", err) + } + if !strings.Contains(strings.ToLower(indexDefinition), "(status, not_before, lease_expires_at, updated_at)") { + t.Fatalf("cleanup due index does not cover the claim lifecycle") + } + + // The production migration runner applies each version once. Reapplying here + // proves that the upgrade also accepts the already-current 0070 schema shape. + applyIdentitySecretCleanupMigration(t, ctx, db.pool, "0072_identity_secret_cleanup_claim_lifecycle.sql") +} + +func newIdentitySecretCleanupMigrationPostgresStore(t *testing.T) *Store { + t.Helper() + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup migration integration tests") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatalf("connect identity Secret cleanup migration test database: %v", err) + } + var databaseName string + if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil { + admin.Close() + t.Fatalf("read identity Secret cleanup migration test database name: %v", err) + } + if !strings.Contains(strings.ToLower(databaseName), "test") { + admin.Close() + t.Fatalf("refusing to use non-test database %q", databaseName) + } + + schemaName := "gateway_identity_cleanup_migration_" + strings.ReplaceAll(uuid.NewString(), "-", "") + schemaIdentifier := pgx.Identifier{schemaName}.Sanitize() + if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil { + admin.Close() + t.Fatalf("create identity Secret cleanup migration test schema: %v", err) + } + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("parse identity Secret cleanup migration test database URL: %v", err) + } + config.ConnConfig.RuntimeParams["search_path"] = schemaName + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + _, _ = admin.Exec(ctx, `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`) + admin.Close() + t.Fatalf("connect identity Secret cleanup migration test schema: %v", err) + } + t.Cleanup(func() { + pool.Close() + if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil { + t.Errorf("drop identity Secret cleanup migration test schema: %v", err) + } + admin.Close() + }) + return &Store{pool: pool} +} + +func applyIdentitySecretCleanupMigration(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) { + t.Helper() + payload, err := os.ReadFile(identitySecretCleanupMigrationPath(t, name)) + if err != nil { + t.Fatalf("read identity Secret cleanup migration: %v", err) + } + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin identity Secret cleanup migration: %v", err) + } + if _, err := tx.Exec(ctx, string(payload)); err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("execute identity Secret cleanup migration: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit identity Secret cleanup migration: %v", err) + } +} + +func identitySecretCleanupMigrationPath(t *testing.T, name string) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate identity Secret cleanup migration test") + } + return filepath.Join(filepath.Dir(filename), "..", "..", "migrations", name) +} + +func requireIdentitySecretCleanupMigrationCheckViolation(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("invalid cleanup claim lifecycle unexpectedly satisfied migration constraints") + } + var postgresError *pgconn.PgError + if !errors.As(err, &postgresError) || postgresError.Code != "23514" { + t.Fatalf("invalid cleanup claim lifecycle error=%v, want PostgreSQL check violation", err) + } +} diff --git a/apps/api/internal/store/security_event_connections.go b/apps/api/internal/store/security_event_connections.go index e53851e..668cf10 100644 --- a/apps/api/internal/store/security_event_connections.go +++ b/apps/api/internal/store/security_event_connections.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "encoding/json" "errors" "time" @@ -13,6 +14,7 @@ import ( var ( ErrSecurityEventConnectionNotFound = errors.New("security event connection not found") ErrSecurityEventConnectionConflict = errors.New("security event connection conflicts with current state") + ErrIdentitySecretCleanupConflict = errors.New("identity secret cleanup conflicts with current state") ) type SecurityEventConnection struct { @@ -44,6 +46,121 @@ type SecurityEventConnectionIdempotency struct { Response json.RawMessage } +type IdentitySecretCleanupClaim struct { + Reference string + ClaimToken string + LeaseExpiresAt time.Time +} + +func (s *Store) QueueIdentitySecretCleanup(ctx context.Context, reference string, notBefore time.Time) error { + tag, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) +VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE +SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now() +WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrIdentitySecretCleanupConflict + } + return nil +} + +func (s *Store) RemovePendingIdentitySecretCleanup(ctx context.Context, reference string) (bool, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue +WHERE secret_ref=$1 AND status='pending'`, reference) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} + +func (s *Store) ClaimIdentitySecretCleanups(ctx context.Context, limit int, lease time.Duration) ([]IdentitySecretCleanupClaim, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + if lease <= 0 || lease > time.Hour { + lease = 2 * time.Minute + } + leaseSeconds := int64((lease + time.Second - 1) / time.Second) + rows, err := s.pool.Query(ctx, `WITH candidates AS MATERIALIZED ( + SELECT secret_ref FROM gateway_identity_secret_cleanup_queue + WHERE (status='pending' AND not_before <= now()) + OR (status='claimed' AND lease_expires_at <= now()) + ORDER BY COALESCE(lease_expires_at,not_before),created_at + FOR UPDATE SKIP LOCKED + LIMIT $1 +) +UPDATE gateway_identity_secret_cleanup_queue queued +SET status='claimed',claim_token=gen_random_uuid(), + lease_expires_at=now()+make_interval(secs => $2::int),updated_at=now() +FROM candidates +WHERE queued.secret_ref=candidates.secret_ref +RETURNING queued.secret_ref,queued.claim_token::text,queued.lease_expires_at`, limit, leaseSeconds) + if err != nil { + return nil, err + } + defer rows.Close() + claims := make([]IdentitySecretCleanupClaim, 0) + for rows.Next() { + var claim IdentitySecretCleanupClaim + if err := rows.Scan(&claim.Reference, &claim.ClaimToken, &claim.LeaseExpiresAt); err != nil { + return nil, err + } + claims = append(claims, claim) + } + return claims, rows.Err() +} + +func (s *Store) CompleteIdentitySecretCleanup(ctx context.Context, reference, claimToken string) (bool, error) { + if _, err := uuid.Parse(claimToken); err != nil { + return false, ErrIdentitySecretCleanupConflict + } + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue +WHERE secret_ref=$1 AND status='claimed' AND claim_token=$2::uuid`, reference, claimToken) + if err != nil { + return false, err + } + return tag.RowsAffected() == 1, nil +} + +func adoptPendingIdentitySecrets(ctx context.Context, tx pgx.Tx, references ...string) error { + unique := make(map[string]struct{}, len(references)) + for _, reference := range references { + if reference == "" { + return ErrIdentitySecretCleanupConflict + } + unique[reference] = struct{}{} + } + values := make([]string, 0, len(unique)) + for reference := range unique { + values = append(values, reference) + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway_identity_secret_cleanup_queue +WHERE secret_ref=ANY($1) AND status='pending'`, values) + if err != nil { + return err + } + if tag.RowsAffected() != int64(len(values)) { + return ErrIdentitySecretCleanupConflict + } + return nil +} + +func queueIdentitySecretCleanupTx(ctx context.Context, tx pgx.Tx, reference string, notBefore time.Time) error { + tag, err := tx.Exec(ctx, `INSERT INTO gateway_identity_secret_cleanup_queue(secret_ref,not_before) +VALUES($1,$2) ON CONFLICT(secret_ref) DO UPDATE +SET not_before=LEAST(gateway_identity_secret_cleanup_queue.not_before,EXCLUDED.not_before),updated_at=now() +WHERE gateway_identity_secret_cleanup_queue.status='pending'`, reference, notBefore) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrIdentitySecretCleanupConflict + } + return nil +} + func (s *Store) SecurityEventConnectionIdempotency(ctx context.Context, operation, key string) (SecurityEventConnectionIdempotency, error) { var value SecurityEventConnectionIdempotency err := s.pool.QueryRow(ctx, `SELECT request_hash,response FROM gateway_security_event_connection_idempotency @@ -64,20 +181,22 @@ VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`, o } func (s *Store) CreateSecurityEventConnection(ctx context.Context, input CreateSecurityEventConnectionInput) (SecurityEventConnection, error) { - var value SecurityEventConnection - err := s.pool.QueryRow(ctx, ` + tx, err := s.pool.Begin(ctx) + if err != nil { + return SecurityEventConnection{}, err + } + defer tx.Rollback(ctx) + _, err = tx.Exec(ctx, ` INSERT INTO gateway_security_event_connections( connection_id,transmitter_issuer,endpoint_url,credential_ref,management_client_id,management_credential_ref,lifecycle_status,idempotency_key ) VALUES($1::uuid,$2,$3,$4,$5,$6,'connecting',$7) -RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id::text,credential_ref, - next_credential_ref,management_client_id,management_credential_ref,lifecycle_status,version,last_error_category,idempotency_key,created_at,updated_at`, + `, input.ConnectionID, input.TransmitterIssuer, input.EndpointURL, input.CredentialRef, input.ManagementClientID, input.ManagementCredentialRef, input.IdempotencyKey, - ).Scan(&value.ConnectionID, &value.TransmitterIssuer, &value.EndpointURL, &value.Audience, &value.StreamID, - &value.CredentialRef, &value.NextCredentialRef, &value.ManagementClientID, &value.ManagementCredentialRef, &value.LifecycleStatus, &value.Version, - &value.LastErrorCategory, &value.IdempotencyKey, &value.CreatedAt, &value.UpdatedAt) + ) if err != nil { if isUniqueViolation(err) { + _ = tx.Rollback(ctx) existing, getErr := s.SecurityEventConnection(ctx) if getErr == nil && existing.IdempotencyKey == input.IdempotencyKey && existing.TransmitterIssuer == input.TransmitterIssuer { return existing, nil @@ -86,7 +205,13 @@ RETURNING connection_id::text,transmitter_issuer,endpoint_url,audience,stream_id } return SecurityEventConnection{}, err } - return value, nil + if err := adoptPendingIdentitySecrets(ctx, tx, input.CredentialRef, input.ManagementCredentialRef); err != nil { + return SecurityEventConnection{}, err + } + if err := tx.Commit(ctx); err != nil { + return SecurityEventConnection{}, err + } + return s.SecurityEventConnection(ctx) } func (s *Store) SecurityEventConnection(ctx context.Context) (SecurityEventConnection, error) { @@ -126,49 +251,56 @@ WHERE connection_id=$1::uuid AND version=$5`, connectionID, audience, streamID, return s.SecurityEventConnection(ctx) } -func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string) (SecurityEventConnection, error) { - tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections -SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL,updated_at=now() -WHERE connection_id=$1::uuid`, connectionID, clientID, reference) +func (s *Store) SetSecurityEventManagementCredential(ctx context.Context, connectionID, clientID, reference string, expectedVersion int64) (SecurityEventConnection, error) { + tx, err := s.pool.Begin(ctx) if err != nil { return SecurityEventConnection{}, err } - if tag.RowsAffected() == 0 { + defer tx.Rollback(ctx) + var oldReference sql.NullString + var lifecycle string + var version int64 + if err := tx.QueryRow(ctx, `SELECT management_credential_ref,lifecycle_status,version FROM gateway_security_event_connections +WHERE connection_id=$1::uuid FOR UPDATE`, connectionID).Scan(&oldReference, &lifecycle, &version); errors.Is(err, pgx.ErrNoRows) { return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound - } - return s.SecurityEventConnection(ctx) -} - -func (s *Store) UpdateSecurityEventConnectionLifecycle(ctx context.Context, connectionID, lifecycle string, errorCategory *string) (SecurityEventConnection, error) { - tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections -SET lifecycle_status=$2,last_error_category=$3,version=version+1,updated_at=now() WHERE connection_id=$1::uuid`, connectionID, lifecycle, errorCategory) - if err != nil { + } else if err != nil { return SecurityEventConnection{}, err } - if tag.RowsAffected() == 0 { - return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound + if version != expectedVersion || lifecycle == "retiring" || lifecycle == "disconnect_pending" { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict } - return s.SecurityEventConnection(ctx) -} - -func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string) (SecurityEventConnection, error) { - tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections -SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now() -WHERE connection_id=$1::uuid`, connectionID, reference) - if err != nil { - return SecurityEventConnection{}, err - } - if tag.RowsAffected() == 0 { - return SecurityEventConnection{}, ErrSecurityEventConnectionNotFound - } - return s.SecurityEventConnection(ctx) -} - -func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) { - tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections -SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL, + tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections +SET management_client_id=$2,management_credential_ref=$3,last_error_category=NULL, version=version+1,updated_at=now() -WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextReference) +WHERE connection_id=$1::uuid AND version=$4 + AND lifecycle_status NOT IN ('retiring','disconnect_pending')`, connectionID, clientID, reference, expectedVersion) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil { + return SecurityEventConnection{}, err + } + if oldReference.Valid && oldReference.String != reference { + if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference.String, time.Now().UTC()); err != nil { + return SecurityEventConnection{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return SecurityEventConnection{}, err + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) TransitionSecurityEventConnectionLifecycle(ctx context.Context, connectionID, expectedLifecycle string, expectedVersion int64, lifecycle string, errorCategory *string) (SecurityEventConnection, error) { + tag, err := s.pool.Exec(ctx, `UPDATE gateway_security_event_connections +SET lifecycle_status=$4,last_error_category=$5,version=version+1,updated_at=now() +WHERE connection_id=$1::uuid AND lifecycle_status=$2 AND version=$3 + AND (lifecycle_status NOT IN ('retiring','disconnect_pending') + OR $4 IN ('retiring','disconnect_pending'))`, + connectionID, expectedLifecycle, expectedVersion, lifecycle, errorCategory) if err != nil { return SecurityEventConnection{}, err } @@ -178,16 +310,168 @@ WHERE connection_id=$1::uuid AND next_credential_ref=$2`, connectionID, nextRefe return s.SecurityEventConnection(ctx) } -func (s *Store) DeleteSecurityEventConnection(ctx context.Context, connectionID string) error { - if _, err := uuid.Parse(connectionID); err != nil { - return ErrSecurityEventConnectionNotFound +func (s *Store) SetSecurityEventNextCredential(ctx context.Context, connectionID, reference string, expectedVersion int64) (SecurityEventConnection, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return SecurityEventConnection{}, err } - tag, err := s.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections +SET next_credential_ref=$2,lifecycle_status='rotating',last_error_category=NULL,version=version+1,updated_at=now() +WHERE connection_id=$1::uuid AND version=$3 AND next_credential_ref IS NULL + AND lifecycle_status IN ('connecting','verifying','bootstrap','enabled','degraded','error')`, connectionID, reference, expectedVersion) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + if err := adoptPendingIdentitySecrets(ctx, tx, reference); err != nil { + return SecurityEventConnection{}, err + } + if err := tx.Commit(ctx); err != nil { + return SecurityEventConnection{}, err + } + return s.SecurityEventConnection(ctx) +} + +func (s *Store) PromoteSecurityEventCredential(ctx context.Context, connectionID, nextReference string) (SecurityEventConnection, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return SecurityEventConnection{}, err + } + defer tx.Rollback(ctx) + var oldReference string + if err := tx.QueryRow(ctx, `SELECT credential_ref FROM gateway_security_event_connections +WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating' FOR UPDATE`, connectionID, nextReference).Scan(&oldReference); errors.Is(err, pgx.ErrNoRows) { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } else if err != nil { + return SecurityEventConnection{}, err + } + tag, err := tx.Exec(ctx, `UPDATE gateway_security_event_connections +SET credential_ref=$2,next_credential_ref=NULL,lifecycle_status='bootstrap',last_error_category=NULL, + version=version+1,updated_at=now() +WHERE connection_id=$1::uuid AND next_credential_ref=$2 AND lifecycle_status='rotating'`, connectionID, nextReference) + if err != nil { + return SecurityEventConnection{}, err + } + if tag.RowsAffected() == 0 { + return SecurityEventConnection{}, ErrSecurityEventConnectionConflict + } + if oldReference != nextReference { + if err := queueIdentitySecretCleanupTx(ctx, tx, oldReference, time.Now().UTC()); err != nil { + return SecurityEventConnection{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return SecurityEventConnection{}, err + } + return s.SecurityEventConnection(ctx) +} + +// DiscardPreparedSecurityEventConnection atomically retires the local Secrets +// and removes only the exact, still-unbound connection generation observed by +// the caller. A concurrent Bind or credential update changes the version and +// therefore preserves both the connection and its Secrets. +func (s *Store) DiscardPreparedSecurityEventConnection(ctx context.Context, connectionID, ownerKey string, expectedVersion int64) error { + if _, err := uuid.Parse(connectionID); err != nil || ownerKey == "" || expectedVersion <= 0 { + return ErrSecurityEventConnectionConflict + } + tx, err := s.pool.Begin(ctx) if err != nil { return err } - if tag.RowsAffected() == 0 { - return ErrSecurityEventConnectionNotFound + defer tx.Rollback(ctx) + + var credentialReference string + var nextReference, managementReference sql.NullString + err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref +FROM gateway_security_event_connections +WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL +FOR UPDATE`, connectionID, ownerKey, expectedVersion).Scan( + &credentialReference, &nextReference, &managementReference, + ) + if errors.Is(err, pgx.ErrNoRows) { + return ErrSecurityEventConnectionConflict } - return nil + if err != nil { + return err + } + + references := map[string]struct{}{credentialReference: {}} + if nextReference.Valid { + references[nextReference.String] = struct{}{} + } + if managementReference.Valid { + references[managementReference.String] = struct{}{} + } + now := time.Now().UTC() + for reference := range references { + if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil { + return err + } + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections +WHERE connection_id=$1::uuid AND idempotency_key=$2 AND version=$3 AND stream_id IS NULL`, + connectionID, ownerKey, expectedVersion) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrSecurityEventConnectionConflict + } + return tx.Commit(ctx) +} + +// FinalizeRetiringSecurityEventConnection atomically transfers every Secret +// reference owned by the exact retiring generation to the durable cleanup +// queue before removing that generation. The SecretStore worker can therefore +// resume after a crash without the database losing its last cleanup intent. +func (s *Store) FinalizeRetiringSecurityEventConnection(ctx context.Context, connectionID string, expectedVersion int64) error { + if _, err := uuid.Parse(connectionID); err != nil || expectedVersion <= 0 { + return ErrSecurityEventConnectionConflict + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var credentialReference string + var nextReference, managementReference sql.NullString + err = tx.QueryRow(ctx, `SELECT credential_ref,next_credential_ref,management_credential_ref +FROM gateway_security_event_connections +WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2 +FOR UPDATE`, connectionID, expectedVersion).Scan( + &credentialReference, &nextReference, &managementReference, + ) + if errors.Is(err, pgx.ErrNoRows) { + return ErrSecurityEventConnectionConflict + } + if err != nil { + return err + } + + references := map[string]struct{}{credentialReference: {}} + if nextReference.Valid { + references[nextReference.String] = struct{}{} + } + if managementReference.Valid { + references[managementReference.String] = struct{}{} + } + now := time.Now().UTC() + for reference := range references { + if err := queueIdentitySecretCleanupTx(ctx, tx, reference, now); err != nil { + return err + } + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway_security_event_connections +WHERE connection_id=$1::uuid AND lifecycle_status='retiring' AND version=$2`, connectionID, expectedVersion) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrSecurityEventConnectionConflict + } + return tx.Commit(ctx) } diff --git a/apps/api/internal/store/security_event_secret_cleanup_integration_test.go b/apps/api/internal/store/security_event_secret_cleanup_integration_test.go new file mode 100644 index 0000000..f7278d6 --- /dev/null +++ b/apps/api/internal/store/security_event_secret_cleanup_integration_test.go @@ -0,0 +1,665 @@ +package store + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestSecurityEventCredentialReferenceSwitchesRemainCrashRecoverable(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + + connectionID := uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + nextReference := "ssf-push-next-" + uuid.NewString() + newManagementReference := "ssf-management-next-" + uuid.NewString() + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, + []string{pushReference, managementReference, nextReference, newManagementReference}) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + + for _, reference := range []string{pushReference, managementReference} { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if err != nil { + t.Fatal(err) + } + assertIdentitySecretQueueState(t, ctx, db, pushReference, false) + assertIdentitySecretQueueState(t, ctx, db, managementReference, false) + + if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); err != nil { + t.Fatal(err) + } + assertIdentitySecretQueueState(t, ctx, db, newManagementReference, false) + assertIdentitySecretQueueState(t, ctx, db, managementReference, true) + + if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + connection, err = db.SecurityEventConnection(ctx) + if err != nil { + t.Fatal(err) + } + connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version) + if err != nil { + t.Fatal(err) + } + assertIdentitySecretQueueState(t, ctx, db, nextReference, false) + if _, err := db.PromoteSecurityEventCredential(ctx, connection.ConnectionID, nextReference); err != nil { + t.Fatal(err) + } + assertIdentitySecretQueueState(t, ctx, db, pushReference, true) +} + +func TestDiscardPreparedSecurityEventConnectionQueuesAllSecretsAtomically(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + ownerKey := "identity-pairing-ssf-" + uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + nextReference := "ssf-push-next-" + uuid.NewString() + references := []string{pushReference, managementReference, nextReference} + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range []string{pushReference, managementReference} { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: ownerKey, + }) + if err != nil { + t.Fatal(err) + } + if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version) + if err != nil { + t.Fatal(err) + } + + if err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version); err != nil { + t.Fatal(err) + } + if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) { + t.Fatalf("discarded prepared connection still exists: %v", err) + } + for _, reference := range references { + assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending") + } +} + +func TestConcurrentPreparedDiscardAndBindPreserveTheWinningGeneration(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + ownerKey := "identity-pairing-ssf-" + uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + references := []string{pushReference, managementReference} + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range references { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: ownerKey, + }) + if err != nil { + t.Fatal(err) + } + + type result struct { + operation string + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + audience := "urn:easyai:ssf:receiver:" + uuid.NewString() + streamID := uuid.NewString() + go func() { + <-start + _, err := db.BindSecurityEventConnection(ctx, connectionID, audience, streamID, "verifying", connection.Version) + results <- result{operation: "bind", err: err} + }() + go func() { + <-start + err := db.DiscardPreparedSecurityEventConnection(ctx, connectionID, ownerKey, connection.Version) + results <- result{operation: "discard", err: err} + }() + close(start) + + outcomes := map[string]error{} + for range 2 { + outcome := <-results + outcomes[outcome.operation] = outcome.err + } + bindWon := outcomes["bind"] == nil + discardWon := outcomes["discard"] == nil + if bindWon == discardWon { + t.Fatalf("concurrent outcomes bind=%v discard=%v, want exactly one winner", outcomes["bind"], outcomes["discard"]) + } + loser := outcomes["bind"] + if bindWon { + loser = outcomes["discard"] + } + if !errors.Is(loser, ErrSecurityEventConnectionConflict) { + t.Fatalf("concurrent loser error=%v, want connection conflict", loser) + } + if bindWon { + persisted, err := db.SecurityEventConnection(ctx) + if err != nil || persisted.StreamID == nil || *persisted.StreamID != streamID { + t.Fatalf("winning bound generation was not preserved: connection=%+v err=%v", persisted, err) + } + for _, reference := range references { + assertIdentitySecretQueueState(t, ctx, db, reference, false) + } + return + } + if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) { + t.Fatalf("winning discard left a connection behind: %v", err) + } + for _, reference := range references { + assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending") + } +} + +func TestConcurrentSecurityEventNextCredentialsAdoptOnlyOneStagedSecret(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + candidates := []string{ + "ssf-push-next-" + uuid.NewString(), + "ssf-push-next-" + uuid.NewString(), + } + allReferences := append([]string{pushReference, managementReference}, candidates...) + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range []string{pushReference, managementReference} { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if err != nil { + t.Fatal(err) + } + for _, reference := range candidates { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + + type result struct { + reference string + err error + } + start := make(chan struct{}) + results := make(chan result, len(candidates)) + var workers sync.WaitGroup + for _, reference := range candidates { + workers.Add(1) + go func(reference string) { + defer workers.Done() + <-start + _, err := db.SetSecurityEventNextCredential(ctx, connectionID, reference, connection.Version) + results <- result{reference: reference, err: err} + }(reference) + } + close(start) + workers.Wait() + close(results) + + var adopted, pending string + for result := range results { + switch { + case result.err == nil: + if adopted != "" { + t.Fatal("more than one concurrent next credential was adopted") + } + adopted = result.reference + case errors.Is(result.err, ErrSecurityEventConnectionConflict): + pending = result.reference + default: + t.Fatalf("concurrent next credential returned unexpected error: %v", result.err) + } + } + if adopted == "" || pending == "" { + t.Fatalf("concurrent next credential outcomes adopted=%t pending=%t", adopted != "", pending != "") + } + connection, err = db.SecurityEventConnection(ctx) + if err != nil || connection.NextCredentialRef == nil || *connection.NextCredentialRef != adopted { + t.Fatalf("connection did not retain the sole adopted next credential: %v", err) + } + assertIdentitySecretQueueState(t, ctx, db, adopted, false) + assertIdentitySecretQueueStatus(t, ctx, db, pending, "pending") +} + +func TestRetiringSecurityEventConnectionCannotAdoptManagementSecret(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + newManagementReference := "ssf-management-next-" + uuid.NewString() + allReferences := []string{pushReference, managementReference, newManagementReference} + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, allReferences) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range []string{pushReference, managementReference} { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if err != nil { + t.Fatal(err) + } + connection, err = db.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil, + ) + if err != nil { + t.Fatal(err) + } + if err := db.QueueIdentitySecretCleanup(ctx, newManagementReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + + if _, err := db.SetSecurityEventManagementCredential(ctx, connectionID, "machine-client", newManagementReference, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) { + t.Fatalf("retiring connection management adoption error=%v, want conflict", err) + } + connection, err = db.SecurityEventConnection(ctx) + if err != nil || connection.ManagementCredentialRef == nil || *connection.ManagementCredentialRef != managementReference { + t.Fatalf("retiring connection changed its management credential: %v", err) + } + assertIdentitySecretQueueStatus(t, ctx, db, newManagementReference, "pending") +} + +func TestSecurityEventRetirementRequiresTheCurrentConnectionGeneration(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + pushReference := "ssf-push-" + connectionID + managementReference := "ssf-management-" + connectionID + references := []string{pushReference, managementReference} + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range references { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if err != nil { + t.Fatal(err) + } + + if _, err := db.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, "connecting", connection.Version-1, "retiring", nil, + ); !errors.Is(err, ErrSecurityEventConnectionConflict) { + t.Fatalf("stale retirement transition error=%v, want conflict", err) + } + current, err := db.SecurityEventConnection(ctx) + if err != nil || current.LifecycleStatus != "connecting" || current.Version != connection.Version { + t.Fatalf("stale transition changed connection generation: lifecycle=%q version=%d err=%v", + current.LifecycleStatus, current.Version, err) + } + current, err = db.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, "connecting", connection.Version, "retiring", nil, + ) + if err != nil { + t.Fatal(err) + } + if _, err := db.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, "retiring", current.Version, "error", nil, + ); !errors.Is(err, ErrSecurityEventConnectionConflict) { + t.Fatalf("retiring generation revival error=%v, want conflict", err) + } + if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version); !errors.Is(err, ErrSecurityEventConnectionConflict) { + t.Fatalf("stale retirement deletion error=%v, want conflict", err) + } + if persisted, err := db.SecurityEventConnection(ctx); err != nil || persisted.LifecycleStatus != "retiring" || persisted.Version != current.Version { + t.Fatalf("stale retirement deletion removed current generation: lifecycle=%q version=%d err=%v", + persisted.LifecycleStatus, persisted.Version, err) + } + if err := db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, current.Version); err != nil { + t.Fatal(err) + } + if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) { + t.Fatalf("current retiring generation was not deleted: %v", err) + } + for _, reference := range references { + assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending") + } +} + +func TestConcurrentSecurityEventRetirementFinalizationQueuesOneGenerationAtomically(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + pushReference := "ssf-push-retire-worker-" + connectionID + managementReference := "ssf-management-retire-worker-" + connectionID + nextReference := "ssf-push-next-retire-worker-" + uuid.NewString() + references := []string{pushReference, managementReference, nextReference} + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references) + }) + if _, err := db.pool.Exec(ctx, `DELETE FROM gateway_security_event_connections`); err != nil { + t.Fatal(err) + } + for _, reference := range []string{pushReference, managementReference} { + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + } + connection, err := db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if err != nil { + t.Fatal(err) + } + if err := db.QueueIdentitySecretCleanup(ctx, nextReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + connection, err = db.SetSecurityEventNextCredential(ctx, connectionID, nextReference, connection.Version) + if err != nil { + t.Fatal(err) + } + connection, err = db.TransitionSecurityEventConnectionLifecycle( + ctx, connectionID, connection.LifecycleStatus, connection.Version, "retiring", nil, + ) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + results := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + <-start + results <- db.FinalizeRetiringSecurityEventConnection(ctx, connectionID, connection.Version) + }() + } + close(start) + workers.Wait() + close(results) + succeeded, conflicted := 0, 0 + for err := range results { + switch { + case err == nil: + succeeded++ + case errors.Is(err, ErrSecurityEventConnectionConflict): + conflicted++ + default: + t.Fatalf("unexpected concurrent finalization error: %v", err) + } + } + if succeeded != 1 || conflicted != 1 { + t.Fatalf("concurrent finalization succeeded=%d conflicted=%d, want 1/1", succeeded, conflicted) + } + if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) { + t.Fatalf("finalized connection still exists: %v", err) + } + for _, reference := range references { + assertIdentitySecretQueueStatus(t, ctx, db, reference, "pending") + } +} + +func TestClaimedStagingSecretsCannotBeAdoptedByAConnection(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + connectionID := uuid.NewString() + pushReference := "ssf-push-claimed-" + uuid.NewString() + managementReference := "ssf-management-claimed-" + uuid.NewString() + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_security_event_connections WHERE connection_id=$1::uuid`, connectionID) + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, + []string{pushReference, managementReference}) + }) + + if err := db.QueueIdentitySecretCleanup(ctx, pushReference, time.Now().Add(10*time.Minute)); err != nil { + t.Fatal(err) + } + if err := db.QueueIdentitySecretCleanup(ctx, managementReference, time.Now().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + claims, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute) + if err != nil || len(claims) != 1 || claims[0].Reference != managementReference { + t.Fatalf("claim staging management Secret: count=%d reference_matches=%t err=%v", + len(claims), len(claims) == 1 && claims[0].Reference == managementReference, err) + } + + _, err = db.CreateSecurityEventConnection(ctx, CreateSecurityEventConnectionInput{ + ConnectionID: connectionID, TransmitterIssuer: "https://auth.test.example/ssf", + EndpointURL: "https://gateway.test.example/api/v1/security-events/ssf", CredentialRef: pushReference, + ManagementClientID: "machine-client", ManagementCredentialRef: managementReference, + IdempotencyKey: "identity-pairing-ssf-" + uuid.NewString(), + }) + if !errors.Is(err, ErrIdentitySecretCleanupConflict) { + t.Fatalf("claimed Secret adoption error=%v, want cleanup conflict", err) + } + if _, err := db.SecurityEventConnection(ctx); !errors.Is(err, ErrSecurityEventConnectionNotFound) { + t.Fatalf("failed adoption committed connection: %v", err) + } + assertIdentitySecretQueueStatus(t, ctx, db, pushReference, "pending") + assertIdentitySecretQueueStatus(t, ctx, db, managementReference, "claimed") +} + +func TestIdentitySecretCleanupCompletionRejectsExpiredClaimABA(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + reference := "ssf-management-aba-" + uuid.NewString() + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference) + }) + if err := db.QueueIdentitySecretCleanup(ctx, reference, time.Now().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + first, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute) + if err != nil || len(first) != 1 { + t.Fatalf("first claim count=%d err=%v", len(first), err) + } + if _, err := db.pool.Exec(ctx, `UPDATE gateway_identity_secret_cleanup_queue SET lease_expires_at=now()-interval '1 second' WHERE secret_ref=$1`, reference); err != nil { + t.Fatal(err) + } + second, err := db.ClaimIdentitySecretCleanups(ctx, 1, time.Minute) + if err != nil || len(second) != 1 || second[0].ClaimToken == first[0].ClaimToken { + t.Fatalf("second claim count=%d token_rotated=%t err=%v", + len(second), len(second) == 1 && second[0].ClaimToken != first[0].ClaimToken, err) + } + completed, err := db.CompleteIdentitySecretCleanup(ctx, reference, first[0].ClaimToken) + if err != nil || completed { + t.Fatalf("stale completion completed=%t err=%v", completed, err) + } + assertIdentitySecretQueueStatus(t, ctx, db, reference, "claimed") + completed, err = db.CompleteIdentitySecretCleanup(ctx, reference, second[0].ClaimToken) + if err != nil || !completed { + t.Fatalf("current completion completed=%t err=%v", completed, err) + } +} + +func TestConcurrentIdentitySecretCleanupClaimsAreDisjoint(t *testing.T) { + db := newSecurityEventSecretCleanupPostgresStore(t) + ctx := context.Background() + references := make([]string, 20) + for index := range references { + references[index] = "ssf-cleanup-concurrent-" + uuid.NewString() + if err := db.QueueIdentitySecretCleanup(ctx, references[index], time.Now().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { + _, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=ANY($1)`, references) + }) + + start := make(chan struct{}) + results := make(chan []IdentitySecretCleanupClaim, 2) + errorsCh := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + <-start + claims, err := db.ClaimIdentitySecretCleanups(ctx, len(references), time.Minute) + results <- claims + errorsCh <- err + }() + } + close(start) + workers.Wait() + close(results) + close(errorsCh) + for err := range errorsCh { + if err != nil { + t.Fatal(err) + } + } + claimed := map[string]string{} + for claims := range results { + for _, claim := range claims { + if previous := claimed[claim.Reference]; previous != "" { + t.Fatalf("Secret %q was claimed by more than one worker", claim.Reference) + } + claimed[claim.Reference] = claim.ClaimToken + } + } + if len(claimed) != len(references) { + t.Fatalf("claimed %d Secrets, want %d", len(claimed), len(references)) + } +} + +func newSecurityEventSecretCleanupPostgresStore(t *testing.T) *Store { + t.Helper() + databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run identity Secret cleanup PostgreSQL integration tests") + } + ctx := context.Background() + probe, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + var databaseName string + if err := probe.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil { + probe.Close() + t.Fatal(err) + } + probe.Close() + if !strings.Contains(strings.ToLower(databaseName), "test") { + t.Fatalf("refusing to migrate non-test database %q", databaseName) + } + applyOIDCJITTestMigrations(t, ctx, databaseURL) + db, err := Connect(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) + return db +} + +func assertIdentitySecretQueueState(t *testing.T, ctx context.Context, db *Store, reference string, expected bool) { + t.Helper() + var exists bool + if err := db.pool.QueryRow(ctx, `SELECT EXISTS( +SELECT 1 FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1)`, reference).Scan(&exists); err != nil { + t.Fatal(err) + } + if exists != expected { + t.Fatalf("Secret cleanup reference %q exists=%t want=%t", reference, exists, expected) + } +} + +func assertIdentitySecretQueueStatus(t *testing.T, ctx context.Context, db *Store, reference, expected string) { + t.Helper() + var status string + if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_identity_secret_cleanup_queue WHERE secret_ref=$1`, reference).Scan(&status); err != nil { + t.Fatal(err) + } + if status != expected { + t.Fatalf("Secret cleanup reference %q status=%q want=%q", reference, status, expected) + } +} diff --git a/apps/api/migrations/0069_identity_pairing_cancellation.sql b/apps/api/migrations/0069_identity_pairing_cancellation.sql new file mode 100644 index 0000000..dcd691c --- /dev/null +++ b/apps/api/migrations/0069_identity_pairing_cancellation.sql @@ -0,0 +1,69 @@ +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '60s'; + +ALTER TABLE gateway_identity_onboarding_exchanges + DROP CONSTRAINT gateway_identity_onboarding_exchanges_status_check; + +ALTER TABLE gateway_identity_onboarding_exchanges + ADD CONSTRAINT gateway_identity_onboarding_exchanges_status_check + CHECK (status IN ('metadata_pending','preparing','ready','credentials_saved','completed','failed','expired','cancelled')), + ADD COLUMN cleanup_status text NOT NULL DEFAULT 'none', + ADD COLUMN cancelled_at timestamptz, + ADD COLUMN cleanup_completed_at timestamptz; + +ALTER TABLE gateway_identity_onboarding_exchanges + ADD CONSTRAINT gateway_identity_onboarding_cleanup_status_check + CHECK (cleanup_status IN ('none','pending','completed')), + ADD CONSTRAINT gateway_identity_onboarding_cleanup_lifecycle_check + CHECK ( + (status <> 'cancelled' AND cleanup_status = 'none' AND cancelled_at IS NULL AND cleanup_completed_at IS NULL) OR + (status = 'cancelled' AND cancelled_at IS NOT NULL AND ( + (cleanup_status = 'pending' AND cleanup_completed_at IS NULL) OR + (cleanup_status = 'completed' AND cleanup_completed_at IS NOT NULL) + )) + ); + +UPDATE gateway_identity_onboarding_exchanges +SET last_error_category = 'pairing_step_failed' +WHERE last_error_category IS NOT NULL + AND last_error_category NOT IN ( + 'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed', + 'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid', + 'security_event_configuration_invalid','security_event_connection_conflict', + 'security_event_discovery_failed','security_event_management_token_failed', + 'security_event_stream_create_failed','security_event_stream_response_invalid', + 'security_event_receiver_activation_failed','security_event_preparation_failed', + 'security_event_retirement_pending','pairing_step_failed', + 'cleanup_revision_unavailable','cleanup_security_event_unavailable', + 'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict', + 'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed', + 'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid', + 'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed', + 'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed', + 'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_failed', + 'cleanup_secret_store_failed','cleanup_finalize_failed' + ); + +ALTER TABLE gateway_identity_onboarding_exchanges + ADD CONSTRAINT gateway_identity_onboarding_error_category_check + CHECK (last_error_category IS NULL OR last_error_category IN ( + 'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed', + 'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid', + 'security_event_configuration_invalid','security_event_connection_conflict', + 'security_event_discovery_failed','security_event_management_token_failed', + 'security_event_stream_create_failed','security_event_stream_response_invalid', + 'security_event_receiver_activation_failed','security_event_preparation_failed', + 'security_event_retirement_pending','pairing_step_failed', + 'cleanup_revision_unavailable','cleanup_security_event_unavailable', + 'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict', + 'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed', + 'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid', + 'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed', + 'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed', + 'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_failed', + 'cleanup_secret_store_failed','cleanup_finalize_failed' + )); + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_onboarding_cleanup + ON gateway_identity_onboarding_exchanges(cleanup_status, updated_at) + WHERE cleanup_status = 'pending'; diff --git a/apps/api/migrations/0070_identity_secret_cleanup_queue.sql b/apps/api/migrations/0070_identity_secret_cleanup_queue.sql new file mode 100644 index 0000000..b1a8b9b --- /dev/null +++ b/apps/api/migrations/0070_identity_secret_cleanup_queue.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS gateway_identity_secret_cleanup_queue ( + secret_ref text PRIMARY KEY CHECK (secret_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'), + not_before timestamptz NOT NULL, + status text NOT NULL DEFAULT 'pending', + claim_token uuid, + lease_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT gateway_identity_secret_cleanup_status_check + CHECK (status IN ('pending','claimed')), + CONSTRAINT gateway_identity_secret_cleanup_claim_check + CHECK ( + (status = 'pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR + (status = 'claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_secret_cleanup_due + ON gateway_identity_secret_cleanup_queue(status, not_before, lease_expires_at, updated_at); diff --git a/apps/api/migrations/0071_identity_pairing_start_reservation.sql b/apps/api/migrations/0071_identity_pairing_start_reservation.sql new file mode 100644 index 0000000..c7901f9 --- /dev/null +++ b/apps/api/migrations/0071_identity_pairing_start_reservation.sql @@ -0,0 +1,29 @@ +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '60s'; + +CREATE TABLE IF NOT EXISTS gateway_identity_pairing_start_reservation ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + attempt_id uuid NOT NULL UNIQUE, + state text NOT NULL DEFAULT 'starting' CHECK (state IN ('starting','paired')), + revision_id uuid UNIQUE REFERENCES gateway_identity_configuration_revisions(id) ON DELETE RESTRICT, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT gateway_identity_pairing_start_state_check CHECK ( + (state='starting' AND revision_id IS NULL) OR + (state='paired' AND revision_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_expiry + ON gateway_identity_pairing_start_reservation(state,expires_at); + +INSERT INTO gateway_identity_pairing_start_reservation(singleton,attempt_id,state,revision_id,expires_at) +SELECT true,exchange.id,'paired',revision.id,exchange.expires_at +FROM gateway_identity_configuration_revisions revision +JOIN gateway_identity_onboarding_exchanges exchange ON exchange.revision_id=revision.id +WHERE revision.state IN ('draft','validated','failed') + AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') +ORDER BY revision.created_at DESC,exchange.created_at DESC +LIMIT 1 +ON CONFLICT DO NOTHING; diff --git a/apps/api/migrations/0072_identity_secret_cleanup_claim_lifecycle.sql b/apps/api/migrations/0072_identity_secret_cleanup_claim_lifecycle.sql new file mode 100644 index 0000000..3d27f8c --- /dev/null +++ b/apps/api/migrations/0072_identity_secret_cleanup_claim_lifecycle.sql @@ -0,0 +1,34 @@ +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '60s'; + +ALTER TABLE gateway_identity_secret_cleanup_queue + ADD COLUMN IF NOT EXISTS status text, + ADD COLUMN IF NOT EXISTS claim_token uuid, + ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz; + +ALTER TABLE gateway_identity_secret_cleanup_queue + DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_status_check, + DROP CONSTRAINT IF EXISTS gateway_identity_secret_cleanup_claim_check; + +UPDATE gateway_identity_secret_cleanup_queue +SET status='pending',claim_token=NULL,lease_expires_at=NULL,updated_at=now() +WHERE status IS NULL + OR status NOT IN ('pending','claimed') + OR (status='pending' AND (claim_token IS NOT NULL OR lease_expires_at IS NOT NULL)) + OR (status='claimed' AND (claim_token IS NULL OR lease_expires_at IS NULL)); + +ALTER TABLE gateway_identity_secret_cleanup_queue + ALTER COLUMN status SET DEFAULT 'pending', + ALTER COLUMN status SET NOT NULL, + ADD CONSTRAINT gateway_identity_secret_cleanup_status_check + CHECK (status IN ('pending','claimed')), + ADD CONSTRAINT gateway_identity_secret_cleanup_claim_check + CHECK ( + (status='pending' AND claim_token IS NULL AND lease_expires_at IS NULL) OR + (status='claimed' AND claim_token IS NOT NULL AND lease_expires_at IS NOT NULL) + ); + +DROP INDEX IF EXISTS idx_gateway_identity_secret_cleanup_due; + +CREATE INDEX idx_gateway_identity_secret_cleanup_due + ON gateway_identity_secret_cleanup_queue(status,not_before,lease_expires_at,updated_at); diff --git a/apps/api/migrations/0073_identity_pairing_start_reservation_upgrade.sql b/apps/api/migrations/0073_identity_pairing_start_reservation_upgrade.sql new file mode 100644 index 0000000..7903e9d --- /dev/null +++ b/apps/api/migrations/0073_identity_pairing_start_reservation_upgrade.sql @@ -0,0 +1,89 @@ +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '60s'; + +-- 0071 was briefly released with only the singleton reservation lease fields. +-- Add the durable lifecycle columns without rewriting that already-applied +-- migration, then reconcile the singleton with the canonical outstanding +-- pairing (when one exists). +ALTER TABLE gateway_identity_pairing_start_reservation + ADD COLUMN IF NOT EXISTS state text, + ADD COLUMN IF NOT EXISTS revision_id uuid, + ADD COLUMN IF NOT EXISTS updated_at timestamptz; + +UPDATE gateway_identity_pairing_start_reservation +SET state=CASE WHEN revision_id IS NULL THEN 'starting' ELSE 'paired' END, + updated_at=COALESCE(updated_at,created_at,now()) +WHERE state IS NULL + OR state NOT IN ('starting','paired') + OR (state='starting' AND revision_id IS NOT NULL) + OR (state='paired' AND revision_id IS NULL) + OR updated_at IS NULL; + +WITH canonical_pairing AS ( + SELECT exchange.id AS attempt_id, + revision.id AS revision_id, + exchange.expires_at + FROM gateway_identity_configuration_revisions revision + JOIN gateway_identity_onboarding_exchanges exchange + ON exchange.revision_id=revision.id + WHERE revision.state IN ('draft','validated','failed') + AND NOT (exchange.status='cancelled' AND exchange.cleanup_status='completed') + ORDER BY revision.created_at DESC,exchange.created_at DESC + LIMIT 1 +) +INSERT INTO gateway_identity_pairing_start_reservation( + singleton,attempt_id,state,revision_id,expires_at,updated_at +) +SELECT true,attempt_id,'paired',revision_id,expires_at,now() +FROM canonical_pairing +ON CONFLICT (singleton) DO UPDATE +SET attempt_id=EXCLUDED.attempt_id, + state=EXCLUDED.state, + revision_id=EXCLUDED.revision_id, + expires_at=EXCLUDED.expires_at, + updated_at=now(); + +ALTER TABLE gateway_identity_pairing_start_reservation + ALTER COLUMN state SET DEFAULT 'starting', + ALTER COLUMN state SET NOT NULL, + ALTER COLUMN updated_at SET DEFAULT now(), + ALTER COLUMN updated_at SET NOT NULL; + +ALTER TABLE gateway_identity_pairing_start_reservation + DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_value_check, + DROP CONSTRAINT IF EXISTS gateway_identity_pairing_start_state_check; + +ALTER TABLE gateway_identity_pairing_start_reservation + ADD CONSTRAINT gateway_identity_pairing_start_state_value_check + CHECK (state IN ('starting','paired')), + ADD CONSTRAINT gateway_identity_pairing_start_state_check CHECK ( + (state='starting' AND revision_id IS NULL) OR + (state='paired' AND revision_id IS NOT NULL) + ); + +DO $migration$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid='gateway_identity_pairing_start_reservation'::regclass + AND contype='f' + AND pg_get_constraintdef(oid) LIKE 'FOREIGN KEY (revision_id)%' + ) THEN + ALTER TABLE gateway_identity_pairing_start_reservation + ADD CONSTRAINT gateway_identity_pairing_start_revision_fk + FOREIGN KEY (revision_id) + REFERENCES gateway_identity_configuration_revisions(id) + ON DELETE RESTRICT; + END IF; +END +$migration$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_pairing_start_revision_unique + ON gateway_identity_pairing_start_reservation(revision_id) + WHERE revision_id IS NOT NULL; + +DROP INDEX IF EXISTS idx_gateway_identity_pairing_start_expiry; + +CREATE INDEX idx_gateway_identity_pairing_start_expiry + ON gateway_identity_pairing_start_reservation(state,expires_at); diff --git a/apps/api/migrations/0074_identity_pairing_error_categories.sql b/apps/api/migrations/0074_identity_pairing_error_categories.sql new file mode 100644 index 0000000..5935c99 --- /dev/null +++ b/apps/api/migrations/0074_identity_pairing_error_categories.sql @@ -0,0 +1,32 @@ +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '60s'; + +-- Pairing recovery added explicit, redacted error categories after 0069 was +-- released. Keep the database allow-list in lockstep so PostgreSQL can persist +-- the reason an administrator must resolve instead of hiding it behind a CHECK +-- violation. +ALTER TABLE gateway_identity_onboarding_exchanges + DROP CONSTRAINT IF EXISTS gateway_identity_onboarding_error_category_check; + +ALTER TABLE gateway_identity_onboarding_exchanges + ADD CONSTRAINT gateway_identity_onboarding_error_category_check + CHECK (last_error_category IS NULL OR last_error_category IN ( + 'metadata_submission_failed','exchange_status_unavailable','credential_delivery_failed', + 'exchange_completion_failed','exchange_expired','remote_exchange_failed','remote_state_invalid', + 'security_event_configuration_invalid','security_event_connection_conflict', + 'security_event_discovery_failed','security_event_management_token_failed', + 'security_event_stream_create_failed','security_event_stream_response_invalid', + 'security_event_receiver_activation_failed','security_event_preparation_failed', + 'security_event_retirement_pending','security_event_credential_handoff_unsafe', + 'security_event_connection_binding_missing','security_event_connection_binding_unavailable', + 'security_event_connection_binding_invalid','security_event_connection_binding_mismatch', + 'pairing_step_failed', + 'cleanup_revision_unavailable','cleanup_security_event_unavailable', + 'cleanup_security_event_configuration_invalid','cleanup_security_event_connection_conflict', + 'cleanup_security_event_discovery_failed','cleanup_security_event_management_token_failed', + 'cleanup_security_event_stream_create_failed','cleanup_security_event_stream_response_invalid', + 'cleanup_security_event_receiver_activation_failed','cleanup_security_event_preparation_failed', + 'cleanup_security_event_retirement_pending','cleanup_security_event_connection_cleanup_failed', + 'cleanup_security_event_secret_cleanup_failed','cleanup_security_event_credential_handoff_unsafe', + 'cleanup_security_event_failed','cleanup_secret_store_failed','cleanup_finalize_failed' + )); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fbf66db..d4616a0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1035,19 +1035,25 @@ export type IdentityPairingStatus = | 'credentials_saved' | 'completed' | 'failed' - | 'expired'; + | 'expired' + | 'cancelled'; + +export type IdentityPairingCleanupStatus = 'none' | 'pending' | 'completed'; export interface IdentityPairingExchange { id: string; revisionId: string; remoteExchangeId: string; status: IdentityPairingStatus; + cleanupStatus: IdentityPairingCleanupStatus; remoteVersion: number; expiresAt: string; version: number; lastErrorCategory?: string; authCenterAuditId?: string; lastTraceId?: string; + cancelledAt?: string; + cleanupCompletedAt?: string; createdAt: string; updatedAt: string; } From b1791623306571466ab1943fcdb4459117fdbcf5 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 18:31:38 +0800 Subject: [PATCH 19/20] =?UTF-8?q?fix(web):=20=E5=AE=8C=E5=96=84=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=AE=A4=E8=AF=81=E6=81=A2=E5=A4=8D=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E9=85=8D=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。 验证:pnpm test;pnpm lint;pnpm build。 --- apps/web/src/App.tsx | 60 +++- apps/web/src/api.test.ts | 46 +++ apps/web/src/api.ts | 16 +- .../src/components/ui/confirm-dialog.test.tsx | 29 ++ apps/web/src/components/ui/confirm-dialog.tsx | 65 +++- apps/web/src/lib/oidc-browser-session.test.ts | 43 ++- apps/web/src/lib/oidc-browser-session.ts | 24 ++ .../pages/admin/UnifiedIdentityPanel.test.tsx | 198 ++++++++++- .../src/pages/admin/UnifiedIdentityPanel.tsx | 313 ++++++++++++++---- 9 files changed, 696 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/components/ui/confirm-dialog.test.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0749ae7..6870008 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -122,7 +122,10 @@ import { persistAccessToken, readStoredAccessToken, } from './lib/auth-storage'; -import { restoreOIDCBrowserSession } from './lib/oidc-browser-session'; +import { + reconcileOIDCBrowserSessionAfterRuntimeChange, + restoreOIDCBrowserSession, +} from './lib/oidc-browser-session'; import { consumeOIDCCallbackError, loadOIDCRuntimeConfiguration, @@ -260,7 +263,15 @@ export function App() { const [state, setState] = useState('idle'); const [error, setError] = useState(''); const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError()); - const [oidcEnabled, setOIDCEnabled] = useState(false); + const [oidcEnabled, setOIDCEnabled] = useState(false); + const currentCredentialRef = useRef(token); + const resetAuthenticatedSessionRef = useRef<() => void>(() => undefined); + currentCredentialRef.current = token; + resetAuthenticatedSessionRef.current = () => { + resetAuthenticatedSession(); + setAuthMode('login'); + setError('统一认证配置已变更,当前登录会话已失效,请重新登录。'); + }; const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); @@ -294,29 +305,48 @@ export function App() { useEffect(() => { let cancelled = false; - const loadIdentityRuntime = async (force = false) => { - const configuration = await loadOIDCRuntimeConfiguration(force); - if (cancelled) return; - setOIDCEnabled(configuration.enabled && configuration.oidcLogin); - if (oidcCallbackError || readStoredAccessToken() || !configuration.enabled || !configuration.oidcLogin) return; - const restored = await restoreOIDCBrowserSession(); - if (!restored || cancelled) return; + const applyRestoredSession = (restored: NonNullable>>) => { + if (cancelled) return; setCurrentUser(restored.user); loadedDataKeysRef.current.add('currentUser'); setToken(restored.credential); setState('idle'); setError(''); - }; - const runtimeChanged = () => { void loadIdentityRuntime(true); }; - window.addEventListener('identity-runtime-changed', runtimeChanged); - void loadIdentityRuntime().catch((err) => { + }; + const loadIdentityRuntime = async (force = false) => { + const configuration = await loadOIDCRuntimeConfiguration(force); + if (cancelled) return; + const identityEnabled = configuration.enabled && configuration.oidcLogin; + setOIDCEnabled(identityEnabled); + if (force && currentCredentialRef.current === OIDC_BROWSER_SESSION_CREDENTIAL) { + await reconcileOIDCBrowserSessionAfterRuntimeChange({ + credential: currentCredentialRef.current, + oidcEnabled: identityEnabled, + onReset: () => { + if (!cancelled) resetAuthenticatedSessionRef.current(); + }, + onRestored: applyRestoredSession, + }); + return; + } + if (oidcCallbackError || readStoredAccessToken() || !identityEnabled) return; + const restored = await restoreOIDCBrowserSession(); + if (!restored || cancelled) return; + applyRestoredSession(restored); + }; + const handleLoadError = (err: unknown) => { if (cancelled) return; setState('error'); setError(err instanceof Error ? err.message : '统一认证登录失败'); - }); + }; + const runtimeChanged = () => { + void loadIdentityRuntime(true).catch(handleLoadError); + }; + window.addEventListener('identity-runtime-changed', runtimeChanged); + void loadIdentityRuntime().catch(handleLoadError); return () => { cancelled = true; - window.removeEventListener('identity-runtime-changed', runtimeChanged); + window.removeEventListener('identity-runtime-changed', runtimeChanged); }; }, [oidcCallbackError]); useEffect(() => { diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index a49aa75..6b6e3d4 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + cancelIdentityPairing, connectSecurityEventTransmitter, deleteOIDCBrowserSession, GatewayApiError, @@ -7,6 +8,7 @@ import { getCurrentUser, OIDC_BROWSER_SESSION_CREDENTIAL, startIdentityPairing, + retireIdentityPairingSecurityEventConflict, validateIdentityRevision, } from './api'; @@ -22,6 +24,10 @@ describe('Gateway provisioning errors', () => { ['OIDC_SESSION_REFRESH_UNAVAILABLE', '认证中心暂时不可用,请稍后重试'], ['OIDC_SESSION_STORE_UNAVAILABLE', '登录会话存储暂时不可用,请稍后重试'], ['OIDC_SESSION_CSRF_REJECTED', '登录会话来源校验失败,请刷新后重试'], + ['IDENTITY_PAIRING_IN_PROGRESS', '请先完成或放弃当前统一认证配对'], + ['IDENTITY_PAIRING_NOT_CANCELLABLE', '当前统一认证配置不能放弃'], + ['IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE', '当前配对状态已变化,请刷新后重试'], + ['IDENTITY_VERSION_CONFLICT', '统一认证状态已变化,请刷新后重试'], ] as const; for (const [code, expected] of cases) { @@ -80,6 +86,46 @@ describe('identity configuration transport', () => { expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); expect(init.credentials).toBe('include'); }); + + it('cancels a pairing with its own ETag and an idempotency key', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: 'pairing', status: 'cancelled', cleanupStatus: 'pending', version: 8, + }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await cancelIdentityPairing('manager-token', 'pairing', 7); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/admin/system/identity/pairings/pairing/cancel'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-cancel-'); + expect(init.credentials).toBe('include'); + }); + + it('retires an SSF conflict through the pairing-scoped recovery endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: 'pairing', status: 'credentials_saved', version: 7, + }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + })); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' }); + + await retireIdentityPairingSecurityEventConflict('manager-token', 'pairing', 7); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/admin/system/identity/pairings/pairing/retire-conflicting-security-event'); + expect(init.method).toBe('POST'); + expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"'); + expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-retire-ssf-conflict-'); + }); }); describe('security event connection transport', () => { diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8fb2644..ea96648 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1019,6 +1019,14 @@ export async function getIdentityPairing(token: string, pairingId: string): Prom return request(`${identityConfigurationPath}/pairings/${pairingId}`, { token }); } +export async function cancelIdentityPairing(token: string, pairingId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings/${pairingId}/cancel`, 'POST', version, undefined, 'cancel'); +} + +export async function retireIdentityPairingSecurityEventConflict(token: string, pairingId: string, version: number): Promise { + return identityConfigurationWrite(token, `${identityConfigurationPath}/pairings/${pairingId}/retire-conflicting-security-event`, 'POST', version, undefined, 'retire-ssf-conflict'); +} + export async function updateIdentityRevisionPolicy( token: string, revisionId: string, @@ -1036,10 +1044,6 @@ export async function activateIdentityRevision(token: string, revisionId: string return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate'); } -export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise { - return identityConfigurationWrite(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback'); -} - export async function disableIdentityConfiguration(token: string, version: number): Promise { return identityConfigurationWrite(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable'); } @@ -1267,6 +1271,10 @@ const gatewayProvisioningErrorMessages: Record = { OIDC_SESSION_REFRESH_UNAVAILABLE: '认证中心暂时不可用,请稍后重试', OIDC_SESSION_STORE_UNAVAILABLE: '登录会话存储暂时不可用,请稍后重试', OIDC_SESSION_CSRF_REJECTED: '登录会话来源校验失败,请刷新后重试', + IDENTITY_PAIRING_IN_PROGRESS: '请先完成或放弃当前统一认证配对', + IDENTITY_PAIRING_NOT_CANCELLABLE: '当前统一认证配置不能放弃', + IDENTITY_PAIRING_CONFLICT_NOT_RESOLVABLE: '当前配对状态已变化,请刷新后重试', + IDENTITY_VERSION_CONFLICT: '统一认证状态已变化,请刷新后重试', }; export function gatewayErrorMessage(details: GatewayErrorDetails) { diff --git a/apps/web/src/components/ui/confirm-dialog.test.tsx b/apps/web/src/components/ui/confirm-dialog.test.tsx new file mode 100644 index 0000000..38f4448 --- /dev/null +++ b/apps/web/src/components/ui/confirm-dialog.test.tsx @@ -0,0 +1,29 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ConfirmDialog } from './confirm-dialog'; + +describe('ConfirmDialog', () => { + it('associates its title and destructive-action description', () => { + const html = renderToStaticMarkup( + undefined} onConfirm={() => undefined} />, + ); + + const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1]; + const describedBy = html.match(/aria-describedby="([^"]+)"/)?.[1]; + expect(labelledBy).toBeTruthy(); + expect(describedBy).toBeTruthy(); + expect(html).toContain(`id="${labelledBy}"`); + expect(html).toContain(`id="${describedBy}"`); + expect(html).toContain('tabindex="-1"'); + }); + + it('keeps the modal container focusable while its actions are loading', () => { + const html = renderToStaticMarkup( + undefined} onConfirm={() => undefined} />, + ); + + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('tabindex="-1"'); + expect(html.match(/disabled=""/g)?.length).toBe(2); + }); +}); diff --git a/apps/web/src/components/ui/confirm-dialog.tsx b/apps/web/src/components/ui/confirm-dialog.tsx index b295e11..42c9faa 100644 --- a/apps/web/src/components/ui/confirm-dialog.tsx +++ b/apps/web/src/components/ui/confirm-dialog.tsx @@ -18,32 +18,79 @@ export interface ConfirmDialogProps { } export function ConfirmDialog(props: ConfirmDialogProps) { - const { onCancel, open } = props; + const { open } = props; + const titleId = React.useId(); + const descriptionId = React.useId(); + const dialogRef = React.useRef(null); + const cancelRef = React.useRef(null); + const loadingRef = React.useRef(props.loading); + const onCancelRef = React.useRef(props.onCancel); + loadingRef.current = props.loading; + onCancelRef.current = props.onCancel; React.useEffect(() => { if (!open) return undefined; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusFrame = window.requestAnimationFrame(() => { + if (cancelRef.current && !cancelRef.current.disabled) cancelRef.current.focus(); + else dialogRef.current?.focus(); + }); function onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') onCancel(); + if (event.key === 'Escape' && !loadingRef.current) { + event.preventDefault(); + onCancelRef.current(); + return; + } + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = Array.from(dialogRef.current.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')); + if (!focusable.length) { + event.preventDefault(); + dialogRef.current.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === dialogRef.current) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (!dialogRef.current.contains(document.activeElement)) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } } window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [onCancel, open]); + return () => { + window.cancelAnimationFrame(focusFrame); + window.removeEventListener('keydown', onKeyDown); + previouslyFocused?.focus(); + }; + }, [open]); + + React.useEffect(() => { + if (open && props.loading) dialogRef.current?.focus(); + }, [open, props.loading]); if (!open) return null; return (
-
-
+
+
- {props.title} - {props.description &&

{props.description}

} + {props.title} + {props.description &&

{props.description}

} {props.children}
- '); + }); +}); + +describe('identity confirmation freshness', () => { + it('requires a fresh confirmation after conflict responses', () => { + expect(isStaleIdentityOperation(new GatewayApiError({ message: 'conflict', status: 409 }))).toBe(true); + expect(isStaleIdentityOperation(new GatewayApiError({ message: 'changed', status: 412 }))).toBe(true); + expect(isStaleIdentityOperation(new GatewayApiError({ message: 'retry', status: 503 }))).toBe(false); + }); +}); + +describe('identity runtime operation completion', () => { + it('treats a successful disable followed by refresh 401 as submitted and requests re-login', async () => { + const onSuccess = vi.fn(); + const onRuntimeChanged = vi.fn(); + + await expect(executeIdentityOperation({ + action: vi.fn().mockResolvedValue(undefined), + onRuntimeChanged, + onSuccess, + refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })), + runtimeChanged: true, + success: '统一认证已禁用,本地管理登录继续可用。', + })).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录')); + expect(onRuntimeChanged).toHaveBeenCalledOnce(); + }); + + it('treats a successful activation followed by refresh 401 as submitted and requests re-login', async () => { + const onSuccess = vi.fn(); + const onRuntimeChanged = vi.fn(); + + await expect(executeIdentityOperation({ + action: vi.fn().mockResolvedValue(undefined), + onRuntimeChanged, + onSuccess, + refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unauthorized', status: 401 })), + runtimeChanged: true, + success: '统一认证已激活,用户需要重新登录。', + })).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('请重新登录')); + expect(onRuntimeChanged).toHaveBeenCalledOnce(); + }); + + it('does not reverse a committed runtime change when the follow-up refresh is unavailable', async () => { + const onSuccess = vi.fn(); + const onRuntimeChanged = vi.fn(); + + await expect(executeIdentityOperation({ + action: vi.fn().mockResolvedValue(undefined), + onRuntimeChanged, + onSuccess, + refresh: vi.fn().mockRejectedValue(new GatewayApiError({ message: 'unavailable', status: 503 })), + runtimeChanged: true, + success: '统一认证已热切换启用,无需重启。', + })).resolves.toBe(true); + expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('操作已提交')); + expect(onSuccess).toHaveBeenCalledWith(expect.stringContaining('状态刷新失败')); + expect(onSuccess).not.toHaveBeenCalledWith(expect.stringContaining('请重新登录')); + expect(onRuntimeChanged).toHaveBeenCalledOnce(); + }); +}); + +describe('IdentityRuntimeActions remote-resource safety', () => { + it('hides active re-pairing for every active configuration', () => { + const html = renderToStaticMarkup( undefined} + onValidate={() => undefined} + />); + + expect(html).not.toContain('重新配对'); + expect(html).toContain('请先禁用统一认证'); + expect(html).toContain('OAuth/SSF 资源'); + expect(html).toContain('重新验证'); + expect(html).toContain('禁用统一认证'); + }); + + it('replaces unsafe previous-version rollback with a new onboarding-code instruction', () => { + const html = renderToStaticMarkup( undefined} + onValidate={() => undefined} + />); + + expect(html).not.toContain('回滚上一版本'); + expect(html).toContain('不能直接回滚'); + expect(html).toContain('新的接入码'); + }); +}); + +function identityRevision( + state: IdentityConfigurationRevision['state'], + capabilities: string[], +): IdentityConfigurationRevision { + return { + id: `${state}-revision`, + state, + schemaVersion: 1, + authCenterUrl: 'https://auth.example.com', + issuer: 'https://issuer.example.com', + scopes: ['openid'], + capabilities, + rolePrefix: 'gateway.', + localTenantKey: 'default', + publicBaseUrl: 'https://api.gateway.example.com', + webBaseUrl: 'https://gateway.example.com', + jitEnabled: true, + legacyJwtEnabled: false, + tokenIntrospection: capabilities.includes('token_introspection'), + sessionRevocation: capabilities.includes('session_revocation'), + sessionIdleSeconds: 1800, + sessionAbsoluteSeconds: 28800, + sessionRefreshSeconds: 60, + version: 1, + createdAt: '2026-07-17T14:00:00+08:00', + updatedAt: '2026-07-17T14:00:00+08:00', + }; +} diff --git a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx index 3ae4700..215b316 100644 --- a/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx +++ b/apps/web/src/pages/admin/UnifiedIdentityPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, type FormEvent } from 'react'; -import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { CheckCircle2, Link2, RefreshCw, ShieldCheck, Unplug } from 'lucide-react'; import type { IdentityConfigurationRevision, IdentityConfigurationView, @@ -7,16 +7,18 @@ import type { } from '@easyai-ai-gateway/contracts'; import { activateIdentityRevision, + cancelIdentityPairing, disableIdentityConfiguration, + GatewayApiError, getIdentityConfiguration, - rollbackIdentityRevision, + retireIdentityPairingSecurityEventConflict, startIdentityPairing, updateIdentityRevisionPolicy, validateIdentityRevision, } from '../../api'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui'; -const terminalPairingStatuses = new Set(['completed', 'failed', 'expired']); +const terminalPairingStatuses = new Set(['completed', 'failed', 'expired', 'cancelled']); export function UnifiedIdentityPanel(props: { token: string }) { const [configuration, setConfiguration] = useState(null); @@ -27,11 +29,14 @@ export function UnifiedIdentityPanel(props: { token: string }) { const [loading, setLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); - const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null); + const [pollError, setPollError] = useState(''); + const [confirmAction, setConfirmAction] = useState<'disable' | 'cancelPairing' | 'retireSecurityEvent' | null>(null); + const operationInProgress = useRef(false); async function refresh() { const next = await getIdentityConfiguration(props.token); setConfiguration(next); + setPollError(''); if (next.draft) { setLocalTenantKey(next.draft.localTenantKey); setLegacyJwtEnabled(next.draft.legacyJwtEnabled); @@ -45,27 +50,40 @@ export function UnifiedIdentityPanel(props: { token: string }) { useEffect(() => { const status = configuration?.pairing?.status; - if (!status || terminalPairingStatuses.has(status)) return undefined; + const cleanupPending = status === 'cancelled' && configuration?.pairing?.cleanupStatus === 'pending'; + if (!status || (terminalPairingStatuses.has(status) && !cleanupPending)) return undefined; const timer = window.setInterval(() => { - void refresh().catch(() => undefined); + void refresh().catch(() => setPollError('统一认证状态自动刷新失败,请点击“刷新”重试。')); }, 2000); return () => window.clearInterval(timer); - }, [configuration?.pairing?.status, props.token]); + }, [configuration?.pairing?.cleanupStatus, configuration?.pairing?.status, props.token]); async function run(action: () => Promise, success: string, runtimeChanged = false) { + if (operationInProgress.current) return false; + operationInProgress.current = true; setLoading(true); setError(''); setMessage(''); try { - await action(); - await refresh(); - setMessage(success); - if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed')); - return true; + return await executeIdentityOperation({ + action, + onRuntimeChanged: () => window.dispatchEvent(new Event('identity-runtime-changed')), + onSuccess: setMessage, + refresh, + runtimeChanged, + success, + }); } catch (caught) { + try { + await refresh(); + } catch { + // Preserve the original operation error; the manual refresh remains available. + } setError(errorMessage(caught, '统一认证操作失败')); - return false; + if (isStaleIdentityOperation(caught)) setConfirmAction(null); + return false; } finally { + operationInProgress.current = false; setLoading(false); } } @@ -89,12 +107,22 @@ export function UnifiedIdentityPanel(props: { token: string }) { }), 'Gateway 本地认证策略已保存。'); } + async function retireConflictingSecurityEventConnection() { + const pairing = configuration?.pairing; + if (!pairing) return false; + return run(async () => { + await retireIdentityPairingSecurityEventConflict(props.token, pairing.id, pairing.version); + }, '现有安全事件连接已进入安全退役;退役完成后本次配对会自动继续。'); + } + const active = configuration?.active; const draft = configuration?.draft; const previous = configuration?.previous; const pairing = configuration?.pairing; const runtime = configuration?.runtime; - const shouldShowPairing = showPairingForm || (!active && !pairing); + const cleanupPending = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'pending'; + const cleanupCompleted = pairing?.status === 'cancelled' && pairing.cleanupStatus === 'completed'; + const shouldShowPairing = !active && (showPairingForm || !pairing || cleanupCompleted); return (
@@ -105,11 +133,14 @@ export function UnifiedIdentityPanel(props: { token: string }) {
{active ? '已启用' : '未启用'} - +
- {(message || error) &&
{error || message}
} + {(message || error || pollError) &&
{error || pollError || message}
} {active && runtime && ( @@ -128,42 +159,23 @@ export function UnifiedIdentityPanel(props: { token: string }) { -
- - - {previous && ( - - )} - -
+ setConfirmAction('disable')} + onValidate={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)} + />
)} - {pairing && !terminalPairingStatuses.has(pairing.status) && ( - - -
-
正在配对认证中心{pairingStatusLabel(pairing.status)}
- {pairing.status} -
-
- Exchange: {shortID(pairing.remoteExchangeId)} - 有效期至: {new Date(pairing.expiresAt).toLocaleString()} - {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} - {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} -
-
-
+ {pairing && (!terminalPairingStatuses.has(pairing.status) || cleanupPending) && ( + setConfirmAction('cancelPairing')} + onResolveConnectionConflict={() => setConfirmAction('retireSecurityEvent')} + /> )} {draft && pairing?.status === 'completed' && ( @@ -198,22 +210,29 @@ export function UnifiedIdentityPanel(props: { token: string }) { )} {draft.state === 'failed' && 验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。} + )} - {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && ( -
- 配对{pairing.status === 'expired' ? '已过期' : '失败'}:{pairing.lastErrorCategory || pairing.status}。请在认证中心生成新的接入码。 - -
+ {pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && !cleanupPending && ( +
+ + {cleanupCompleted ? '旧配对已放弃,临时凭据与本次创建的 SSF 连接已清理。' : `配对${pairing.status === 'expired' ? '已过期' : '失败'}:${pairingFailureMessage(pairing.lastErrorCategory)}。`} + + {cleanupCompleted + ? + : } +
)} {shouldShowPairing && ( -
{active ? '重新配对认证中心' : '接入认证中心'}

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

+
接入认证中心

认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。

接入码 10 分钟有效
@@ -229,7 +248,6 @@ export function UnifiedIdentityPanel(props: { token: string }) {
- {active && }
@@ -237,28 +255,113 @@ export function UnifiedIdentityPanel(props: { token: string }) { )} setConfirmAction(null)} onConfirm={async () => { - let succeeded = false; - if (confirmAction === 'rollback' && previous) { - succeeded = await run(() => rollbackIdentityRevision(props.token, previous.id, previous.version), '统一认证已回滚,用户需要重新登录。', true); - } else if (confirmAction === 'disable' && active) { - succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true); + let succeeded = false; + if (confirmAction === 'disable' && active) { + succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true); + } else if (confirmAction === 'cancelPairing' && pairing) { + succeeded = await run(() => cancelIdentityPairing(props.token, pairing.id, pairing.version), '已放弃当前草稿,后台正在安全清理临时资源。'); + } else if (confirmAction === 'retireSecurityEvent') { + succeeded = await retireConflictingSecurityEventConnection(); } - if (succeeded) setConfirmAction(null); + if (succeeded) setConfirmAction(null); }} /> ); } +export function PairingProgressCard({ pairing, loading, onCancel, onResolveConnectionConflict }: { + pairing: NonNullable; + loading: boolean; + onCancel: () => void; + onResolveConnectionConflict?: () => void; +}) { + const cleaning = pairing.status === 'cancelled' && pairing.cleanupStatus === 'pending'; + const connectionConflict = pairing.lastErrorCategory === 'security_event_connection_conflict'; + const unsafeCredentialHandoff = pairing.lastErrorCategory === 'security_event_credential_handoff_unsafe'; + return ( + + +
+
{cleaning ? '正在清理已放弃的配对' : '正在配对认证中心'}{pairingStatusLabel(pairing.status, pairing.cleanupStatus)}
+ {cleaning ? 'cleanup_pending' : pairing.status} +
+ {pairing.lastErrorCategory && ( +
+ {pairingFailureMessage(pairing.lastErrorCategory)}(错误分类:{safePairingCategory(pairing.lastErrorCategory)})。{cleaning + ? '后台会继续安全清理。' + : connectionConflict + ? '此问题不会通过自动重试恢复;请安全退役现有连接,或放弃本次配对。' + : unsafeCredentialHandoff + ? '此问题不会自动恢复;请先在原配置下断开旧连接,或放弃本次配对。' + : '后台正在自动重试;也可以放弃本次配对后重新配置。'} +
+ )} +
+ Exchange: {shortID(pairing.remoteExchangeId)} + 有效期至: {new Date(pairing.expiresAt).toLocaleString()} + {pairing.lastTraceId && Trace ID: {pairing.lastTraceId}} + {pairing.authCenterAuditId && Auth Center Audit ID: {pairing.authCenterAuditId}} +
+ {!cleaning &&
+ {connectionConflict && } + +
} +
+
+ ); +} + +export function IdentityRuntimeActions({ + loading, + onDisable, + onValidate, + previous, +}: { + loading: boolean; + onDisable: () => void; + onValidate: () => void; + previous: IdentityConfigurationRevision | null; +}) { + return ( +
+ + + 当前 Active 关联认证中心的 OAuth/SSF 资源;请先禁用统一认证,再使用新接入码重新配置。 + + {previous && ( + + 旧版本的远端 OAuth/SSF 资源可能已经变化,不能直接回滚;请使用新的接入码恢复。 + + )} + +
+ ); +} + function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) { return (
@@ -294,10 +397,84 @@ function defaultPairingInput(): IdentityPairingInput { }; } -function pairingStatusLabel(status: string) { +function pairingStatusLabel(status: string, cleanupStatus?: string) { + if (status === 'cancelled' && cleanupStatus === 'pending') return '正在销毁临时凭据并清理由本次 Revision 创建的安全事件连接'; return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record)[status] ?? status; } +const pairingFailureMessages: Readonly> = { + security_event_configuration_invalid: '安全事件配置不完整或无效', + security_event_discovery_failed: '无法获取或校验 SSF Discovery(网络、HTTP、JSON、Issuer 或端点异常)', + security_event_connection_conflict: '已有安全事件连接与本次接入配置冲突', + security_event_credential_handoff_unsafe: '旧安全事件连接与本次认证中心或服务 Client 不匹配;系统不会发送旧凭据,请先在原配置下断开旧连接', + security_event_connection_binding_missing: '安全事件连接尚未建立', + security_event_connection_binding_unavailable: '安全事件连接状态暂时不可用', + security_event_connection_binding_invalid: '安全事件连接缺少 Revision 绑定信息', + security_event_connection_binding_mismatch: '认证中心返回的安全事件 Audience 或连接归属与本次配置不一致', + security_event_retirement_pending: '现有安全事件连接正在安全退役,完成后本次配对会自动继续', + security_event_management_token_failed: '服务客户端暂时无法取得安全事件管理 Token', + security_event_stream_create_failed: '安全事件 Stream 创建失败', + security_event_stream_response_invalid: '安全事件 Stream 返回内容不符合预期', + security_event_receiver_activation_failed: 'Gateway 安全事件 Receiver 启动失败', + security_event_preparation_failed: '安全事件能力准备失败', + metadata_submission_failed: '业务地址提交失败', + exchange_status_unavailable: '认证中心资源准备状态暂时不可用', + credential_delivery_failed: '一次性机器凭据领取或保存失败', + exchange_expired: '一次性接入 Exchange 已过期', + remote_exchange_failed: '认证中心未能完成应用资源准备,请结合 Audit ID 排查', + remote_state_invalid: '认证中心返回了不符合当前流程的 Exchange 状态', + exchange_completion_failed: 'Gateway 暂时无法确认认证中心 Exchange 已完成', + cleanup_revision_unavailable: '待清理的统一认证草稿暂时不可用', + cleanup_security_event_unavailable: '安全事件清理服务暂时不可用', + cleanup_security_event_retirement_pending: '安全事件 Stream 已断开,正在等待安全退役窗口结束', + cleanup_security_event_connection_cleanup_failed: '安全事件连接清理暂时失败', + cleanup_security_event_secret_cleanup_failed: '安全事件临时凭据清理暂时失败', + cleanup_security_event_failed: '安全事件连接清理暂时失败', + cleanup_secret_store_failed: '统一认证临时凭据清理暂时失败', + cleanup_finalize_failed: '清理结果暂时无法确认', +}; + +export function pairingFailureMessage(category?: string) { + return pairingFailureMessages[category ?? ''] ?? '统一认证配对步骤暂时失败'; +} + +function safePairingCategory(category: string) { + return Object.hasOwn(pairingFailureMessages, category) ? category : 'pairing_step_failed'; +} + +export function isStaleIdentityOperation(caught: unknown) { + return caught instanceof GatewayApiError && (caught.details.status === 409 || caught.details.status === 412); +} + +export async function executeIdentityOperation(options: { + action: () => Promise; + onRuntimeChanged: () => void; + onSuccess: (message: string) => void; + refresh: () => Promise; + runtimeChanged: boolean; + success: string; +}) { + await options.action(); + try { + await options.refresh(); + } catch (caught) { + if (!options.runtimeChanged) throw caught; + const followUp = isIdentityRefreshAuthenticationLoss(caught) + ? '当前统一认证会话已失效,请重新登录。' + : '最新状态刷新失败,请稍后点击“刷新”确认。'; + options.onSuccess(`${options.success} 操作已提交;${followUp}`); + options.onRuntimeChanged(); + return true; + } + options.onSuccess(options.success); + if (options.runtimeChanged) options.onRuntimeChanged(); + return true; +} + +function isIdentityRefreshAuthenticationLoss(caught: unknown) { + return caught instanceof GatewayApiError && caught.details.status === 401; +} + function capabilityLabel(value: string) { return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record)[value] ?? value; } From 5b8178b7033708c3e5696dcba15176a912d21de4 Mon Sep 17 00:00:00 2001 From: chengcheng Date: Fri, 17 Jul 2026 18:32:04 +0800 Subject: [PATCH 20/20] =?UTF-8?q?docs(identity):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=A4=E8=AF=81=E6=81=A2=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E9=87=8D=E9=85=8D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充管理员从 Active 禁用到新接入码重配的标准流程,说明 credentials_saved 恢复、SSF 凭据交接、持久清理、Break-glass 与动态 Origin 边界,并明确历史 Revision 仅作审计、当前不支持直接回滚。 --- docs/oidc-jit-provisioning.md | 9 +-- docs/security/ssf-session-revocation.md | 17 +++++- ...standard-identity-runtime-configuration.md | 59 ++++++++++++++++--- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/docs/oidc-jit-provisioning.md b/docs/oidc-jit-provisioning.md index 770d1c2..12d1aae 100644 --- a/docs/oidc-jit-provisioning.md +++ b/docs/oidc-jit-provisioning.md @@ -21,10 +21,11 @@ OIDC、JIT 和浏览器会话不读取业务环境变量。管理员在 Auth Cen - 浏览器只保存 32 字节随机、`HttpOnly + SameSite=Strict` 的 Session Cookie。新标签页通过该 Cookie 调用 `/api/v1/me` 恢复登录态;Gateway 不签发第二枚 JWT。 - Access Token 继续保持 5 分钟。认证请求发现剩余时间不超过 60 秒时使用 Refresh Token 自动刷新;多实例通过 PostgreSQL 刷新租约保证同一版本只刷新一次,并原子保存旋转后的 Refresh Token。 - Session 闲置 30 分钟、绝对最长 8 小时。闲置 5~30 分钟后的首个请求可自动刷新;超过闲置或绝对期限不会刷新,必须重新登录。浏览器不运行定时刷新。 -- 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权的 POST、PUT、PATCH、DELETE 必须携带 `CORS_ALLOWED_ORIGIN` 白名单中的 Origin,否则返回结构化 403。 +- 所有 Web API 请求使用 `credentials: include`。Cookie 鉴权请求的 Origin 必须与当前健康 Active Revision 的 Gateway Web 地址精确匹配,否则返回结构化 403;不接受 `*`。 - Cookie 的 `Secure` 属性由 Revision 中的 Gateway API 公网地址推导:生产地址只允许 HTTPS;本地开发允许 localhost HTTP。可信 Origin 由 Gateway Web 地址精确推导,不接受 `*`。 - `POST /api/v1/auth/oidc/logout` 校验可信 Origin、删除本地 Session、使用公共 Client ID 撤销 Refresh Token,并跳转 Auth Center 退出地址;`DELETE /api/v1/auth/oidc/session` 保留为幂等的本地删除接口。 -- Session Encryption Key 由 Gateway 服务端生成,只以 Secret 引用进入 Revision,不得复用 JWT Secret,也不会进入数据库、响应、日志或浏览器。禁用统一认证会清理现有 BFF Session;回滚后用户需要重新登录。 +- 第一次接入、确认禁用或 Active Runtime 故障恢复时,跨 Origin 管理页面使用部署级精确 bootstrap CORS;生产部署建议由反向代理通过同源 `/gateway-api` 暴露 API。该配置不包含 Issuer、Client 或 Scope。 +- Session Encryption Key 由 Gateway 服务端生成,只以 Secret 引用进入 Revision,不得复用 JWT Secret,也不会进入数据库、响应、日志或浏览器。禁用统一认证会清理现有 BFF Session;重新配对后用户需要重新登录。 ## 数据和事务语义 @@ -53,8 +54,8 @@ OIDC、JIT 和浏览器会话不读取业务环境变量。管理员在 Auth Cen `ErrLocalUserRequired` 仅作为防御性内部错误保留,对外统一转换为结构化 403。 -## 验收与回滚 +## 验收与重新接入 自动化门禁通过后,依次验证本地真实 OIDC 和 `auth.51easyai.com` Staging 专用测试租户。证据应包含脱敏 Claims、网络截图、本地 Gateway 用户记录、Trace ID、Gateway/Auth Center 审计 ID及 200/401/403/503 负向证据;不得保存 Token、授权码、密码或 Secret。 -JIT 策略变更通过创建并验证新 Revision 后激活;身份配置回滚使用“系统设置 → 统一认证”的回滚操作。激活、回滚和禁用均要求本地 Break-glass Manager 可用,并清理受影响的 BFF Session。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 +Active Revision 可原位重新验证。需要改变远端身份资源时,先确认本地 Break-glass Manager,禁用当前配置,再使用 Auth Center 新接入码创建、验证和激活新 Revision;历史 `superseded` Revision 只用于审计,不能直接回滚或重新激活。已经创建的 `source=oidc` 测试投影保留为惰性数据,不自动删除、迁移或合并。 diff --git a/docs/security/ssf-session-revocation.md b/docs/security/ssf-session-revocation.md index f832f8d..a199c54 100644 --- a/docs/security/ssf-session-revocation.md +++ b/docs/security/ssf-session-revocation.md @@ -48,6 +48,19 @@ Verification 成功后仅在首次连接和主动轮换时自动启用 Stream。 “断开连接”先删除远端 Stream,再进入至少 360 秒 `retiring`,期间继续应用撤销水位和 RFC 7662,避免旧 Token 因关闭功能重新有效。远端不可用时保持 `disconnect_pending` 并指数退避重试;不会提前删除 Push Secret。收据、水位和脱敏审计数据不会清表。 +禁用统一认证也遵循相同顺序:若 Active Revision 开启了 SSF,只有远端 Stream 已删除且本地连接进入 `retiring` 后,Active 才会变为 `superseded`,BFF Session 才会清理。`disconnect_pending` 时禁用返回明确的 retirement pending,数据库 Active 保持不变;即使完整 OIDC Runtime 已 fail closed,Gateway 也会只重建 SSF 管理面完成断开,不要求 Discovery、JIT 或 BFF 先恢复。`retiring` 最终清理由服务生命周期 Worker 托管,Gateway 重启后仍会继续。 + +## 旧连接交接与配对恢复 + +Pairing 停在 `credentials_saved` 时,Manifest 和机器凭据已经保存,但 Stream 可能仍在创建、验证或退役: + +- 暂时性 Discovery/Transmitter 错误和 `security_event_retirement_pending` 会自动重试。 +- `security_event_connection_conflict` 只允许通过当前 Pairing 的“安全退役现有连接”处理。 +- `security_event_credential_handoff_unsafe` 表示旧连接与新 Issuer/Client 不匹配;系统不会把旧凭据发往新端点。管理员应在原配置下断开,或放弃当前 Pairing。 +- 放弃后等待 `cleanup_status=completed`,再从 Auth Center 生成新接入码重新配对。 + +若旧连接与新配置具备可证明的同一管理边界,Gateway 仍不会直接覆盖 S1:先用新机器凭据 S2 从新 OIDC Issuer 取得管理 Token,再经过认证读取旧 Transmitter;存在 Stream 时精确校验 Stream ID、Issuer、Receiver 和 Audience。证明成功后才原子切换凭据引用并把 S1 交给持久清理队列;失败时 S1/S2 引用和远端连接都保持不变。 + 所有写管理接口要求 Gateway `Manager` 权限、`Idempotency-Key` 和 `If-Match`: ```text @@ -58,10 +71,10 @@ POST /api/admin/system/identity/security-events/connection/rotate-credential DELETE /api/admin/system/identity/security-events/connection ``` -## 监控、回滚与验收 +## 监控、退役与验收 `GET /metrics` 输出接收结果、删除 Session 数、水位拒绝数、Verification 年龄、健康模式、内省结果、JWKS 失败和处理延迟。至少告警 Verification age 超过 120 秒、fallback 超过 5 分钟、内省失败、SET 拒绝增长以及撤销 P99 超过 3 秒。日志只记录脱敏 Trace/Audit ID 和错误类别。 -回滚时先在认证中心暂停 Stream,再通过 Gateway 安全断开;保留迁移表、水位和审计。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。 +当前禁止直接激活历史 `superseded` Revision,因为 Auth Center 会复用并修改远端 OAuth/SSF 资源,旧快照无法证明仍然匹配。恢复配置时先通过 Gateway 安全禁用并退役 Stream,再使用 Auth Center 新接入码重新配对;保留迁移表、水位和审计。Legacy HS256 Token 和 API Key 不在本协议撤销范围内。 真实链路只使用明确标记为测试用途的 Application,并且必须在自动化测试后执行。报告包含脱敏 Trace、Claims、截图、Audit ID、投递延迟、fallback 和恢复证据;需要人工或第三方操作时记录 `SKIPPED_HUMAN_OR_THIRD_PARTY_REQUIRED`。 diff --git a/docs/standard-identity-runtime-configuration.md b/docs/standard-identity-runtime-configuration.md index 0638059..365c4ab 100644 --- a/docs/standard-identity-runtime-configuration.md +++ b/docs/standard-identity-runtime-configuration.md @@ -13,22 +13,33 @@ OIDC、JIT、Introspection、BFF 和 SSF 的业务配置以 Gateway 管理 API/ ## Revision 状态机 ```text -draft -> validated -> active -> superseded +draft -> validated -> active draft | validated -> failed -superseded -> validated -> active -active -> superseded (回滚、替换或禁用) +active -> active (重新验证) +active -> superseded (禁用) +superseded (只读历史,不可重新验证或激活) ``` -同一时刻最多一个 Active Revision。Revision 保存非敏感配置与 Secret 引用,不保存机器 Secret 或 Session 加密根。关键身份字段变化时清理旧 BFF Session;回滚后用户重新登录。 +同一时刻最多一个 Active Revision。Revision 保存非敏感配置与 Secret 引用,不保存机器 Secret 或 Session 加密根。Auth Center 当前会复用并修改远端 OAuth/SSF 资源,因此旧 Revision 无法证明 Redirect URI、Scope、Audience、Client Secret 和 Stream 仍与历史快照一致。直接回滚以及 `superseded -> validated -> active` 均被服务端拒绝;恢复旧配置也必须先禁用,再使用新接入码完成一次新的远端交接。 + +## 管理员标准流程 + +1. 先确认至少一个本地 `source=gateway`、已启用且具有 Manager/Admin 权限的 Break-glass 密码凭据可登录。 +2. 若已有 Active,先点击“禁用统一认证”。启用 SSF 时,Gateway 会先删除远端 Stream;只有连接进入 `retiring` 后才封存 Active 和删除 BFF Session。 +3. 在 Auth Center 通用 Application 的“应用接入”向导选择能力、确认预览并生成一张新的接入码。 +4. 在 Gateway 填写 Auth Center、接入码、Gateway API/Web 地址和本地租户映射。 +5. 等待 Pairing `completed`,保存本地策略,执行“验证配置”,再执行“激活配置”。 + +当前流程是受控停机切换,不宣称跨配置零停机。要支持 Active 直接换绑或历史 Revision 一键回滚,必须先在 Auth Center 实现远端 OAuth/SSF 资源版本化与可证明的双版本交接。 ## 激活顺序 1. 从 Draft 和 SecretStore 构建不可变候选 Runtime。 2. 验证 Discovery、Issuer、JWKS、Audience、Tenant、Client、本地租户、Introspection 和可选 SSF Discovery。 3. 确认存在可用的本地 Break-glass Manager。 -4. 在数据库事务中把 Revision 原子设为 Active,并把旧版本设为 Superseded。 +4. 在数据库事务中确认没有其他 Active 或配对启动预留,再把 Revision 原子设为 Active。 5. 原子替换进程内 Runtime 引用;在途请求继续使用旧实例。 -6. 若内存替换失败,恢复数据库指针并保持旧 Runtime;不得部分启用。 +6. 数据库提交结果不确定时,运行时先 fail closed,再由后台协调器按数据库中的唯一 Active 自动重建;不得部分启用。 验证失败不会修改当前 Active Runtime;首次配置失败时统一认证保持关闭,本地管理登录继续可用。 @@ -38,22 +49,51 @@ active -> superseded (回滚、替换或禁用) 接入码、Exchange Token、机器 Secret 不进入 URL、数据库、日志、审计、指标或浏览器持久存储。SecretStore 写入失败时请求认证中心轮换,旧 Secret 立即失效。 +配对主状态为 `metadata_pending -> preparing -> ready -> credentials_saved -> completed`。`failed`、`expired` 和 `cancelled` 是终态;可重试步骤保留当前主状态,并只记录白名单内的脱敏错误分类。这样页面既能说明当前停在哪一步,也不会把 Discovery URL、Token、Secret 或底层响应写入数据库和浏览器。 + +`cancelled` 只表示管理员放弃 Gateway 本地的未激活配对,不伪装成已撤销认证中心中已经领取的 Exchange 或历史凭据。Gateway 使用独立的 `cleanup_status`(`none`、`pending`、`completed`)跟踪本地补偿: + +1. `POST /api/admin/system/identity/pairings/{pairingID}/cancel` 要求 Manager、`Idempotency-Key` 和 Pairing `If-Match`。 +2. 数据库事务先把未激活 Revision 封存为 `failed/pairing_cancelled`,再把 Pairing 标记为 `cancelled/pending`。 +3. 后台任务停止原配对 Worker,只销毁该 Revision 的 Exchange Token、机器凭据、Session Key 和以精确 owner key 创建的 SSF 连接;不得删除共享或 Active 连接。 +4. 绑定了 Stream 的 SSF 连接遵循既有安全退役窗口;服务重启后自动恢复 `pending` 清理。 +5. 只有清理进入 `completed` 后才允许提交下一张接入码。下一次凭据交付会轮换机器 Secret,因此不依赖回放旧 Secret。 + +管理员页面在 `credentials_saved` 等处理中状态展示稳定错误分类并继续自动重试,同时始终提供“放弃并清理本次配对”。`failed`、`expired` 或验证失败的未激活草稿也必须提供相同恢复入口;清理完成后重新显示标准配对表单。 + +`credentials_saved` 只表示 Manifest 与机器凭据已经安全保存,不代表 SSF Stream 已建立或 Revision 已可激活: + +- Discovery、远端暂时不可用和 `security_event_retirement_pending` 由后台重试。 +- `security_event_connection_conflict` 只能通过当前 Pairing 作用域的“安全退役现有连接”处理。 +- `security_event_credential_handoff_unsafe` 表示旧连接与新 Issuer/Client 不匹配;系统不会尝试旧凭据,管理员应在原配置下断开,或放弃本次 Pairing 并重新生成接入码。 +- 放弃后必须等 `cleanup_status=completed`,再提交新接入码。 + +当 `credentials_saved` 因已有 SSF owner 冲突而等待时,页面只能调用 Pairing 作用域的退役操作。服务端同时校验 Pairing ID、版本、错误分类和 Revision owner:只退役不属于当前 Revision 的旧连接;一旦当前 Revision 已拥有连接,陈旧操作自动成为 no-op。退役期间持久化 `security_event_retirement_pending`,页面隐藏重复退役按钮并显示自动继续。服务重启时除恢复处理中 Pairing 和 Cleanup 外,还会为“已完成、待验证/激活”的 Revision 从 SecretStore 重建 prepared Receiver。 + +旧 SSF 连接需要换用新机器凭据时,Gateway 先用新凭据向新 OIDC Issuer 取得管理 Token,再对旧 Transmitter 执行经过认证的 Stream 读取;存在 Stream 时必须精确核验 Stream ID、Issuer、Receiver 和 Audience。只有该端到端证明成功后才替换本地凭据引用并进入旧连接退役;证明失败不会发送旧 Secret、不会覆盖引用,也不会自动删除连接。 + +后台协调器每 5 秒恢复持久状态:优先继续 `cleanup_status=pending`,再恢复唯一的处理中 Pairing,然后恢复 `completed` 但尚未激活的 prepared Receiver,并校准提交结果不确定的 Active Runtime。SSF `retiring` 的最终清理由服务生命周期 Worker 托管,不随旧 Runtime 的 30 秒关闭而取消;到达安全窗口后,数据库事务先把全部 Secret 引用移交给持久清理队列,再删除精确的连接 generation。 + +SSF 凭据写入采用持久化 Secret 清理队列:新引用先以延迟清理记录暂存,数据库事务在引用生效时撤销暂存记录,并把旧引用原子加入到期清理队列。SecretStore 删除完成后才删除队列记录;进程在写入、切换或删除任一步退出,重启任务都能继续收敛。队列只保存非敏感引用和时间,不保存 Secret 值。 + ## 威胁模型摘要 -- 伪造/越权:所有管理写操作要求 Manager、`Idempotency-Key`、`If-Match` 和审计;Exchange Token 仅授权一个 Exchange。 +- 伪造/越权:所有管理写操作要求 Manager、`Idempotency-Key`、`If-Match` 和写前审计;审计存储不可用时拒绝变更。管理 API 不接受 API Key,OIDC/JWT 不接受 `?key=` URL 传递。 - 篡改:Manifest 使用严格 schema_version 和字段白名单;Issuer 与 Discovery issuer 必须精确相等。 - 信息泄露:公开运行时接口只返回启用状态、登录/退出入口和脱敏健康状态。 - SSRF:认证中心地址必须通过协议与地址策略校验,禁止 userinfo、重定向和非开发 HTTP;Discovery/Token/Introspection 请求使用超时与响应大小上限。 -- 锁死:激活、禁用与回滚前确认本地 Break-glass Manager 可用。 +- 锁死:激活和禁用前确认本地 Break-glass Manager 可用;Break-glass Token 使用独立 `local_break_glass_manager` purpose。即使 Active Runtime 构造失败,持久化 Active 的精确 Web Origin 仍可用于恢复管理,Cookie 身份校验继续 fail closed。 - 降级:OIDC 校验 fail closed;Introspection/SSF 不可用时遵循已有降级窗口,不绕过身份校验。 ## 前端运行时配置 Web 前端启动后读取公开只读统一认证状态,不使用 `VITE_OIDC_*` 构建变量。安全事件作为统一认证能力状态显示,原独立页面只保留运行运维动作。 +Active Revision 的 `WebBaseURL` 会以精确 scheme + host 动态加入 CORS,禁止 `*`;Cookie CSRF 只信任当前健康 Active 的 Web Origin。第一次接入或确认禁用后没有 Active 时,跨 Origin 管理 UI 仍需部署级精确 `CORS_ALLOWED_ORIGIN`,或由反向代理以同源 `/gateway-api` 提供 API。该 bootstrap Origin 是管理平面的网络边界,不是 OIDC 业务配置。 + ## 部署级配置 -允许继续由部署环境提供的统一认证相关参数仅限 SecretStore 与基础设施健康窗口: +允许继续由部署环境提供的参数仅限 SecretStore、管理面 bootstrap Origin 与基础设施健康窗口: ```dotenv IDENTITY_SECRET_STORE=file @@ -61,6 +101,7 @@ IDENTITY_SECRET_DIR=.local-secrets/identity IDENTITY_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS=60 IDENTITY_SECURITY_EVENTS_STALE_AFTER_SECONDS=180 IDENTITY_SECURITY_EVENTS_CLOCK_SKEW_SECONDS=60 +CORS_ALLOWED_ORIGIN=https://gateway-admin.example.com ``` Kubernetes 部署改用 `IDENTITY_KUBERNETES_NAMESPACE`、`IDENTITY_KUBERNETES_SECRET_NAME`、`IDENTITY_KUBERNETES_API_SERVER`、`IDENTITY_KUBERNETES_TOKEN_FILE` 和 `IDENTITY_KUBERNETES_CA_FILE`。这些参数不包含 Issuer、Audience、Scope、Client ID、Machine Secret 或业务开关。