feat(identity): 实现统一认证运行时热切换
从 Active Revision 构造并验证 OIDC、BFF Session、Introspection 与 SSF Runtime,在数据库激活成功后原子替换内存引用。请求链路使用不可变快照,失败保留当前运行时,本地管理登录不受影响。\n\n验证:go test ./apps/api/...;go vet ./apps/api/...
This commit is contained in:
parent
96bbd3a2f6
commit
a9e23cb237
@ -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) {
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
96
apps/api/internal/httpapi/identity_runtime.go
Normal file
96
apps/api/internal/httpapi/identity_runtime.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
209
apps/api/internal/identityruntime/builder.go
Normal file
209
apps/api/internal/identityruntime/builder.go
Normal file
@ -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"
|
||||
}
|
||||
151
apps/api/internal/identityruntime/manager.go
Normal file
151
apps/api/internal/identityruntime/manager.go
Normal file
@ -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)
|
||||
}
|
||||
120
apps/api/internal/identityruntime/manager_test.go
Normal file
120
apps/api/internal/identityruntime/manager_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user