修复 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。
299 lines
12 KiB
Go
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()
|
|
}
|