easyai-ai-gateway/apps/api/internal/identity/configuration.go
chengcheng a312ad880d fix(identity): 完善统一认证配对恢复与安全退役
修复 credentials_saved 状态无法恢复、配对与激活并发冲突,以及 SSF 和身份 Secret 生命周期不完整的问题。新增持久化协调器、取消与清理状态机、事务级并发门禁、受控 SSF 凭据交接、禁用后的延迟 Secret 清理,并对生产环境统一认证及 Discovery 端点强制 HTTPS。

验证:go test ./...;go test -race ./internal/auth ./internal/identity ./internal/identityruntime ./internal/securityevents ./internal/httpapi ./internal/store -count=1;go vet ./...;真实 PostgreSQL 并发及清理成功/冲突回滚测试;pnpm openapi。
2026-07-17 18:31:12 +08:00

299 lines
12 KiB
Go

package identity
import (
"errors"
"net"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
var (
ErrRevisionNotFound = errors.New("identity configuration revision not found")
ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state")
ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required")
ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid")
ErrActiveConfigurationHandoffRequired = errors.New("active identity configuration requires an explicit remote resource handoff before re-pairing")
ErrRollbackConfigurationHandoffRequired = errors.New("rollback requires a fresh remote resource handoff")
ErrSecurityEventRetirementPending = errors.New("active security event connection must retire before identity can be disabled")
)
type RevisionPolicy struct {
LocalTenantKey string `json:"localTenantKey"`
RolePrefix string `json:"rolePrefix"`
JITEnabled bool `json:"jitEnabled"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
}
func (policy RevisionPolicy) Validate() error {
if strings.TrimSpace(policy.LocalTenantKey) == "" || strings.TrimSpace(policy.RolePrefix) == "" {
return errors.New("local tenant mapping and role prefix are required")
}
if policy.SessionIdleSeconds <= 0 || policy.SessionAbsoluteSeconds <= policy.SessionIdleSeconds ||
policy.SessionRefreshSeconds <= 0 || policy.SessionRefreshSeconds >= policy.SessionIdleSeconds {
return errors.New("identity session policy is invalid")
}
return nil
}
type RevisionState string
const (
RevisionDraft RevisionState = "draft"
RevisionValidated RevisionState = "validated"
RevisionActive RevisionState = "active"
RevisionSuperseded RevisionState = "superseded"
RevisionFailed RevisionState = "failed"
)
type Revision struct {
ID string `json:"id"`
State RevisionState `json:"state"`
SchemaVersion int `json:"schemaVersion"`
AuthCenterURL string `json:"authCenterUrl"`
Issuer string `json:"issuer,omitempty"`
TenantID string `json:"tenantId,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
Audience string `json:"audience,omitempty"`
BrowserClientID string `json:"browserClientId,omitempty"`
MachineClientID string `json:"machineClientId,omitempty"`
Scopes []string `json:"scopes"`
Capabilities []string `json:"capabilities"`
RolePrefix string `json:"rolePrefix"`
LocalTenantKey string `json:"localTenantKey"`
PublicBaseURL string `json:"publicBaseUrl"`
WebBaseURL string `json:"webBaseUrl"`
JITEnabled bool `json:"jitEnabled"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
TokenIntrospection bool `json:"tokenIntrospection"`
SessionRevocation bool `json:"sessionRevocation"`
SecurityEventIssuer string `json:"securityEventIssuer,omitempty"`
SecurityEventConfigURL string `json:"securityEventConfigurationUrl,omitempty"`
SecurityEventAudience string `json:"securityEventAudience,omitempty"`
MachineCredentialRef string `json:"-"`
SessionEncryptionKeyRef string `json:"-"`
SessionIdleSeconds int `json:"sessionIdleSeconds"`
SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"`
SessionRefreshSeconds int `json:"sessionRefreshSeconds"`
Version int64 `json:"version"`
LastErrorCategory string `json:"lastErrorCategory,omitempty"`
LastTraceID string `json:"lastTraceId,omitempty"`
LastAuditID string `json:"lastAuditId,omitempty"`
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
SupersededAt *time.Time `json:"supersededAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PairingInput struct {
AuthCenterURL string `json:"authCenterUrl"`
OnboardingCode string `json:"onboardingCode"`
PublicBaseURL string `json:"publicBaseUrl"`
WebBaseURL string `json:"webBaseUrl"`
LocalTenantKey string `json:"localTenantKey"`
LegacyJWTEnabled bool `json:"legacyJwtEnabled"`
}
type ConsumerMetadata struct {
PublicBaseURL string `json:"public_base_url"`
WebBaseURL string `json:"web_base_url"`
RedirectURIs []string `json:"redirect_uris"`
LogoutURIs []string `json:"logout_uris"`
ReceiverEndpoint string `json:"receiver_endpoint,omitempty"`
}
type ManifestApplication struct {
Manifest ManifestV1
MachineCredentialRef string
SessionEncryptionKeyRef string
TraceID string
AuditID string
AppEnv string
}
func NewDraft(input PairingInput, appEnv string) (Revision, error) {
if _, err := input.ConsumerMetadata(false, appEnv); err != nil {
return Revision{}, err
}
authCenter, _ := exactBaseURL(input.AuthCenterURL, appEnv)
publicBase, _ := exactBaseURL(input.PublicBaseURL, appEnv)
webBase, _ := exactBaseURL(input.WebBaseURL, appEnv)
return Revision{
ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1,
AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey),
PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled,
Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800,
SessionRefreshSeconds: 60, Version: 1,
}, nil
}
func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) {
if revision.State != RevisionDraft {
return Revision{}, ErrRevisionConflict
}
if err := input.Manifest.Validate(input.AppEnv); err != nil {
return Revision{}, err
}
capabilities := make(map[string]bool, len(input.Manifest.Capabilities))
for _, capability := range input.Manifest.Capabilities {
capabilities[capability] = true
}
if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" {
return Revision{}, errors.New("machine credential reference is required")
}
if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" {
return Revision{}, errors.New("session encryption key reference is required")
}
revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/")
revision.TenantID = input.Manifest.TenantID
revision.ApplicationID = input.Manifest.ApplicationID
revision.Audience = input.Manifest.Audience
revision.Scopes = append([]string(nil), input.Manifest.Scopes...)
revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...)
if input.Manifest.Clients.BrowserLogin != nil {
revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID
}
if input.Manifest.Clients.MachineToMachine != nil {
revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID
}
revision.TokenIntrospection = capabilities["token_introspection"]
revision.SessionRevocation = capabilities["session_revocation"]
if input.Manifest.SecurityEvents != nil {
revision.SecurityEventIssuer = input.Manifest.SecurityEvents.TransmitterIssuer
revision.SecurityEventConfigURL = input.Manifest.SecurityEvents.ConfigurationEndpoint
revision.SecurityEventAudience = input.Manifest.SecurityEvents.Audience
}
revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef)
revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef)
revision.LastTraceID = strings.TrimSpace(input.TraceID)
revision.LastAuditID = strings.TrimSpace(input.AuditID)
return revision, nil
}
func CanTransition(from, to RevisionState) bool {
switch from {
case RevisionDraft:
return to == RevisionValidated || to == RevisionFailed
case RevisionValidated:
return to == RevisionActive || to == RevisionFailed
case RevisionActive:
return to == RevisionSuperseded
case RevisionSuperseded:
return to == RevisionValidated
default:
return false
}
}
func (input PairingInput) ConsumerMetadata(sessionRevocation bool, appEnv string) (ConsumerMetadata, error) {
authCenter, err := exactBaseURL(input.AuthCenterURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("auth center URL is invalid")
}
_ = authCenter
publicBase, err := exactBaseURL(input.PublicBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("public base URL is invalid")
}
webBase, err := exactBaseURL(input.WebBaseURL, appEnv)
if err != nil {
return ConsumerMetadata{}, errors.New("web base URL is invalid")
}
if strings.TrimSpace(input.LocalTenantKey) == "" {
return ConsumerMetadata{}, errors.New("local tenant mapping is required")
}
metadata := ConsumerMetadata{
PublicBaseURL: publicBase, WebBaseURL: webBase,
RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"},
LogoutURIs: []string{webBase + "/"},
}
if sessionRevocation {
metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf"
}
return metadata, nil
}
func exactBaseURL(raw, appEnv string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("invalid public URL")
}
hostname := strings.ToLower(parsed.Hostname())
if parsed.Path != "" && parsed.Path != "/" {
return "", errors.New("base URL must not contain a path")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && !(scheme == "http" && isLocalIdentityEnvironment(appEnv) && isLoopbackHost(hostname)) {
return "", errors.New("public URL must use HTTPS")
}
port := parsed.Port()
if scheme == "https" && port == "443" || scheme == "http" && port == "80" {
port = ""
}
host := hostname
if strings.Contains(hostname, ":") {
host = "[" + hostname + "]"
}
if port != "" {
host = net.JoinHostPort(hostname, port)
}
return scheme + "://" + host, nil
}
func isLocalIdentityEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
// ValidateRevisionURLs re-applies the deployment environment URL policy when
// constructing a Runtime. This protects against legacy or directly persisted
// revisions bypassing the pairing boundary.
func ValidateRevisionURLs(revision Revision, appEnv string) error {
urls := []struct {
label string
raw string
base bool
}{
{label: "auth center", raw: revision.AuthCenterURL, base: true},
{label: "issuer", raw: revision.Issuer},
{label: "public base", raw: revision.PublicBaseURL, base: true},
{label: "web base", raw: revision.WebBaseURL, base: true},
}
for _, candidate := range urls {
var err error
if candidate.base {
_, err = exactBaseURL(candidate.raw, appEnv)
} else {
err = validatePublicIdentityURL(candidate.raw, appEnv)
}
if err != nil {
return errors.New("identity " + candidate.label + " URL must use HTTPS in this environment")
}
}
if revision.SessionRevocation || revision.SecurityEventIssuer != "" || revision.SecurityEventConfigURL != "" {
if validatePublicIdentityURL(revision.SecurityEventIssuer, appEnv) != nil ||
validatePublicIdentityURL(revision.SecurityEventConfigURL, appEnv) != nil {
return errors.New("identity security event URLs must use HTTPS in this environment")
}
}
return nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}