feat(identity): 增加统一认证管理接口
提供接入码配对、配置查询、本地策略修改、验证、激活、回滚、禁用和公开运行时状态接口。写操作强制 Idempotency-Key 与 If-Match,后台 Exchange 支持重启恢复和过期收敛,并记录脱敏 Trace 与审计。\n\n验证:go test 相关 identity、store、identityruntime、httpapi 包;go vet ./apps/api/...
This commit is contained in:
@@ -0,0 +1,491 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type identityConfigurationView struct {
|
||||||
|
Active *identity.Revision `json:"active"`
|
||||||
|
Draft *identity.Revision `json:"draft"`
|
||||||
|
Previous *identity.Revision `json:"previous"`
|
||||||
|
Pairing *identity.PairingExchange `json:"pairing,omitempty"`
|
||||||
|
Runtime identityRuntimeStatus `json:"runtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityRuntimeStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RevisionID string `json:"revisionId,omitempty"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
JIT string `json:"jit"`
|
||||||
|
TokenIntrospection string `json:"tokenIntrospection"`
|
||||||
|
SessionRevocation string `json:"sessionRevocation"`
|
||||||
|
LastTraceID string `json:"lastTraceId,omitempty"`
|
||||||
|
LastAuditID string `json:"lastAuditId,omitempty"`
|
||||||
|
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicIdentityConfiguration struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
OIDCLogin bool `json:"oidcLogin"`
|
||||||
|
LoginURL string `json:"loginUrl,omitempty"`
|
||||||
|
LogoutURL string `json:"logoutUrl,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityPolicyPatch struct {
|
||||||
|
LocalTenantKey *string `json:"localTenantKey"`
|
||||||
|
RolePrefix *string `json:"rolePrefix"`
|
||||||
|
JITEnabled *bool `json:"jitEnabled"`
|
||||||
|
LegacyJWTEnabled *bool `json:"legacyJwtEnabled"`
|
||||||
|
SessionIdleSeconds *int `json:"sessionIdleSeconds"`
|
||||||
|
SessionAbsoluteSeconds *int `json:"sessionAbsoluteSeconds"`
|
||||||
|
SessionRefreshSeconds *int `json:"sessionRefreshSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedIdentityResponse struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
ETag string `json:"etag,omitempty"`
|
||||||
|
AuditID string `json:"auditId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityWriteOperation struct {
|
||||||
|
operation, key, requestHash string
|
||||||
|
release func()
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (operation *identityWriteOperation) close() {
|
||||||
|
if operation != nil {
|
||||||
|
operation.once.Do(operation.release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getPublicIdentityConfiguration(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
runtime := s.currentIdentityRuntime()
|
||||||
|
if runtime == nil || runtime.Revision.State != identity.RevisionActive {
|
||||||
|
writeJSON(w, http.StatusOK, publicIdentityConfiguration{Status: "disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := publicIdentityConfiguration{Enabled: true, Status: "active"}
|
||||||
|
if oidcRuntimeReady(runtime) {
|
||||||
|
view.OIDCLogin = true
|
||||||
|
view.LoginURL = "/api/v1/auth/oidc/login"
|
||||||
|
view.LogoutURL = "/api/v1/auth/oidc/logout"
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
view := identityConfigurationView{Runtime: s.identityRuntimeStatus(r)}
|
||||||
|
if revision, err := s.store.ActiveIdentityConfigurationRevision(r.Context()); err == nil {
|
||||||
|
view.Active = &revision
|
||||||
|
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "统一认证配置暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revision, err := s.store.LatestInactiveIdentityConfigurationRevision(r.Context()); err == nil {
|
||||||
|
view.Draft = &revision
|
||||||
|
if pairing, pairingErr := s.store.IdentityPairingExchangeForRevision(r.Context(), revision.ID); pairingErr == nil {
|
||||||
|
view.Pairing = &pairing
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "统一认证草稿暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if revision, err := s.store.LatestSupersededIdentityConfigurationRevision(r.Context()); err == nil {
|
||||||
|
view.Previous = &revision
|
||||||
|
} else if !errors.Is(err, identity.ErrRevisionNotFound) {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "统一认证历史版本暂时不可用", "IDENTITY_CONFIGURATION_UNAVAILABLE")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input identity.PairingInput
|
||||||
|
if !decodeIdentityRequest(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operation, ok := s.beginIdentityWrite(w, r, "pairing.start", 0, input)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer operation.close()
|
||||||
|
traceID := ensureIdentityTraceID(w, r)
|
||||||
|
pairing, err := s.identityPairing.Start(r.Context(), input, traceID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeIdentityError(w, r, "pairing.start", "", traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditID := s.recordIdentityConfigurationAudit(r, "pairing.start", pairing.RevisionID, "accepted", traceID, "")
|
||||||
|
pairing.AuthCenterAuditID = ""
|
||||||
|
s.completeIdentityWrite(w, r, operation, http.StatusAccepted, pairing, pairing.Version, auditID)
|
||||||
|
s.startIdentityPairingWorker(pairing.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getIdentityPairing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
pairing, err := s.store.IdentityPairingExchange(r.Context(), r.PathValue("pairingID"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeIdentityError(w, r, "pairing.get", r.PathValue("pairingID"), ensureIdentityTraceID(w, r), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", identityETag(pairing.Version))
|
||||||
|
writeJSON(w, http.StatusOK, pairing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateIdentityDraftPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var patch identityPolicyPatch
|
||||||
|
if !decodeIdentityRequest(w, r, &patch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.policy", expectedVersion, patch)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer operation.close()
|
||||||
|
revision, err := s.store.IdentityConfigurationRevision(r.Context(), r.PathValue("revisionID"))
|
||||||
|
if err != nil || revision.Version != expectedVersion {
|
||||||
|
s.writeIdentityError(w, r, "revision.policy", r.PathValue("revisionID"), ensureIdentityTraceID(w, r), firstIdentityError(err, identity.ErrRevisionConflict))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy := identity.RevisionPolicy{
|
||||||
|
LocalTenantKey: revision.LocalTenantKey, RolePrefix: revision.RolePrefix, JITEnabled: revision.JITEnabled,
|
||||||
|
LegacyJWTEnabled: revision.LegacyJWTEnabled, SessionIdleSeconds: revision.SessionIdleSeconds,
|
||||||
|
SessionAbsoluteSeconds: revision.SessionAbsoluteSeconds, SessionRefreshSeconds: revision.SessionRefreshSeconds,
|
||||||
|
}
|
||||||
|
applyIdentityPolicyPatch(&policy, patch)
|
||||||
|
updated, err := s.store.UpdateIdentityRevisionPolicy(r.Context(), revision.ID, expectedVersion, policy)
|
||||||
|
if err != nil {
|
||||||
|
s.writeIdentityError(w, r, "revision.policy", revision.ID, ensureIdentityTraceID(w, r), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
traceID := ensureIdentityTraceID(w, r)
|
||||||
|
auditID := s.recordIdentityConfigurationAudit(r, "revision.policy", revision.ID, "success", traceID, "")
|
||||||
|
s.completeIdentityWrite(w, r, operation, http.StatusOK, updated, updated.Version, auditID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) validateIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.runIdentityRevisionAction(w, r, "revision.validate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
|
return s.identityRuntime.Validate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) activateIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.runIdentityRevisionAction(w, r, "revision.activate", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
|
return s.identityRuntime.Activate(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rollbackIdentityRevision(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.runIdentityRevisionAction(w, r, "revision.rollback", func(expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
|
return s.identityRuntime.Rollback(r.Context(), r.PathValue("revisionID"), expected, traceID, auditID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) disableIdentityConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operation, ok := s.beginIdentityWriteWithVersion(w, r, "revision.disable", expectedVersion, struct{}{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer operation.close()
|
||||||
|
traceID := ensureIdentityTraceID(w, r)
|
||||||
|
auditID := s.recordIdentityConfigurationAudit(r, "revision.disable", "active", "requested", traceID, "")
|
||||||
|
disabled, err := s.identityRuntime.Disable(r.Context(), expectedVersion, traceID, auditID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeIdentityError(w, r, "revision.disable", "active", traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.completeIdentityWrite(w, r, operation, http.StatusOK, disabled, disabled.Version, auditID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runIdentityRevisionAction(w http.ResponseWriter, r *http.Request, action string, run func(int64, string, string) (identity.Revision, error)) {
|
||||||
|
expectedVersion, ok := requiredIdentityVersion(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
operation, ok := s.beginIdentityWriteWithVersion(w, r, action, expectedVersion, struct{}{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer operation.close()
|
||||||
|
traceID := ensureIdentityTraceID(w, r)
|
||||||
|
auditID := s.recordIdentityConfigurationAudit(r, action, r.PathValue("revisionID"), "requested", traceID, "")
|
||||||
|
revision, err := run(expectedVersion, traceID, auditID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeIdentityError(w, r, action, r.PathValue("revisionID"), traceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.completeIdentityWrite(w, r, operation, http.StatusOK, revision, revision.Version, auditID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) identityRuntimeStatus(r *http.Request) identityRuntimeStatus {
|
||||||
|
runtime := s.currentIdentityRuntime()
|
||||||
|
if runtime == nil {
|
||||||
|
return identityRuntimeStatus{Login: "disabled", JIT: "disabled", TokenIntrospection: "disabled", SessionRevocation: "disabled"}
|
||||||
|
}
|
||||||
|
revision := runtime.Revision
|
||||||
|
status := identityRuntimeStatus{
|
||||||
|
Enabled: true, RevisionID: revision.ID, Login: capabilityHealth(runtime.PublicClient != nil),
|
||||||
|
JIT: capabilityHealth(revision.JITEnabled), TokenIntrospection: capabilityHealth(revision.TokenIntrospection),
|
||||||
|
SessionRevocation: capabilityHealth(revision.SessionRevocation), LastTraceID: revision.LastTraceID,
|
||||||
|
LastAuditID: revision.LastAuditID, LastErrorCategory: revision.LastErrorCategory,
|
||||||
|
}
|
||||||
|
if revision.SessionRevocation && runtime.SecurityEvents != nil {
|
||||||
|
if connection, err := runtime.SecurityEvents.Get(r.Context()); err == nil && connection.LifecycleStatus != "active" {
|
||||||
|
status.SessionRevocation = connection.HealthMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func capabilityHealth(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "healthy"
|
||||||
|
}
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeIdentityRequest(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(output); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
writeError(w, http.StatusBadRequest, "统一认证请求格式无效", "INVALID_REQUEST")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) beginIdentityWrite(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
|
||||||
|
if parsed, ok := requiredIdentityVersion(w, r); !ok || parsed != version {
|
||||||
|
if ok {
|
||||||
|
writeError(w, http.StatusPreconditionFailed, "统一认证配置版本已变化", "IDENTITY_VERSION_CONFLICT")
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return s.beginIdentityWriteWithVersion(w, r, operation, version, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) beginIdentityWriteWithVersion(w http.ResponseWriter, r *http.Request, operation string, version int64, request any) (*identityWriteOperation, bool) {
|
||||||
|
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||||
|
if key == "" || len(key) > 255 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Idempotency-Key is required", "IDEMPOTENCY_KEY_REQUIRED")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(request)
|
||||||
|
digest := sha256.Sum256(append([]byte(fmt.Sprintf("%s\x00%d\x00", operation, version)), encoded...))
|
||||||
|
requestHash := fmt.Sprintf("%x", digest[:])
|
||||||
|
s.identityManagementMu.Lock()
|
||||||
|
write := &identityWriteOperation{operation: operation, key: key, requestHash: requestHash, release: s.identityManagementMu.Unlock}
|
||||||
|
if s.store == nil {
|
||||||
|
return write, true
|
||||||
|
}
|
||||||
|
recorded, err := s.store.IdentityManagementRequest(r.Context(), operation, key)
|
||||||
|
if errors.Is(err, store.ErrIdentityManagementRequestNotFound) {
|
||||||
|
return write, true
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
write.close()
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "幂等状态暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if recorded.RequestHash != requestHash {
|
||||||
|
write.close()
|
||||||
|
writeError(w, http.StatusConflict, "Idempotency-Key 已用于其他请求", "IDEMPOTENCY_KEY_REUSED")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var response storedIdentityResponse
|
||||||
|
if json.Unmarshal(recorded.Response, &response) != nil {
|
||||||
|
write.close()
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "幂等响应暂时不可用", "IDENTITY_IDEMPOTENCY_UNAVAILABLE")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
write.close()
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Idempotent-Replayed", "true")
|
||||||
|
if response.ETag != "" {
|
||||||
|
w.Header().Set("ETag", response.ETag)
|
||||||
|
}
|
||||||
|
if response.AuditID != "" {
|
||||||
|
w.Header().Set("X-Audit-Id", response.AuditID)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(response.Status)
|
||||||
|
_, _ = w.Write(response.Body)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) completeIdentityWrite(w http.ResponseWriter, r *http.Request, operation *identityWriteOperation, status int, payload any, version int64, auditID string) {
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
stored, _ := json.Marshal(storedIdentityResponse{Status: status, Body: body, ETag: identityETag(version), AuditID: auditID})
|
||||||
|
if s.store != nil {
|
||||||
|
if err := s.store.RecordIdentityManagementRequest(r.Context(), store.IdentityManagementRequest{
|
||||||
|
Operation: operation.operation, Key: operation.key, RequestHash: operation.requestHash, Response: stored,
|
||||||
|
}); err != nil && s.logger != nil {
|
||||||
|
s.logger.ErrorContext(r.Context(), "identity idempotency response could not be recorded", "operation", operation.operation, "error_category", "idempotency_store_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operation.close()
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("ETag", identityETag(version))
|
||||||
|
if auditID != "" {
|
||||||
|
w.Header().Set("X-Audit-Id", auditID)
|
||||||
|
}
|
||||||
|
writeJSON(w, status, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredIdentityVersion(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
value := strings.Trim(strings.TrimPrefix(strings.TrimSpace(r.Header.Get("If-Match")), "W/"), `"`)
|
||||||
|
version, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || version < 0 {
|
||||||
|
writeError(w, http.StatusPreconditionRequired, "If-Match is required", "IF_MATCH_REQUIRED")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityETag(version int64) string { return fmt.Sprintf(`W/"%d"`, version) }
|
||||||
|
|
||||||
|
func applyIdentityPolicyPatch(policy *identity.RevisionPolicy, patch identityPolicyPatch) {
|
||||||
|
if patch.LocalTenantKey != nil {
|
||||||
|
policy.LocalTenantKey = strings.TrimSpace(*patch.LocalTenantKey)
|
||||||
|
}
|
||||||
|
if patch.RolePrefix != nil {
|
||||||
|
policy.RolePrefix = strings.TrimSpace(*patch.RolePrefix)
|
||||||
|
}
|
||||||
|
if patch.JITEnabled != nil {
|
||||||
|
policy.JITEnabled = *patch.JITEnabled
|
||||||
|
}
|
||||||
|
if patch.LegacyJWTEnabled != nil {
|
||||||
|
policy.LegacyJWTEnabled = *patch.LegacyJWTEnabled
|
||||||
|
}
|
||||||
|
if patch.SessionIdleSeconds != nil {
|
||||||
|
policy.SessionIdleSeconds = *patch.SessionIdleSeconds
|
||||||
|
}
|
||||||
|
if patch.SessionAbsoluteSeconds != nil {
|
||||||
|
policy.SessionAbsoluteSeconds = *patch.SessionAbsoluteSeconds
|
||||||
|
}
|
||||||
|
if patch.SessionRefreshSeconds != nil {
|
||||||
|
policy.SessionRefreshSeconds = *patch.SessionRefreshSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstIdentityError(actual, fallback error) error {
|
||||||
|
if actual != nil {
|
||||||
|
return actual
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureIdentityTraceID(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
return ensureSecurityEventTraceID(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeIdentityError(w http.ResponseWriter, r *http.Request, action, targetID, traceID string, err error) {
|
||||||
|
status, message, code := identityErrorProjection(err)
|
||||||
|
s.recordIdentityConfigurationAudit(r, action, targetID, "failure", traceID, code)
|
||||||
|
writeError(w, status, message, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityErrorProjection(err error) (int, string, string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, identity.ErrRevisionNotFound):
|
||||||
|
return http.StatusNotFound, "统一认证配置不存在", "IDENTITY_CONFIGURATION_NOT_FOUND"
|
||||||
|
case errors.Is(err, identity.ErrRevisionConflict):
|
||||||
|
return http.StatusPreconditionFailed, "统一认证配置版本或状态已变化", "IDENTITY_VERSION_CONFLICT"
|
||||||
|
case errors.Is(err, identity.ErrBreakGlassRequired):
|
||||||
|
return http.StatusConflict, "请先保留至少一个可用的本地应急管理员凭据", "BREAK_GLASS_MANAGER_REQUIRED"
|
||||||
|
case errors.Is(err, identity.ErrLocalTenantInvalid):
|
||||||
|
return http.StatusConflict, "本地租户映射无效", "IDENTITY_LOCAL_TENANT_INVALID"
|
||||||
|
case err != nil && (strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "required")):
|
||||||
|
return http.StatusBadRequest, "统一认证配置无效", "IDENTITY_CONFIGURATION_INVALID"
|
||||||
|
default:
|
||||||
|
return http.StatusBadGateway, "认证中心或统一认证服务暂时不可用", "IDENTITY_SERVICE_UNAVAILABLE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) recordIdentityConfigurationAudit(r *http.Request, action, targetID, outcome, traceID, errorCategory string) string {
|
||||||
|
if s.store == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
actor, _ := auth.UserFromContext(r.Context())
|
||||||
|
input := store.AuditLogInput{
|
||||||
|
Category: "identity", Action: "identity." + action, TargetType: "identity_configuration_revision",
|
||||||
|
TargetID: firstNonEmptyText(targetID, "pending"), RequestIP: limitAuditText(requestIP(r), 128),
|
||||||
|
UserAgent: limitAuditText(r.UserAgent(), 512), Metadata: map[string]any{
|
||||||
|
"outcome": outcome, "traceId": traceID, "errorCategory": errorCategory,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if actor != nil {
|
||||||
|
input.ActorGatewayUserID = uuidText(firstNonEmptyText(actor.GatewayUserID, actor.ID))
|
||||||
|
input.ActorUserID, input.ActorUsername, input.ActorSource, input.ActorRoles = actor.ID, actor.Username, actor.Source, actor.Roles
|
||||||
|
}
|
||||||
|
audit, err := s.store.RecordAuditLog(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.WarnContext(r.Context(), "record identity audit failed", "action", action, "error_category", "audit_store_failed", "trace_id", traceID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return audit.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startIdentityPairingWorker(pairingID string) {
|
||||||
|
if _, loaded := s.identityPairingWorkers.LoadOrStore(pairingID, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer s.identityPairingWorkers.Delete(pairingID)
|
||||||
|
delay := time.Second
|
||||||
|
for {
|
||||||
|
pairing, err := s.identityPairing.Continue(s.ctx, pairingID)
|
||||||
|
if err == nil {
|
||||||
|
if pairing.Status == identity.PairingCompleted || pairing.Status == identity.PairingFailed || pairing.Status == identity.PairingExpired {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delay = time.Second
|
||||||
|
} else {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("identity pairing step failed and will retry", "pairing_id", pairingID, "error_category", "pairing_step_failed")
|
||||||
|
}
|
||||||
|
if delay < 15*time.Second {
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequiredIdentityWriteHeaders(t *testing.T) {
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/identity", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
if _, ok := requiredIdentityVersion(recorder, request); ok || recorder.Code != http.StatusPreconditionRequired {
|
||||||
|
t.Fatalf("missing If-Match status=%d", recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Header.Set("If-Match", `W/"7"`)
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
version, ok := requiredIdentityVersion(recorder, request)
|
||||||
|
if !ok || version != 7 {
|
||||||
|
t.Fatalf("weak ETag version=%d ok=%v", version, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityErrorProjectionProtectsInternalDetails(t *testing.T) {
|
||||||
|
status, message, code := identityErrorProjection(assertionError("upstream response contained secret-token"))
|
||||||
|
if status != http.StatusBadGateway || code != "IDENTITY_SERVICE_UNAVAILABLE" || message == "upstream response contained secret-token" {
|
||||||
|
t.Fatalf("unsafe error projection status=%d code=%q message=%q", status, code, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type assertionError string
|
||||||
|
|
||||||
|
func (err assertionError) Error() string { return string(err) }
|
||||||
@@ -20,21 +20,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
store *store.Store
|
store *store.Store
|
||||||
oidcUserResolver oidcUserResolver
|
oidcUserResolver oidcUserResolver
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
oidcClient oidcPublicClient
|
oidcClient oidcPublicClient
|
||||||
oidcSessions oidcSessionManager
|
oidcSessions oidcSessionManager
|
||||||
oidcSessionCipher *oidcsession.Cipher
|
oidcSessionCipher *oidcsession.Cipher
|
||||||
runner *runner.Service
|
runner *runner.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
geminiUploadSessions sync.Map
|
geminiUploadSessions sync.Map
|
||||||
securityEventReceiver http.Handler
|
securityEventReceiver http.Handler
|
||||||
securityEventManager *ssfreceiver.ConnectionManager
|
securityEventManager *ssfreceiver.ConnectionManager
|
||||||
identityRuntime *identityruntime.Manager
|
identityRuntime *identityruntime.Manager
|
||||||
identityPairing *identity.PairingService
|
identityPairing *identity.PairingService
|
||||||
|
identityManagementMu sync.Mutex
|
||||||
|
identityPairingWorkers sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
type oidcPublicClient interface {
|
type oidcPublicClient interface {
|
||||||
@@ -77,8 +79,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
|
runtimeBuilder := identityruntime.NewRuntimeBuilder(ctx, db, secretStore, identityruntime.RuntimeBuilderConfig{
|
||||||
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
|
AppEnv: cfg.AppEnv, JWKSCacheTTL: 5 * time.Minute,
|
||||||
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
|
HeartbeatInterval: time.Duration(cfg.OIDCSecurityEventsHeartbeatIntervalSeconds) * time.Second,
|
||||||
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
|
StaleAfter: time.Duration(cfg.OIDCSecurityEventsStaleAfterSeconds) * time.Second,
|
||||||
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
|
ClockSkew: time.Duration(cfg.OIDCSecurityEventsClockSkewSeconds) * time.Second,
|
||||||
}, securityEventMetrics)
|
}, securityEventMetrics)
|
||||||
server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder)
|
server.identityRuntime = identityruntime.NewManager(db, runtimeBuilder)
|
||||||
if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil {
|
if err := server.identityRuntime.LoadActive(ctx); err != nil && logger != nil {
|
||||||
@@ -87,6 +89,13 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
|
server.identityPairing = identity.NewPairingService(db, secretStore, func(baseURL string) (identity.OnboardingRemote, error) {
|
||||||
return identity.NewOnboardingClient(baseURL, nil)
|
return identity.NewOnboardingClient(baseURL, nil)
|
||||||
}, server.identityRuntime)
|
}, server.identityRuntime)
|
||||||
|
if pending, pendingErr := db.PendingIdentityPairingExchanges(ctx); pendingErr == nil {
|
||||||
|
for _, pairing := range pending {
|
||||||
|
server.startIdentityPairingWorker(pairing.ID)
|
||||||
|
}
|
||||||
|
} else if logger != nil {
|
||||||
|
logger.Warn("pending identity pairings could not be resumed", "error_category", "pairing_resume_failed")
|
||||||
|
}
|
||||||
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
|
server.auth.OIDCVerifierProvider = func() *auth.OIDCVerifier {
|
||||||
if runtime := server.identityRuntime.Current(); runtime != nil {
|
if runtime := server.identityRuntime.Current(); runtime != nil {
|
||||||
return runtime.Verifier
|
return runtime.Verifier
|
||||||
@@ -124,6 +133,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
|
mux.HandleFunc("GET /api/v1/auth/oidc/callback", server.completeOIDCLogin)
|
||||||
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
|
mux.HandleFunc("POST /api/v1/auth/oidc/logout", server.logoutOIDCSession)
|
||||||
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
|
mux.HandleFunc("DELETE /api/v1/auth/oidc/session", server.deleteOIDCBrowserSession)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/identity", server.getPublicIdentityConfiguration)
|
||||||
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
|
mux.HandleFunc("POST /api/v1/security-events/ssf", server.receiveSecurityEvent)
|
||||||
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
mux.Handle("GET /api/v1/me", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.me)))
|
||||||
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
|
mux.Handle("GET /api/v1/public/catalog/providers", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.listCatalogProviders)))
|
||||||
@@ -194,6 +204,14 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
|||||||
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
||||||
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
|
mux.Handle("GET /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getClientCustomizationSettings)))
|
||||||
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
|
mux.Handle("PATCH /api/admin/system/client-customization/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateClientCustomizationSettings)))
|
||||||
|
mux.Handle("GET /api/admin/system/identity/configuration", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityConfiguration)))
|
||||||
|
mux.Handle("POST /api/admin/system/identity/pairings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.startIdentityPairing)))
|
||||||
|
mux.Handle("GET /api/admin/system/identity/pairings/{pairingID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getIdentityPairing)))
|
||||||
|
mux.Handle("PATCH /api/admin/system/identity/revisions/{revisionID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateIdentityDraftPolicy)))
|
||||||
|
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/validate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.validateIdentityRevision)))
|
||||||
|
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/activate", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.activateIdentityRevision)))
|
||||||
|
mux.Handle("POST /api/admin/system/identity/revisions/{revisionID}/rollback", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.rollbackIdentityRevision)))
|
||||||
|
mux.Handle("POST /api/admin/system/identity/disable", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.disableIdentityConfiguration)))
|
||||||
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
|
mux.Handle("GET /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getSecurityEventConnection)))
|
||||||
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
|
mux.Handle("PUT /api/admin/system/identity/security-events/connection", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.putSecurityEventConnection)))
|
||||||
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
|
mux.Handle("POST /api/admin/system/identity/security-events/connection/verify", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.verifySecurityEventConnection)))
|
||||||
|
|||||||
@@ -17,6 +17,27 @@ var (
|
|||||||
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RevisionPolicy struct {
|
||||||
|
LocalTenantKey string `json:"localTenantKey"`
|
||||||
|
RolePrefix string `json:"rolePrefix"`
|
||||||
|
JITEnabled bool `json:"jitEnabled"`
|
||||||
|
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
|
||||||
|
SessionIdleSeconds int `json:"sessionIdleSeconds"`
|
||||||
|
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
|
||||||
|
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (policy RevisionPolicy) Validate() error {
|
||||||
|
if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" {
|
||||||
|
return errors.New("local tenant mapping and role prefix are required")
|
||||||
|
}
|
||||||
|
if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds ||
|
||||||
|
policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds {
|
||||||
|
return errors.New("identity session policy is invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type RevisionState string
|
type RevisionState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ func (service *PairingService) Continue(ctx context.Context, pairingID string) (
|
|||||||
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired {
|
if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired {
|
||||||
return pairing, nil
|
return pairing, nil
|
||||||
}
|
}
|
||||||
|
if !pairing.ExpiresAt.After(time.Now()) {
|
||||||
|
expired, updateErr := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{
|
||||||
|
Status: PairingExpired, RemoteVersion: pairing.RemoteVersion, LastErrorCategory: "exchange_expired",
|
||||||
|
})
|
||||||
|
if updateErr == nil {
|
||||||
|
_ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef)
|
||||||
|
}
|
||||||
|
return expired, updateErr
|
||||||
|
}
|
||||||
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PairingExchange{}, err
|
return PairingExchange{}, err
|
||||||
|
|||||||
@@ -154,3 +154,26 @@ func TestPairingRetriesWithRotatedCredentialAfterSecretStoreFailure(t *testing.T
|
|||||||
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
|
t.Fatalf("revision did not retain SecretStore references: %#v", repository.revision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPairingExpirationIsPersistedAndExchangeTokenIsDestroyed(t *testing.T) {
|
||||||
|
repository := &pairingRepositoryFake{exchange: PairingExchange{
|
||||||
|
ID: "pairing", RevisionID: "revision", ExchangeTokenRef: "identity-exchange-pairing",
|
||||||
|
Status: PairingPreparing, RemoteVersion: 2, ExpiresAt: time.Now().Add(-time.Minute), Version: 4,
|
||||||
|
}}
|
||||||
|
secrets := &secretStoreFake{values: map[string][]byte{"identity-exchange-pairing": []byte("temporary-token")}}
|
||||||
|
service := NewPairingService(repository, secrets, func(string) (OnboardingRemote, error) {
|
||||||
|
t.Fatal("expired exchange must not call the remote service")
|
||||||
|
return nil, nil
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
expired, err := service.Continue(context.Background(), "pairing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if expired.Status != PairingExpired || expired.LastErrorCategory != "exchange_expired" {
|
||||||
|
t.Fatalf("expired pairing was not persisted: %#v", expired)
|
||||||
|
}
|
||||||
|
if _, exists := secrets.values["identity-exchange-pairing"]; exists {
|
||||||
|
t.Fatal("expired exchange token remained in SecretStore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ type Repository interface {
|
|||||||
ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error)
|
ActiveIdentityConfigurationRevision(context.Context) (identity.Revision, error)
|
||||||
MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error)
|
MarkIdentityRevisionValidated(context.Context, string, int64, string, string) (identity.Revision, error)
|
||||||
MarkIdentityRevisionFailed(context.Context, string, int64, string, 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)
|
ActivateIdentityRevision(context.Context, string, int64, string, string) (identity.Revision, bool, error)
|
||||||
DisableActiveIdentityRevision(context.Context, int64) (identity.Revision, error)
|
DisableActiveIdentityRevision(context.Context, int64, string, string) (identity.Revision, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Builder interface {
|
type Builder interface {
|
||||||
@@ -96,7 +96,7 @@ func (manager *Manager) Validate(ctx context.Context, id string, expectedVersion
|
|||||||
return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
|
return manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64) (identity.Revision, error) {
|
func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
manager.operation.Lock()
|
manager.operation.Lock()
|
||||||
defer manager.operation.Unlock()
|
defer manager.operation.Unlock()
|
||||||
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
|
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
|
||||||
@@ -110,7 +110,7 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Revision{}, err
|
return identity.Revision{}, err
|
||||||
}
|
}
|
||||||
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion)
|
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, expectedVersion, traceID, auditID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
candidate.Close()
|
candidate.Close()
|
||||||
return identity.Revision{}, err
|
return identity.Revision{}, err
|
||||||
@@ -121,10 +121,40 @@ func (manager *Manager) Activate(ctx context.Context, id string, expectedVersion
|
|||||||
return activated, nil
|
return activated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64) (identity.Revision, error) {
|
func (manager *Manager) Rollback(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
manager.operation.Lock()
|
manager.operation.Lock()
|
||||||
defer manager.operation.Unlock()
|
defer manager.operation.Unlock()
|
||||||
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion)
|
revision, err := manager.repository.IdentityConfigurationRevision(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return identity.Revision{}, err
|
||||||
|
}
|
||||||
|
if revision.Version != expectedVersion || revision.State != identity.RevisionSuperseded {
|
||||||
|
return identity.Revision{}, identity.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
candidate, err := manager.builder.Build(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return identity.Revision{}, err
|
||||||
|
}
|
||||||
|
validated, err := manager.repository.MarkIdentityRevisionValidated(ctx, id, expectedVersion, traceID, auditID)
|
||||||
|
if err != nil {
|
||||||
|
candidate.Close()
|
||||||
|
return identity.Revision{}, err
|
||||||
|
}
|
||||||
|
activated, _, err := manager.repository.ActivateIdentityRevision(ctx, id, validated.Version, traceID, auditID)
|
||||||
|
if err != nil {
|
||||||
|
candidate.Close()
|
||||||
|
return identity.Revision{}, err
|
||||||
|
}
|
||||||
|
candidate.Revision = activated
|
||||||
|
old := manager.current.Swap(candidate)
|
||||||
|
retireRuntime(old)
|
||||||
|
return activated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) Disable(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
|
manager.operation.Lock()
|
||||||
|
defer manager.operation.Unlock()
|
||||||
|
disabled, err := manager.repository.DisableActiveIdentityRevision(ctx, expectedVersion, traceID, auditID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Revision{}, err
|
return identity.Revision{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (f *runtimeRepositoryFake) MarkIdentityRevisionFailed(_ context.Context, id
|
|||||||
f.revisions[id] = revision
|
f.revisions[id] = revision
|
||||||
return revision, nil
|
return revision, nil
|
||||||
}
|
}
|
||||||
func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64) (identity.Revision, bool, error) {
|
func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id string, expected int64, traceID, auditID string) (identity.Revision, bool, error) {
|
||||||
f.activateCalled = true
|
f.activateCalled = true
|
||||||
revision := f.revisions[id]
|
revision := f.revisions[id]
|
||||||
if revision.Version != expected || revision.State != identity.RevisionValidated {
|
if revision.Version != expected || revision.State != identity.RevisionValidated {
|
||||||
@@ -56,16 +56,16 @@ func (f *runtimeRepositoryFake) ActivateIdentityRevision(_ context.Context, id s
|
|||||||
old.State = identity.RevisionSuperseded
|
old.State = identity.RevisionSuperseded
|
||||||
f.revisions[old.ID] = old
|
f.revisions[old.ID] = old
|
||||||
}
|
}
|
||||||
revision.State, revision.Version = identity.RevisionActive, revision.Version+1
|
revision.State, revision.Version, revision.LastTraceID, revision.LastAuditID = identity.RevisionActive, revision.Version+1, traceID, auditID
|
||||||
f.active, f.revisions[id] = revision, revision
|
f.active, f.revisions[id] = revision, revision
|
||||||
return revision, true, nil
|
return revision, true, nil
|
||||||
}
|
}
|
||||||
func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64) (identity.Revision, error) {
|
func (f *runtimeRepositoryFake) DisableActiveIdentityRevision(_ context.Context, expected int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
if f.active.Version != expected {
|
if f.active.Version != expected {
|
||||||
return identity.Revision{}, identity.ErrRevisionConflict
|
return identity.Revision{}, identity.ErrRevisionConflict
|
||||||
}
|
}
|
||||||
disabled := f.active
|
disabled := f.active
|
||||||
disabled.State, disabled.Version = identity.RevisionSuperseded, disabled.Version+1
|
disabled.State, disabled.Version, disabled.LastTraceID, disabled.LastAuditID = identity.RevisionSuperseded, disabled.Version+1, traceID, auditID
|
||||||
f.revisions[disabled.ID] = disabled
|
f.revisions[disabled.ID] = disabled
|
||||||
f.active = identity.Revision{}
|
f.active = identity.Revision{}
|
||||||
return disabled, nil
|
return disabled, nil
|
||||||
@@ -110,7 +110,7 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
|
|||||||
manager := NewManager(repository, &runtimeBuilderFake{})
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
||||||
manager.current.Store(&Runtime{Revision: active})
|
manager.current.Store(&Runtime{Revision: active})
|
||||||
|
|
||||||
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version)
|
activated, err := manager.Activate(context.Background(), candidate.ID, candidate.Version, "trace", "audit")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -118,3 +118,21 @@ func TestActivationSwapsRuntimeOnlyAfterRepositoryActivation(t *testing.T) {
|
|||||||
t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current())
|
t.Fatalf("activation order failed: activated=%#v current=%#v", activated, manager.Current())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRollbackValidatesAndAtomicallyReplacesActiveRuntime(t *testing.T) {
|
||||||
|
active := identity.Revision{ID: "current", State: identity.RevisionActive, Version: 5}
|
||||||
|
previous := identity.Revision{ID: "previous", State: identity.RevisionSuperseded, Version: 3}
|
||||||
|
repository := &runtimeRepositoryFake{
|
||||||
|
revisions: map[string]identity.Revision{"current": active, "previous": previous}, active: active,
|
||||||
|
}
|
||||||
|
manager := NewManager(repository, &runtimeBuilderFake{})
|
||||||
|
manager.current.Store(&Runtime{Revision: active})
|
||||||
|
|
||||||
|
rolledBack, err := manager.Rollback(context.Background(), previous.ID, previous.Version, "trace", "audit")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rolledBack.State != identity.RevisionActive || manager.Current().Revision.ID != previous.ID || repository.active.ID != previous.ID {
|
||||||
|
t.Fatalf("rollback did not replace active runtime: revision=%#v current=%#v", rolledBack, manager.Current())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrIdentityManagementRequestNotFound = errors.New("identity management request not found")
|
||||||
|
|
||||||
|
type IdentityManagementRequest struct {
|
||||||
|
Operation string
|
||||||
|
Key string
|
||||||
|
RequestHash string
|
||||||
|
Response []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
const identityRevisionColumns = `
|
const identityRevisionColumns = `
|
||||||
id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
|
id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''),
|
||||||
COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix,
|
COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix,
|
||||||
@@ -45,6 +56,57 @@ FROM gateway_identity_configuration_revisions WHERE state='active'`))
|
|||||||
return revision, normalizeIdentityRevisionError(err)
|
return revision, normalizeIdentityRevisionError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) LatestInactiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
|
||||||
|
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||||
|
FROM gateway_identity_configuration_revisions WHERE state IN ('draft','validated','failed') ORDER BY created_at DESC LIMIT 1`))
|
||||||
|
return revision, normalizeIdentityRevisionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LatestSupersededIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) {
|
||||||
|
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+`
|
||||||
|
FROM gateway_identity_configuration_revisions WHERE state='superseded' ORDER BY superseded_at DESC NULLS LAST LIMIT 1`))
|
||||||
|
return revision, normalizeIdentityRevisionError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateIdentityRevisionPolicy(ctx context.Context, id string, expectedVersion int64, policy identity.RevisionPolicy) (identity.Revision, error) {
|
||||||
|
if err := policy.Validate(); err != nil {
|
||||||
|
return identity.Revision{}, err
|
||||||
|
}
|
||||||
|
revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE gateway_identity_configuration_revisions SET local_tenant_key=$3,role_prefix=$4,jit_enabled=$5,
|
||||||
|
legacy_jwt_enabled=$6,session_idle_seconds=$7,session_absolute_seconds=$8,session_refresh_seconds=$9,
|
||||||
|
version=version+1,updated_at=now()
|
||||||
|
WHERE id=$1::uuid AND version=$2 AND state='draft'
|
||||||
|
RETURNING `+identityRevisionColumns, id, expectedVersion, policy.LocalTenantKey, policy.RolePrefix, policy.JITEnabled,
|
||||||
|
policy.LegacyJWTEnabled, policy.SessionIdleSeconds, policy.SessionAbsoluteSeconds, policy.SessionRefreshSeconds))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return identity.Revision{}, identity.ErrRevisionConflict
|
||||||
|
}
|
||||||
|
return revision, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) IdentityManagementRequest(ctx context.Context, operation, key string) (IdentityManagementRequest, error) {
|
||||||
|
var request IdentityManagementRequest
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT operation,idempotency_key,request_hash,response,created_at
|
||||||
|
FROM gateway_identity_management_requests WHERE operation=$1 AND idempotency_key=$2`, operation, key).Scan(
|
||||||
|
&request.Operation, &request.Key, &request.RequestHash, &request.Response, &request.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return IdentityManagementRequest{}, ErrIdentityManagementRequestNotFound
|
||||||
|
}
|
||||||
|
return request, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecordIdentityManagementRequest(ctx context.Context, request IdentityManagementRequest) error {
|
||||||
|
if !json.Valid(request.Response) {
|
||||||
|
return errors.New("identity management response is invalid")
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `INSERT INTO gateway_identity_management_requests(operation,idempotency_key,request_hash,response)
|
||||||
|
VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(operation,idempotency_key) DO NOTHING`,
|
||||||
|
request.Operation, request.Key, request.RequestHash, request.Response)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
|
func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) {
|
||||||
current, err := s.IdentityConfigurationRevision(ctx, id)
|
current, err := s.IdentityConfigurationRevision(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +165,7 @@ RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, aud
|
|||||||
return revision, err
|
return revision, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64) (identity.Revision, bool, error) {
|
func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, bool, error) {
|
||||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Revision{}, false, err
|
return identity.Revision{}, false, err
|
||||||
@@ -143,8 +205,8 @@ version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active',
|
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active',
|
||||||
activated_at=now(),superseded_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='validated'
|
activated_at=now(),superseded_at=NULL,last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now()
|
||||||
RETURNING `+identityRevisionColumns, id, expectedVersion))
|
WHERE id=$1::uuid AND version=$2 AND state='validated' RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Revision{}, false, err
|
return identity.Revision{}, false, err
|
||||||
}
|
}
|
||||||
@@ -160,7 +222,7 @@ RETURNING `+identityRevisionColumns, id, expectedVersion))
|
|||||||
return revision, identityChanged, nil
|
return revision, identityChanged, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64) (identity.Revision, error) {
|
func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64, traceID, auditID string) (identity.Revision, error) {
|
||||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return identity.Revision{}, err
|
return identity.Revision{}, err
|
||||||
@@ -172,7 +234,8 @@ func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersi
|
|||||||
return identity.Revision{}, identity.ErrBreakGlassRequired
|
return identity.Revision{}, identity.ErrBreakGlassRequired
|
||||||
}
|
}
|
||||||
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
|
revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',
|
||||||
superseded_at=now(),version=version+1,updated_at=now() WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion))
|
superseded_at=now(),last_trace_id=NULLIF($2,''),last_audit_id=NULLIF($3,''),version=version+1,updated_at=now()
|
||||||
|
WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion, traceID, auditID))
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return identity.Revision{}, identity.ErrRevisionConflict
|
return identity.Revision{}, identity.ErrRevisionConflict
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,34 @@ FROM gateway_identity_onboarding_exchanges WHERE id=$1::uuid`, id))
|
|||||||
return exchange, err
|
return exchange, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) IdentityPairingExchangeForRevision(ctx context.Context, revisionID string) (identity.PairingExchange, error) {
|
||||||
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `SELECT `+identityPairingColumns+`
|
||||||
|
FROM gateway_identity_onboarding_exchanges WHERE revision_id=$1::uuid`, revisionID))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return identity.PairingExchange{}, identity.ErrRevisionNotFound
|
||||||
|
}
|
||||||
|
return exchange, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PendingIdentityPairingExchanges(ctx context.Context) ([]identity.PairingExchange, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT `+identityPairingColumns+`
|
||||||
|
FROM gateway_identity_onboarding_exchanges
|
||||||
|
WHERE status IN ('metadata_pending','preparing','ready','credentials_saved') ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
exchanges := make([]identity.PairingExchange, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
exchange, scanErr := scanIdentityPairing(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
exchanges = append(exchanges, exchange)
|
||||||
|
}
|
||||||
|
return exchanges, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
|
func (s *Store) UpdateIdentityPairingExchange(ctx context.Context, id string, expectedVersion int64, update identity.PairingExchangeUpdate) (identity.PairingExchange, error) {
|
||||||
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
|
exchange, err := scanIdentityPairing(s.pool.QueryRow(ctx, `
|
||||||
UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''),
|
UPDATE gateway_identity_onboarding_exchanges SET status=$3,remote_version=$4,last_error_category=NULLIF($5,''),
|
||||||
|
|||||||
Reference in New Issue
Block a user