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 {
|
type Authenticator struct {
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
ServerMainBaseURL string
|
ServerMainBaseURL string
|
||||||
ServerMainInternalToken string
|
ServerMainInternalToken string
|
||||||
ServerMainInternalKey string
|
ServerMainInternalKey string
|
||||||
ServerMainInternalSecret string
|
ServerMainInternalSecret string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
|
LocalAPIKeyVerifier func(ctx context.Context, apiKey string) (*User, error)
|
||||||
OIDCVerifier *OIDCVerifier
|
OIDCVerifier *OIDCVerifier
|
||||||
OIDCSessionResolver func(ctx context.Context, sessionID string) (*User, error)
|
OIDCVerifierProvider func() *OIDCVerifier
|
||||||
LegacyJWTEnabled bool
|
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 {
|
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 token == "" {
|
||||||
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
|
if cookie, err := r.Cookie(OIDCSessionCookieName); err == nil {
|
||||||
sessionID := strings.TrimSpace(cookie.Value)
|
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 nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
return a.OIDCSessionResolver(r.Context(), sessionID)
|
return resolver(r.Context(), sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@ -168,7 +175,7 @@ func (a *Authenticator) Authenticate(r *http.Request) (*User, error) {
|
|||||||
if algorithm == "RS256" || algorithm == "ES256" {
|
if algorithm == "RS256" || algorithm == "ES256" {
|
||||||
return a.AuthenticateOIDCAccessToken(r.Context(), token)
|
return a.AuthenticateOIDCAccessToken(r.Context(), token)
|
||||||
}
|
}
|
||||||
if !a.LegacyJWTEnabled {
|
if !a.legacyJWTEnabled() {
|
||||||
return nil, ErrUnauthorized
|
return nil, ErrUnauthorized
|
||||||
}
|
}
|
||||||
return a.verifyJWT(token)
|
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) {
|
func (a *Authenticator) AuthenticateOIDCAccessToken(ctx context.Context, token string) (*User, error) {
|
||||||
algorithm := jwtAlgorithm(token)
|
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 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) {
|
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
|
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) {
|
func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (*User, error) {
|
||||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "ES256"}))
|
||||||
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
|
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
|
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) {
|
func (c *OIDCPublicClient) AuthorizationURL(ctx context.Context, state, nonce, pkceVerifier string) (string, error) {
|
||||||
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
|
if strings.TrimSpace(state) == "" || strings.TrimSpace(nonce) == "" || !validPKCEVerifier(pkceVerifier) {
|
||||||
return "", errors.New("state, nonce and PKCE verifier are required")
|
return "", errors.New("state, nonce and PKCE verifier are required")
|
||||||
|
|||||||
@ -63,6 +63,9 @@ func TestOIDCPublicClientUsesAuthorizationCodePKCES256WithoutSecret(t *testing.T
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
authorizationURL, err := client.AuthorizationURL(context.Background(), "state", "nonce", pkceVerifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestPublicRouteIgnoresExpiredOptionalOIDCSession(t *testing.T) {
|
||||||
authenticator := New("local-jwt-secret", "", "")
|
authenticator := New("local-jwt-secret", "", "")
|
||||||
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
authenticator.OIDCSessionResolver = func(context.Context, string) (*User, error) {
|
||||||
|
|||||||
@ -49,6 +49,9 @@ func TestOIDCVerifierAcceptsRS256AndES256StableClaims(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := verifier.ValidateConfiguration(context.Background()); err != nil {
|
||||||
|
t.Fatalf("ValidateConfiguration() error = %v", err)
|
||||||
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
kid 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
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/auth/oidc/login [get]
|
// @Router /api/v1/auth/oidc/login [get]
|
||||||
func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
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)
|
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -53,13 +54,13 @@ func (s *Server) startOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
|
writeError(w, http.StatusBadRequest, "登录后返回地址无效", errorCodeOIDCLoginInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
encoded, err := s.oidcSessionCipher.EncodeLoginTransaction(transaction)
|
encoded, err := runtime.SessionCipher.EncodeLoginTransaction(transaction)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
|
s.logger.ErrorContext(r.Context(), "encode OIDC login transaction failed", "error", err)
|
||||||
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
|
writeError(w, http.StatusServiceUnavailable, "登录会话初始化失败,请稍后重试", errorCodeOIDCSessionStoreUnavailable)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
|
s.logger.ErrorContext(r.Context(), "load OIDC authorization endpoint failed", "error", err)
|
||||||
writeError(w, http.StatusServiceUnavailable, "认证中心暂时不可用,请稍后重试", "OIDC_AUTHORIZATION_UNAVAILABLE")
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: oidcsession.LoginTransactionCookieName, Value: encoded,
|
Name: oidcsession.LoginTransactionCookieName, Value: encoded,
|
||||||
Path: s.oidcCallbackCookiePath(), MaxAge: 600, Expires: time.Now().Add(10 * time.Minute),
|
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")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
|
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
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/auth/oidc/callback [get]
|
// @Router /api/v1/auth/oidc/callback [get]
|
||||||
func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
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)
|
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -94,7 +96,7 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
|
s.writeOIDCLoginTransactionError(w, r, "登录事务无效或已过期", oidcLoginFailureCookieMissing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
transaction, err := s.oidcSessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
transaction, err := runtime.SessionCipher.DecodeLoginTransaction(cookie.Value, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
|
s.writeOIDCLoginTransactionError(w, r, "登录事务校验失败,请重新登录", oidcLoginFailureTransactionInvalid)
|
||||||
return
|
return
|
||||||
@ -107,22 +109,22 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
|
s.writeOIDCLoginTransactionError(w, r, "认证中心回调缺少必要参数,请重新登录", oidcLoginFailureAuthorizationResponseMissing)
|
||||||
return
|
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 == "" {
|
if err != nil || tokens.AccessToken == "" || tokens.RefreshToken == "" || tokens.IDToken == "" {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
|
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心登录结果无效,请重新登录", errorCodeOIDCTokenExchangeFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
identity, err := s.auth.AuthenticateOIDCAccessToken(r.Context(), tokens.AccessToken)
|
identity, err := runtime.Verifier.Verify(r.Context(), tokens.AccessToken)
|
||||||
if err != nil || identity == nil {
|
if err != nil || identity == nil {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心访问令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||||
return
|
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 {
|
if err != nil || idSubject != identity.ID {
|
||||||
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
s.writeOIDCCallbackError(w, r, http.StatusUnauthorized, "认证中心身份令牌校验失败", errorCodeOIDCTokenExchangeFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
projection, err := s.resolveOIDCUserProjection(r.Context(), r, identity)
|
projection, err := s.resolveOIDCUserProjectionForRevision(r.Context(), r, identity, runtime.Revision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeOIDCCallbackProjectionError(w, r, err)
|
s.writeOIDCCallbackProjectionError(w, r, err)
|
||||||
return
|
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"))
|
s.writeOIDCUserResolutionError(w, r, errors.New("OIDC user resolver returned no local user"))
|
||||||
return
|
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,
|
AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, IDToken: tokens.IDToken,
|
||||||
}, projection.User)
|
}, projection.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -141,12 +143,12 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
|
Name: auth.OIDCSessionCookieName, Value: rawSession, Path: "/",
|
||||||
MaxAge: s.cfg.OIDCSessionAbsoluteTTLSeconds, Expires: now.Add(time.Duration(s.cfg.OIDCSessionAbsoluteTTLSeconds) * time.Second),
|
MaxAge: runtime.Revision.SessionAbsoluteSeconds, Expires: now.Add(time.Duration(runtime.Revision.SessionAbsoluteSeconds) * time.Second),
|
||||||
HttpOnly: true, Secure: s.cfg.OIDCSessionCookieSecure, SameSite: http.SameSiteStrictMode,
|
HttpOnly: true, Secure: runtime.CookieSecure, SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
s.recordOIDCSessionAudit(r, projection.User)
|
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
|
// logoutOIDCSession godoc
|
||||||
@ -158,13 +160,14 @@ func (s *Server) completeOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Failure 503 {object} ErrorEnvelope
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/auth/oidc/logout [post]
|
// @Router /api/v1/auth/oidc/logout [post]
|
||||||
func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
|
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)
|
writeError(w, http.StatusNotFound, "OIDC browser session is disabled", errorCodeOIDCBrowserSessionDisabled)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var bundle oidcsession.TokenBundle
|
var bundle oidcsession.TokenBundle
|
||||||
if cookie, err := r.Cookie(auth.OIDCSessionCookieName); err == nil {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
||||||
return
|
return
|
||||||
@ -172,14 +175,14 @@ func (s *Server) logoutOIDCSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.clearOIDCSessionCookie(w)
|
s.clearOIDCSessionCookie(w)
|
||||||
if bundle.RefreshToken != "" {
|
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)
|
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.
|
// 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 {
|
if err != nil {
|
||||||
location = s.cfg.OIDCPostLogoutRedirectURI
|
location = runtime.Revision.WebBaseURL + "/"
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
http.Redirect(w, r, location, http.StatusSeeOther)
|
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
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/auth/oidc/session [delete]
|
// @Router /api/v1/auth/oidc/session [delete]
|
||||||
func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request) {
|
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 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)
|
writeError(w, http.StatusServiceUnavailable, "登录会话存储暂时不可用", errorCodeOIDCSessionStoreUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -206,33 +210,40 @@ func (s *Server) deleteOIDCBrowserSession(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) oidcBrowserSessionReady() bool {
|
func (s *Server) oidcBrowserSessionReady() bool {
|
||||||
return s.cfg.OIDCEnabled && s.cfg.OIDCBrowserSessionEnabled && s.auth != nil && s.auth.OIDCVerifier != nil &&
|
return oidcRuntimeReady(s.currentIdentityRuntime())
|
||||||
s.oidcClient != nil && s.oidcSessions != nil && s.oidcSessionCipher != nil
|
}
|
||||||
|
|
||||||
|
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) {
|
func (s *Server) clearOIDCLoginCookie(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: oidcsession.LoginTransactionCookieName, Value: "", Path: s.oidcCallbackCookiePath(),
|
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 {
|
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"
|
return "/api/v1/auth/oidc/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) {
|
func (s *Server) clearOIDCSessionCookie(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: auth.OIDCSessionCookieName, Value: "", Path: "/",
|
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 {
|
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 base + returnTo
|
||||||
}
|
}
|
||||||
return 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) {
|
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")) {
|
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 := parsed.Query()
|
||||||
query.Set("oidcError", code)
|
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) {
|
func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
|
||||||
if s.oidcSessions == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(15 * time.Minute)
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@ -331,7 +342,11 @@ func (s *Server) startOIDCSessionCleanup(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
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)
|
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 {
|
func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -350,7 +366,7 @@ func (s *Server) protectOIDCSessionCookie(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
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)
|
writeError(w, http.StatusForbidden, "browser session request origin was rejected", errorCodeOIDCSessionCSRF)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"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"
|
"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) {
|
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 {
|
if s.oidcUserResolver == nil {
|
||||||
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
|
return store.ResolveOrProvisionOIDCUserResult{}, errors.New("OIDC user resolver is unavailable")
|
||||||
}
|
}
|
||||||
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
|
return s.oidcUserResolver.ResolveOrProvisionOIDCUser(ctx, store.ResolveOrProvisionOIDCUserInput{
|
||||||
Issuer: s.cfg.OIDCIssuer,
|
Issuer: revision.Issuer,
|
||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Roles: user.Roles,
|
Roles: user.Roles,
|
||||||
TenantID: user.TenantID,
|
TenantID: user.TenantID,
|
||||||
GatewayTenantKey: s.cfg.OIDCGatewayTenantKey,
|
GatewayTenantKey: revision.LocalTenantKey,
|
||||||
ProvisioningEnabled: s.cfg.OIDCJITProvisioningEnabled,
|
ProvisioningEnabled: revision.JITEnabled,
|
||||||
RequestIP: limitAuditText(requestIP(r), 128),
|
RequestIP: limitAuditText(requestIP(r), 128),
|
||||||
UserAgent: limitAuditText(r.UserAgent(), 512),
|
UserAgent: limitAuditText(r.UserAgent(), 512),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -31,11 +31,12 @@ type securityEventConnectionResponse struct {
|
|||||||
func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
ensureSecurityEventTraceID(w, r)
|
ensureSecurityEventTraceID(w, r)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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()})
|
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connection, err := s.securityEventManager.Get(r.Context())
|
connection, err := manager.Get(r.Context())
|
||||||
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
|
writeJSON(w, http.StatusOK, map[string]any{"connected": false, "lifecycleStatus": "disconnected", "prerequisites": s.securityEventPrerequisites()})
|
||||||
return
|
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) {
|
func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
traceID := ensureSecurityEventTraceID(w, r)
|
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")
|
writeError(w, http.StatusConflict, "OIDC must be configured before connecting security events", "security_event_prerequisite_missing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -79,12 +81,12 @@ func (s *Server) putSecurityEventConnection(w http.ResponseWriter, r *http.Reque
|
|||||||
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
if s.replaySecurityEventOperation(w, r, "connect", idempotencyKey, requestHash) {
|
||||||
return
|
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
|
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 {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, "connect", traceID, err)
|
s.writeSecurityEventConnectionError(w, r, manager, "connect", traceID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.writeSecurityEventOperation(w, r, "connect", idempotencyKey, requestHash, traceID, connection)
|
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) {
|
func (s *Server) verifySecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
traceID := ensureSecurityEventTraceID(w, r)
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connection, err := s.securityEventManager.Verify(r.Context())
|
connection, err := manager.Verify(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, "verify", traceID, err)
|
s.writeSecurityEventConnectionError(w, r, manager, "verify", traceID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.writeSecurityEventOperation(w, r, "verify", idempotencyKey, requestHash, traceID, connection)
|
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) {
|
func (s *Server) rotateSecurityEventConnectionCredential(w http.ResponseWriter, r *http.Request) {
|
||||||
traceID := ensureSecurityEventTraceID(w, r)
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connection, err := s.securityEventManager.RotateCredential(r.Context())
|
connection, err := manager.RotateCredential(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, "rotate", traceID, err)
|
s.writeSecurityEventConnectionError(w, r, manager, "rotate", traceID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.writeSecurityEventOperation(w, r, "rotate", idempotencyKey, requestHash, traceID, connection)
|
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) {
|
func (s *Server) deleteSecurityEventConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
traceID := ensureSecurityEventTraceID(w, r)
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connection, err := s.securityEventManager.Disconnect(r.Context())
|
connection, err := manager.Disconnect(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, "disconnect", traceID, err)
|
s.writeSecurityEventConnectionError(w, r, manager, "disconnect", traceID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection)
|
s.writeSecurityEventOperation(w, r, "disconnect", idempotencyKey, requestHash, traceID, connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) securityEventPrerequisites() map[string]any {
|
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{
|
return map[string]any{
|
||||||
"oidcConfigured": s.cfg.OIDCEnabled && s.cfg.OIDCIssuer != "" && s.cfg.OIDCTenantID != "",
|
"oidcConfigured": revision.Issuer != "" && revision.TenantID != "",
|
||||||
"introspectionClientConfigured": s.cfg.OIDCIntrospectionClientID != "" && s.cfg.OIDCIntrospectionClientSecret != "",
|
"introspectionClientConfigured": revision.MachineClientID != "" && revision.MachineCredentialRef != "",
|
||||||
"publicBaseUrlConfigured": s.cfg.PublicBaseURL != "",
|
"publicBaseUrlConfigured": revision.PublicBaseURL != "",
|
||||||
"managementClientId": s.cfg.OIDCIntrospectionClientID,
|
"managementClientId": revision.MachineClientID,
|
||||||
"credentialInputSupported": true,
|
"credentialInputSupported": false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,8 +164,8 @@ func requiredConnectionIdempotencyKey(w http.ResponseWriter, r *http.Request) (s
|
|||||||
return value, true
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, operation, traceID string) (string, string, bool) {
|
func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Request, manager *ssfreceiver.ConnectionManager, operation, traceID string) (string, string, bool) {
|
||||||
if s.securityEventManager == nil {
|
if manager == nil {
|
||||||
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
|
writeError(w, http.StatusNotFound, "security event connection does not exist", "security_event_connection_not_found")
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@ -164,9 +177,9 @@ func (s *Server) beginSecurityEventOperation(w http.ResponseWriter, r *http.Requ
|
|||||||
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
|
if s.replaySecurityEventOperation(w, r, operation, idempotencyKey, requestHash) {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
connection, err := s.securityEventManager.Get(r.Context())
|
connection, err := manager.Get(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeSecurityEventConnectionError(w, r, operation, traceID, err)
|
s.writeSecurityEventConnectionError(w, r, manager, operation, traceID, err)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version)
|
return idempotencyKey, requestHash, matchConnectionVersion(w, r, connection.Version)
|
||||||
@ -247,11 +260,11 @@ func matchConnectionVersion(w http.ResponseWriter, r *http.Request, expected int
|
|||||||
return true
|
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)
|
status, message, code := securityEventConnectionErrorProjection(err)
|
||||||
connection := ssfreceiver.ConnectionView{}
|
connection := ssfreceiver.ConnectionView{}
|
||||||
if s.securityEventManager != nil {
|
if manager != nil {
|
||||||
connection, _ = s.securityEventManager.Get(r.Context())
|
connection, _ = manager.Get(r.Context())
|
||||||
}
|
}
|
||||||
errorCategory := code
|
errorCategory := code
|
||||||
if connection.LastErrorCategory != nil && *connection.LastErrorCategory != "" {
|
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/auth"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
"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/oidcsession"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
ssfreceiver "github.com/easyai/easyai-ai-gateway/apps/api/internal/securityevents"
|
||||||
@ -31,6 +33,8 @@ type Server struct {
|
|||||||
geminiUploadSessions sync.Map
|
geminiUploadSessions sync.Map
|
||||||
securityEventReceiver http.Handler
|
securityEventReceiver http.Handler
|
||||||
securityEventManager *ssfreceiver.ConnectionManager
|
securityEventManager *ssfreceiver.ConnectionManager
|
||||||
|
identityRuntime *identityruntime.Manager
|
||||||
|
identityPairing *identity.PairingService
|
||||||
}
|
}
|
||||||
|
|
||||||
type oidcPublicClient interface {
|
type oidcPublicClient interface {
|
||||||
@ -63,83 +67,43 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
runner: runner.New(cfg, db, logger),
|
runner: runner.New(cfg, db, logger),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
server.auth.LegacyJWTEnabled = !cfg.OIDCEnabled || cfg.OIDCAcceptLegacyHS256
|
|
||||||
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
|
server.auth.ServerMainInternalKey = cfg.ServerMainInternalKey
|
||||||
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
|
server.auth.ServerMainInternalSecret = cfg.ServerMainInternalSecret
|
||||||
securityEventMetrics := &ssfreceiver.Metrics{}
|
securityEventMetrics := &ssfreceiver.Metrics{}
|
||||||
if cfg.OIDCEnabled {
|
secretStore, err := securityEventSecretStore(cfg)
|
||||||
secretStore, err := securityEventSecretStore(cfg)
|
if err != nil {
|
||||||
if err != nil {
|
panic("invalid identity SecretStore: " + err.Error())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if cfg.OIDCEnabled {
|
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
|
||||||
var evaluator func(context.Context, auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error)
|
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
|
||||||
if server.securityEventManager != nil {
|
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
|
||||||
evaluator = server.securityEventManager.Evaluate
|
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{
|
return nil
|
||||||
Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience, TenantID: cfg.OIDCTenantID,
|
}
|
||||||
RolePrefix: cfg.OIDCRolePrefix, RequiredScopes: cfg.OIDCRequiredScopes,
|
server.auth.OIDCSessionResolverProvider = func(ctx context.Context, sessionID string) (*auth.User, error) {
|
||||||
JWKSCacheTTL: time.Duration(cfg.OIDCJWKSCacheTTLSeconds) * time.Second,
|
runtime := server.identityRuntime.Current()
|
||||||
IntrospectionEnabled: cfg.OIDCIntrospectionEnabled,
|
if runtime == nil || runtime.Sessions == nil {
|
||||||
IntrospectionClientID: cfg.OIDCIntrospectionClientID,
|
return nil, auth.ErrUnauthorized
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||||
server.runner.StartAsyncQueueWorker(ctx)
|
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