修复 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。
1415 lines
54 KiB
Go
1415 lines
54 KiB
Go
package securityevents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
sessionRevokedEvent = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
|
|
pushDeliveryMethod = "urn:ietf:rfc:8935"
|
|
)
|
|
|
|
type ConnectionRepository interface {
|
|
Repository
|
|
StateRepository
|
|
CreateSecurityEventConnection(context.Context, store.CreateSecurityEventConnectionInput) (store.SecurityEventConnection, error)
|
|
SecurityEventConnection(context.Context) (store.SecurityEventConnection, error)
|
|
BindSecurityEventConnection(context.Context, string, string, string, string, int64) (store.SecurityEventConnection, error)
|
|
SetSecurityEventManagementCredential(context.Context, string, string, string, int64) (store.SecurityEventConnection, error)
|
|
TransitionSecurityEventConnectionLifecycle(context.Context, string, string, int64, string, *string) (store.SecurityEventConnection, error)
|
|
SetSecurityEventNextCredential(context.Context, string, string, int64) (store.SecurityEventConnection, error)
|
|
PromoteSecurityEventCredential(context.Context, string, string) (store.SecurityEventConnection, error)
|
|
DiscardPreparedSecurityEventConnection(context.Context, string, string, int64) error
|
|
FinalizeRetiringSecurityEventConnection(context.Context, string, int64) error
|
|
SecurityEventMetrics(context.Context, string, string) (string, *time.Time, error)
|
|
QueueIdentitySecretCleanup(context.Context, string, time.Time) error
|
|
RemovePendingIdentitySecretCleanup(context.Context, string) (bool, error)
|
|
}
|
|
|
|
type ConnectionManagerConfig struct {
|
|
AppEnv string
|
|
OIDCEnabled bool
|
|
OIDCIssuer string
|
|
OIDCTenantID string
|
|
ManagementClientID string
|
|
ManagementClientSecret string
|
|
ExpectedTransmitterIssuer string
|
|
ExpectedAudience string
|
|
ExpectedOwnerKey string
|
|
StrictRevisionBinding bool
|
|
PublicBaseURL string
|
|
HeartbeatInterval time.Duration
|
|
StaleAfter time.Duration
|
|
ClockSkew time.Duration
|
|
BootstrapDuration time.Duration
|
|
CredentialOverlapDuration time.Duration
|
|
RetirementDuration time.Duration
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type ConnectionView struct {
|
|
ConnectionID string `json:"connectionId"`
|
|
TransmitterIssuer string `json:"transmitterIssuer"`
|
|
ReceiverEndpoint string `json:"receiverEndpoint"`
|
|
Audience *string `json:"audience,omitempty"`
|
|
StreamID *string `json:"streamId,omitempty"`
|
|
LifecycleStatus string `json:"lifecycleStatus"`
|
|
HealthMode string `json:"healthMode"`
|
|
ManagementClientID string `json:"managementClientId"`
|
|
LastVerificationAt *time.Time `json:"lastVerificationAt,omitempty"`
|
|
LastErrorCategory *string `json:"lastErrorCategory,omitempty"`
|
|
Version int64 `json:"version"`
|
|
}
|
|
|
|
type connectionRuntime struct {
|
|
handler *Handler
|
|
service *Service
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type connectionRetirementAuthorization struct {
|
|
connectionID string
|
|
credentialRef string
|
|
ownerKey string
|
|
}
|
|
|
|
type ConnectionManager struct {
|
|
ctx context.Context
|
|
repository ConnectionRepository
|
|
secrets SecretStore
|
|
config ConnectionManagerConfig
|
|
metrics *Metrics
|
|
mutex sync.RWMutex
|
|
operation sync.Mutex
|
|
runtime *connectionRuntime
|
|
retirement *connectionRetirementAuthorization
|
|
bindingMismatch bool
|
|
}
|
|
|
|
type transmitterConfiguration struct {
|
|
SpecVersion string `json:"spec_version"`
|
|
Issuer string `json:"issuer"`
|
|
JWKSURI string `json:"jwks_uri"`
|
|
ConfigurationEndpoint string `json:"configuration_endpoint"`
|
|
StatusEndpoint string `json:"status_endpoint"`
|
|
VerificationEndpoint string `json:"verification_endpoint"`
|
|
DeliveryMethodsSupported []string `json:"delivery_methods_supported"`
|
|
}
|
|
|
|
type streamConfiguration struct {
|
|
StreamID string `json:"stream_id"`
|
|
Issuer string `json:"iss"`
|
|
Audience string `json:"aud"`
|
|
EventsRequested []string `json:"events_requested"`
|
|
Delivery struct {
|
|
Method string `json:"method"`
|
|
EndpointURL string `json:"endpoint_url"`
|
|
} `json:"delivery"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
type remoteHTTPError struct{ status int }
|
|
|
|
func (e remoteHTTPError) Error() string {
|
|
return fmt.Sprintf("SSF endpoint returned HTTP %d", e.status)
|
|
}
|
|
|
|
type safeConnectionError struct {
|
|
category string
|
|
cause error
|
|
}
|
|
|
|
func (err safeConnectionError) Error() string {
|
|
return "security event operation failed: " + err.category
|
|
}
|
|
func (err safeConnectionError) Unwrap() error { return err.cause }
|
|
func (err safeConnectionError) SafeErrorCategory() string { return err.category }
|
|
|
|
func NewConnectionManager(ctx context.Context, repository ConnectionRepository, secrets SecretStore, config ConnectionManagerConfig, metrics *Metrics) (*ConnectionManager, error) {
|
|
if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || config.OIDCTenantID == "" {
|
|
return nil, errors.New("security event connection prerequisites are incomplete")
|
|
}
|
|
if config.HeartbeatInterval <= 0 {
|
|
config.HeartbeatInterval = time.Minute
|
|
}
|
|
if config.StaleAfter < 2*config.HeartbeatInterval {
|
|
config.StaleAfter = 3 * time.Minute
|
|
}
|
|
if config.ClockSkew == 0 {
|
|
config.ClockSkew = time.Minute
|
|
}
|
|
if config.BootstrapDuration == 0 {
|
|
config.BootstrapDuration = 6 * time.Minute
|
|
}
|
|
if config.CredentialOverlapDuration == 0 {
|
|
config.CredentialOverlapDuration = 3 * time.Minute
|
|
}
|
|
if config.RetirementDuration == 0 {
|
|
config.RetirementDuration = defaultSecurityEventRetirementDuration
|
|
}
|
|
manager := &ConnectionManager{ctx: ctx, repository: repository, secrets: secrets, config: config, metrics: metrics}
|
|
connection, err := repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
if config.StrictRevisionBinding {
|
|
return nil, safeConnectionError{category: "connection_binding_missing", cause: err}
|
|
}
|
|
return manager, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load security event connection: %w", err)
|
|
}
|
|
if config.StrictRevisionBinding {
|
|
if err := manager.validateConnectionBinding(connection); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if hasRevisionBindingExpectation(config) {
|
|
if err := manager.validateConnectionBinding(connection); err != nil {
|
|
// A recovery manager may inspect a foreign singleton connection, but
|
|
// it must not activate it: activate() would combine the old persisted
|
|
// Secret with the new Revision's token endpoint.
|
|
if connection.LifecycleStatus == "retiring" {
|
|
go manager.finishRetirement(connection)
|
|
}
|
|
manager.bindingMismatch = true
|
|
return manager, nil
|
|
}
|
|
}
|
|
if connection.LifecycleStatus == "retiring" {
|
|
if connection.StreamID != nil && connection.Audience != nil {
|
|
_ = manager.activate(ctx, connection)
|
|
}
|
|
go manager.finishRetirement(connection)
|
|
return manager, nil
|
|
}
|
|
if connection.LifecycleStatus == "disconnect_pending" {
|
|
if connection.StreamID != nil && connection.Audience != nil {
|
|
_ = manager.activate(ctx, connection)
|
|
}
|
|
go manager.retryDisconnect(connection.ConnectionID)
|
|
return manager, nil
|
|
}
|
|
if connection.StreamID != nil && connection.Audience != nil {
|
|
if err := manager.activate(ctx, connection); err != nil {
|
|
category := "credential_unavailable"
|
|
_, _ = repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "degraded", &category,
|
|
)
|
|
}
|
|
}
|
|
if connection.LifecycleStatus == "bootstrap" {
|
|
go manager.finishBootstrap(connection.ConnectionID)
|
|
}
|
|
return manager, nil
|
|
}
|
|
|
|
func hasRevisionBindingExpectation(config ConnectionManagerConfig) bool {
|
|
return strings.TrimSpace(config.ExpectedTransmitterIssuer) != "" ||
|
|
strings.TrimSpace(config.ExpectedAudience) != "" ||
|
|
strings.TrimSpace(config.ExpectedOwnerKey) != ""
|
|
}
|
|
|
|
// ValidateConfiguredConnectionBinding verifies that the singleton persisted
|
|
// SSF connection belongs to the identity Revision represented by this manager.
|
|
// Recovery managers call this explicitly only when they are about to become an
|
|
// Active Runtime; cleanup and retirement remain able to inspect old bindings.
|
|
func (m *ConnectionManager) ValidateConfiguredConnectionBinding(ctx context.Context) error {
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return safeConnectionError{category: "connection_binding_missing", cause: err}
|
|
}
|
|
if err != nil {
|
|
return safeConnectionError{category: "connection_binding_unavailable", cause: err}
|
|
}
|
|
return m.validateConnectionBinding(connection)
|
|
}
|
|
|
|
func (m *ConnectionManager) validateConnectionBinding(connection store.SecurityEventConnection) error {
|
|
expectedIssuer := strings.TrimRight(strings.TrimSpace(m.config.ExpectedTransmitterIssuer), "/")
|
|
expectedAudience := strings.TrimSpace(m.config.ExpectedAudience)
|
|
expectedClientID := strings.TrimSpace(m.config.ManagementClientID)
|
|
expectedOwner := strings.TrimSpace(m.config.ExpectedOwnerKey)
|
|
if expectedIssuer == "" || expectedAudience == "" || expectedClientID == "" || expectedOwner == "" {
|
|
return safeConnectionError{category: "connection_binding_invalid", cause: errors.New("security event Revision binding is incomplete")}
|
|
}
|
|
if connection.TransmitterIssuer != expectedIssuer || connection.Audience == nil || *connection.Audience != expectedAudience ||
|
|
connection.ManagementClientID != expectedClientID || connection.IdempotencyKey != expectedOwner {
|
|
return safeConnectionError{category: "connection_binding_mismatch", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) Get(ctx context.Context) (ConnectionView, error) {
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
return m.view(connection), nil
|
|
}
|
|
|
|
func (m *ConnectionManager) IntrospectionCredential(ctx context.Context) (string, []byte, error) {
|
|
if err := m.requireOperationalBinding(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
|
|
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
|
|
}
|
|
return "", nil, errors.New("managed machine credential is unavailable")
|
|
}
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return m.managementCredential(ctx, connection)
|
|
}
|
|
|
|
func (m *ConnectionManager) Connect(ctx context.Context, issuer, managementClientID string, managementSecret []byte, idempotencyKey string) (ConnectionView, error) {
|
|
m.operation.Lock()
|
|
defer m.operation.Unlock()
|
|
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
|
managementClientID = strings.TrimSpace(managementClientID)
|
|
if idempotencyKey == "" {
|
|
return ConnectionView{}, errors.New("idempotency key is required")
|
|
}
|
|
existing, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return ConnectionView{}, err
|
|
}
|
|
credentialsProvided := managementClientID != "" || len(managementSecret) > 0
|
|
if (managementClientID == "") != (len(managementSecret) == 0) {
|
|
return ConnectionView{}, errors.New("machine client id and secret must be provided together")
|
|
}
|
|
if err == nil && (existing.LifecycleStatus == "retiring" ||
|
|
existing.LifecycleStatus == "disconnect_pending" && existing.IdempotencyKey == idempotencyKey) {
|
|
return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if !credentialsProvided {
|
|
if err == nil {
|
|
managementClientID, managementSecret, _ = m.managementCredential(ctx, existing)
|
|
} else {
|
|
managementClientID = strings.TrimSpace(m.config.ManagementClientID)
|
|
managementSecret = []byte(m.config.ManagementClientSecret)
|
|
}
|
|
}
|
|
defer clear(managementSecret)
|
|
if err := m.validatePrerequisites(issuer, managementClientID, managementSecret, errors.Is(err, store.ErrSecurityEventConnectionNotFound)); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
if err == nil {
|
|
if m.bindingMismatch && existing.IdempotencyKey == idempotencyKey {
|
|
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if existing.TransmitterIssuer != issuer {
|
|
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if credentialsProvided {
|
|
if existing.IdempotencyKey != idempotencyKey {
|
|
if existing.ManagementClientID == "" || existing.ManagementClientID != managementClientID {
|
|
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if existing.StreamID == nil {
|
|
m.retirement = &connectionRetirementAuthorization{
|
|
connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey,
|
|
}
|
|
return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if m.retirement == nil || m.retirement.connectionID != existing.ConnectionID ||
|
|
m.retirement.credentialRef != valueOrEmpty(existing.ManagementCredentialRef) ||
|
|
m.retirement.ownerKey != idempotencyKey {
|
|
if err := m.proveManagementCredentialHandoff(ctx, existing, managementClientID, managementSecret); err != nil {
|
|
return ConnectionView{}, safeConnectionError{category: "credential_handoff_unsafe", cause: err}
|
|
}
|
|
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
m.retirement = &connectionRetirementAuthorization{
|
|
connectionID: existing.ConnectionID, credentialRef: valueOrEmpty(existing.ManagementCredentialRef), ownerKey: idempotencyKey,
|
|
}
|
|
}
|
|
if existing.LifecycleStatus == "disconnect_pending" {
|
|
go m.retryDisconnect(existing.ConnectionID)
|
|
return ConnectionView{}, safeConnectionError{category: "retirement_pending", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
return ConnectionView{}, safeConnectionError{category: "connection_conflict", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if existing.ManagementClientID != "" && existing.ManagementClientID != managementClientID {
|
|
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
|
|
}
|
|
existing, err = m.persistManagementCredential(ctx, existing, managementClientID, managementSecret)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
}
|
|
if existing.StreamID != nil && !resumableConnectionLifecycle(existing.LifecycleStatus) {
|
|
if existing.Audience != nil {
|
|
if err := m.activate(ctx, existing); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
}
|
|
return m.view(existing), nil
|
|
}
|
|
return m.resumeConnecting(ctx, existing)
|
|
}
|
|
connectionID := uuid.NewString()
|
|
reference := "ssf-push-" + connectionID
|
|
managementReference := "ssf-management-" + connectionID
|
|
if err := m.stageIdentitySecret(ctx, managementReference, managementSecret); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
secret, err := randomPushBearer()
|
|
if err != nil {
|
|
_ = m.retireIdentitySecret(ctx, managementReference)
|
|
return ConnectionView{}, err
|
|
}
|
|
defer clear(secret)
|
|
if err := m.stageIdentitySecret(ctx, reference, secret); err != nil {
|
|
_ = m.retireIdentitySecret(ctx, managementReference)
|
|
return ConnectionView{}, err
|
|
}
|
|
endpoint := strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf"
|
|
connection, err := m.repository.CreateSecurityEventConnection(ctx, store.CreateSecurityEventConnectionInput{
|
|
ConnectionID: connectionID, TransmitterIssuer: issuer, EndpointURL: endpoint,
|
|
CredentialRef: reference, ManagementClientID: managementClientID,
|
|
ManagementCredentialRef: managementReference, IdempotencyKey: idempotencyKey,
|
|
})
|
|
if err != nil {
|
|
// Commit outcome can be ambiguous: rollback keeps the staging rows,
|
|
// while commit atomically removes them. Requeueing here could delete
|
|
// credentials already referenced by the committed connection.
|
|
return ConnectionView{}, err
|
|
}
|
|
return m.resumeConnecting(ctx, connection)
|
|
}
|
|
|
|
func (m *ConnectionManager) persistManagementCredential(ctx context.Context, connection store.SecurityEventConnection, clientID string, secret []byte) (store.SecurityEventConnection, error) {
|
|
reference := "ssf-management-" + connection.ConnectionID + "-" + uuid.NewString()
|
|
if err := m.stageIdentitySecret(ctx, reference, secret); err != nil {
|
|
return store.SecurityEventConnection{}, err
|
|
}
|
|
updated, err := m.repository.SetSecurityEventManagementCredential(ctx, connection.ConnectionID, clientID, reference, connection.Version)
|
|
if err != nil {
|
|
// The staging row is the source of truth when commit outcome is unknown.
|
|
return store.SecurityEventConnection{}, err
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func resumableConnectionLifecycle(status string) bool {
|
|
switch status {
|
|
case "connecting", "verifying", "degraded", "error":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m *ConnectionManager) resumeConnecting(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) {
|
|
secret, err := m.secrets.Get(ctx, connection.CredentialRef)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
defer clear(secret)
|
|
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
|
|
if err != nil {
|
|
return ConnectionView{}, m.connectionError(ctx, connection, "discovery_failed", err)
|
|
}
|
|
token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
|
|
if err != nil {
|
|
return ConnectionView{}, m.connectionError(ctx, connection, "management_token_failed", err)
|
|
}
|
|
description := "easyai-gateway:" + connection.ConnectionID
|
|
stream, err := m.findStream(ctx, client, configuration, token, description, connection.EndpointURL)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
stream, err = m.createStream(ctx, client, configuration, token, connection.EndpointURL, string(secret), description)
|
|
}
|
|
if err != nil {
|
|
return ConnectionView{}, m.connectionError(ctx, connection, "stream_create_failed", err)
|
|
}
|
|
if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil {
|
|
return ConnectionView{}, m.connectionError(ctx, connection, "stream_response_invalid", err)
|
|
}
|
|
connection, err = m.repository.BindSecurityEventConnection(ctx, connection.ConnectionID, stream.Audience, stream.StreamID, "verifying", connection.Version)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
started := time.Now().UTC()
|
|
if err := m.activate(ctx, connection); err != nil {
|
|
return ConnectionView{}, m.connectionError(ctx, connection, "receiver_activation_failed", err)
|
|
}
|
|
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, false)
|
|
return m.view(connection), nil
|
|
}
|
|
|
|
func (m *ConnectionManager) Verify(ctx context.Context) (ConnectionView, error) {
|
|
if err := m.requireOperationalBinding(); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
m.mutex.RLock()
|
|
runtime := m.runtime
|
|
m.mutex.RUnlock()
|
|
if runtime == nil || runtime.service == nil {
|
|
return ConnectionView{}, errors.New("security event receiver is unavailable")
|
|
}
|
|
started := time.Now().UTC()
|
|
runtime.service.RequestVerification(ctx)
|
|
if connection.LifecycleStatus == "verifying" || connection.LifecycleStatus == "rotating" {
|
|
configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer)
|
|
if discoverErr != nil {
|
|
return ConnectionView{}, discoverErr
|
|
}
|
|
token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
|
|
if tokenErr != nil {
|
|
return ConnectionView{}, tokenErr
|
|
}
|
|
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, connection.LifecycleStatus == "rotating")
|
|
}
|
|
return m.view(connection), nil
|
|
}
|
|
|
|
func (m *ConnectionManager) RotateCredential(ctx context.Context) (ConnectionView, error) {
|
|
m.operation.Lock()
|
|
defer m.operation.Unlock()
|
|
if err := m.requireOperationalBinding(); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
if connection.StreamID == nil || connection.Audience == nil {
|
|
return ConnectionView{}, store.ErrSecurityEventConnectionConflict
|
|
}
|
|
var nextReference string
|
|
var next []byte
|
|
if connection.NextCredentialRef == nil {
|
|
nextReference = "ssf-push-" + connection.ConnectionID + "-next-" + uuid.NewString()
|
|
next, err = randomPushBearer()
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
if err := m.stageIdentitySecret(ctx, nextReference, next); err != nil {
|
|
clear(next)
|
|
return ConnectionView{}, err
|
|
}
|
|
connection, err = m.repository.SetSecurityEventNextCredential(ctx, connection.ConnectionID, nextReference, connection.Version)
|
|
if err != nil {
|
|
clear(next)
|
|
// Never recreate cleanup intent after an ambiguous adoption commit.
|
|
return ConnectionView{}, err
|
|
}
|
|
} else {
|
|
nextReference = *connection.NextCredentialRef
|
|
next, err = m.secrets.Get(ctx, nextReference)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
}
|
|
defer clear(next)
|
|
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
token, err := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "paused", "credential_rotation"); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
if err := m.patchStreamCredential(ctx, client, configuration, token, *connection.StreamID, connection.EndpointURL, string(next)); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
started := time.Now().UTC()
|
|
if err := m.activate(ctx, connection); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
go m.finishAutomaticVerification(connection.ConnectionID, configuration, client, token, started, true)
|
|
return m.view(connection), nil
|
|
}
|
|
|
|
func (m *ConnectionManager) Disconnect(ctx context.Context) (ConnectionView, error) {
|
|
m.operation.Lock()
|
|
defer m.operation.Unlock()
|
|
if err := m.requireOperationalBinding(); err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
return m.disconnect(ctx, connection)
|
|
}
|
|
|
|
// RetireConflictingConnection is a pairing-scoped recovery operation. It may
|
|
// retire only a connection owned by another workflow; a stale request becomes
|
|
// a no-op once the current pairing owns the singleton connection.
|
|
func (m *ConnectionManager) RetireConflictingConnection(ctx context.Context, currentOwnerKey string) error {
|
|
m.operation.Lock()
|
|
defer m.operation.Unlock()
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(currentOwnerKey) == "" {
|
|
return errors.New("current security event owner is required")
|
|
}
|
|
if connection.IdempotencyKey == currentOwnerKey {
|
|
return nil
|
|
}
|
|
if connection.LifecycleStatus == "retiring" {
|
|
go m.finishRetirement(connection)
|
|
return nil
|
|
}
|
|
if m.retirement == nil || m.retirement.connectionID != connection.ConnectionID ||
|
|
m.retirement.credentialRef != valueOrEmpty(connection.ManagementCredentialRef) ||
|
|
m.retirement.ownerKey != currentOwnerKey {
|
|
return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
if connection.LifecycleStatus == "disconnect_pending" {
|
|
go m.retryDisconnect(connection.ConnectionID)
|
|
return nil
|
|
}
|
|
_, err = m.disconnect(ctx, connection)
|
|
return err
|
|
}
|
|
|
|
func (m *ConnectionManager) disconnect(ctx context.Context, connection store.SecurityEventConnection) (ConnectionView, error) {
|
|
if connection.LifecycleStatus == "retiring" || connection.LifecycleStatus == "disconnect_pending" {
|
|
return m.view(connection), nil
|
|
}
|
|
if connection.StreamID != nil {
|
|
configuration, client, discoverErr := m.discover(ctx, connection.TransmitterIssuer)
|
|
if discoverErr == nil {
|
|
token, tokenErr := m.managementToken(ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
|
|
if tokenErr == nil {
|
|
discoverErr = m.deleteStream(ctx, client, configuration, token, *connection.StreamID)
|
|
if isRemoteHTTPStatus(discoverErr, http.StatusNotFound) {
|
|
discoverErr = nil
|
|
}
|
|
}
|
|
}
|
|
if discoverErr != nil {
|
|
category := "remote_disconnect_failed"
|
|
transitioned, transitionErr := m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "disconnect_pending", &category,
|
|
)
|
|
if transitionErr != nil {
|
|
return ConnectionView{}, transitionErr
|
|
}
|
|
connection = transitioned
|
|
go m.retryDisconnect(connection.ConnectionID)
|
|
return m.view(connection), nil
|
|
}
|
|
}
|
|
connection, err := m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "retiring", nil,
|
|
)
|
|
if err != nil {
|
|
return ConnectionView{}, err
|
|
}
|
|
go m.finishRetirement(connection)
|
|
return m.view(connection), nil
|
|
}
|
|
|
|
// DiscardPreparedConnection removes only the connection created by one identity
|
|
// pairing. Bound streams continue through the normal retirement window so
|
|
// revocation watermarks and introspection fallback are never bypassed.
|
|
func (m *ConnectionManager) DiscardPreparedConnection(ctx context.Context, ownerKey string) error {
|
|
m.operation.Lock()
|
|
defer m.operation.Unlock()
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
|
|
}
|
|
if strings.TrimSpace(ownerKey) == "" || connection.IdempotencyKey != ownerKey {
|
|
return nil
|
|
}
|
|
if connection.StreamID != nil {
|
|
if connection.LifecycleStatus != "retiring" && connection.LifecycleStatus != "disconnect_pending" {
|
|
if _, err := m.disconnect(ctx, connection); err != nil {
|
|
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
|
|
}
|
|
}
|
|
return safeConnectionError{category: "retirement_pending", cause: errors.New("security event retirement is pending")}
|
|
}
|
|
err = m.repository.DiscardPreparedSecurityEventConnection(
|
|
ctx, connection.ConnectionID, ownerKey, connection.Version,
|
|
)
|
|
if err != nil && !errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return safeConnectionError{category: "connection_cleanup_failed", cause: err}
|
|
}
|
|
m.stopRuntime()
|
|
return nil
|
|
}
|
|
|
|
func connectionSecretReferences(connection store.SecurityEventConnection) []string {
|
|
references := []string{connection.CredentialRef}
|
|
if connection.ManagementCredentialRef != nil {
|
|
references = append(references, *connection.ManagementCredentialRef)
|
|
}
|
|
if connection.NextCredentialRef != nil {
|
|
references = append(references, *connection.NextCredentialRef)
|
|
}
|
|
return references
|
|
}
|
|
|
|
func (m *ConnectionManager) retryDisconnect(connectionID string) {
|
|
delay := time.Second
|
|
for {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-time.After(delay):
|
|
}
|
|
connection, err := m.repository.SecurityEventConnection(m.ctx)
|
|
if err != nil || connection.ConnectionID != connectionID || connection.LifecycleStatus != "disconnect_pending" || connection.StreamID == nil {
|
|
return
|
|
}
|
|
configuration, client, err := m.discover(m.ctx, connection.TransmitterIssuer)
|
|
if err == nil {
|
|
var token string
|
|
token, err = m.managementToken(m.ctx, client, "ssf.stream.read ssf.stream.verify ssf.stream.manage")
|
|
if err == nil {
|
|
err = m.deleteStream(m.ctx, client, configuration, token, *connection.StreamID)
|
|
if isRemoteHTTPStatus(err, http.StatusNotFound) {
|
|
err = nil
|
|
}
|
|
}
|
|
}
|
|
if err == nil {
|
|
connection, err = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
m.ctx, connectionID, "disconnect_pending", connection.Version, "retiring", nil,
|
|
)
|
|
if err == nil {
|
|
go m.finishRetirement(connection)
|
|
}
|
|
return
|
|
}
|
|
if delay < time.Minute {
|
|
delay *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *ConnectionManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
m.mutex.RLock()
|
|
runtime := m.runtime
|
|
m.mutex.RUnlock()
|
|
if runtime == nil || runtime.handler == nil {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if _, err := m.repository.SecurityEventConnection(r.Context()); errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Retry-After", "1")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
runtime.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (m *ConnectionManager) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return auth.OIDCSecurityEventEvaluation{}, nil
|
|
}
|
|
if err != nil {
|
|
return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, err
|
|
}
|
|
m.mutex.RLock()
|
|
runtime := m.runtime
|
|
m.mutex.RUnlock()
|
|
if runtime == nil || runtime.service == nil {
|
|
if connection.Audience == nil {
|
|
return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, nil
|
|
}
|
|
result, evaluateErr := m.repository.EvaluateOIDCSecurityEvent(
|
|
ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.TenantID, identity.Subject,
|
|
identity.IssuedAt, time.Now().UTC(), m.config.StaleAfter,
|
|
)
|
|
if result.Revoked && m.metrics != nil {
|
|
m.metrics.ObserveWatermarkRejection()
|
|
}
|
|
return auth.OIDCSecurityEventEvaluation{Enabled: true, Revoked: result.Revoked, RequireIntrospection: true}, evaluateErr
|
|
}
|
|
evaluation, err := runtime.service.Evaluate(ctx, identity)
|
|
evaluation.Enabled = true
|
|
if connection.LifecycleStatus != "enabled" && connection.LifecycleStatus != "bootstrap" {
|
|
evaluation.RequireIntrospection = true
|
|
}
|
|
return evaluation, err
|
|
}
|
|
|
|
func (m *ConnectionManager) activate(ctx context.Context, connection store.SecurityEventConnection) error {
|
|
if connection.StreamID == nil || connection.Audience == nil {
|
|
return errors.New("security event connection binding is incomplete")
|
|
}
|
|
current, err := m.secrets.Get(ctx, connection.CredentialRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer clear(current)
|
|
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer clear(managementSecret)
|
|
var next []byte
|
|
if connection.NextCredentialRef != nil {
|
|
next, err = m.secrets.Get(ctx, *connection.NextCredentialRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer clear(next)
|
|
}
|
|
issuerURL, err := validatedIssuerURL(connection.TransmitterIssuer, m.config.AppEnv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
safeClient := m.config.HTTPClient
|
|
if safeClient == nil {
|
|
safeClient = safeHTTPClient(issuerURL, m.config.AppEnv)
|
|
}
|
|
verifier, err := NewVerifier(VerifierConfig{
|
|
TransmitterIssuer: connection.TransmitterIssuer, Audience: *connection.Audience,
|
|
SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, StreamID: *connection.StreamID,
|
|
ClockSkew: m.config.ClockSkew, HTTPClient: safeClient,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
verifier.SetMetrics(m.metrics)
|
|
handler, err := NewHandler(verifier, m.repository, connection.TransmitterIssuer, *connection.Audience, *connection.StreamID, string(current), string(next))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
handler.SetMetrics(m.metrics)
|
|
service, err := NewService(m.repository, ServiceConfig{
|
|
Issuer: connection.TransmitterIssuer, Audience: *connection.Audience, StreamID: *connection.StreamID,
|
|
ManagementTokenURL: strings.TrimRight(m.config.OIDCIssuer, "/") + "/token",
|
|
ManagementClientID: managementClientID, ManagementSecret: string(managementSecret),
|
|
HeartbeatInterval: m.config.HeartbeatInterval, StaleAfter: m.config.StaleAfter,
|
|
HTTPClient: safeClient,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service.SetMetrics(m.metrics)
|
|
runtimeContext, cancel := context.WithCancel(m.ctx)
|
|
if err := service.Initialize(runtimeContext); err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
m.mutex.Lock()
|
|
previous := m.runtime
|
|
m.runtime = &connectionRuntime{handler: handler, service: service, cancel: cancel}
|
|
m.mutex.Unlock()
|
|
if previous != nil && previous.cancel != nil {
|
|
previous.cancel()
|
|
}
|
|
go service.Run(runtimeContext)
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) stopRuntime() {
|
|
m.mutex.Lock()
|
|
previous := m.runtime
|
|
m.runtime = nil
|
|
m.mutex.Unlock()
|
|
if previous != nil && previous.cancel != nil {
|
|
previous.cancel()
|
|
}
|
|
}
|
|
|
|
func (m *ConnectionManager) finishAutomaticVerification(connectionID string, configuration transmitterConfiguration, client *http.Client, token string, started time.Time, rotating bool) {
|
|
ctx, cancel := context.WithTimeout(m.ctx, 90*time.Second)
|
|
defer cancel()
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
expectedLifecycle := "verifying"
|
|
if rotating {
|
|
expectedLifecycle = "rotating"
|
|
}
|
|
for {
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil || connection.ConnectionID != connectionID || connection.StreamID == nil || connection.LifecycleStatus != expectedLifecycle {
|
|
return
|
|
}
|
|
_, verifiedAt, _ := m.repository.SecurityEventMetrics(ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
|
|
if verifiedAt != nil && !verifiedAt.Before(started) {
|
|
if err := m.setStreamStatus(ctx, client, configuration, token, *connection.StreamID, "enabled", "verification_succeeded"); err != nil {
|
|
category := "stream_enable_failed"
|
|
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category,
|
|
)
|
|
return
|
|
}
|
|
if rotating && connection.NextCredentialRef != nil {
|
|
nextReference := *connection.NextCredentialRef
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-time.After(m.config.CredentialOverlapDuration):
|
|
}
|
|
current, currentErr := m.repository.SecurityEventConnection(m.ctx)
|
|
if currentErr != nil || current.ConnectionID != connectionID || current.LifecycleStatus != "rotating" ||
|
|
current.NextCredentialRef == nil || *current.NextCredentialRef != nextReference {
|
|
return
|
|
}
|
|
connection, err = m.repository.PromoteSecurityEventCredential(m.ctx, connectionID, nextReference)
|
|
if err == nil {
|
|
_ = m.activate(m.ctx, connection)
|
|
go m.finishBootstrap(connectionID)
|
|
}
|
|
return
|
|
}
|
|
connection, err = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connectionID, expectedLifecycle, connection.Version, "bootstrap", nil,
|
|
)
|
|
if err == nil {
|
|
go m.finishBootstrap(connectionID)
|
|
}
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
category := "verification_timeout"
|
|
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
m.ctx, connectionID, expectedLifecycle, connection.Version, expectedLifecycle, &category,
|
|
)
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *ConnectionManager) finishBootstrap(connectionID string) {
|
|
connection, err := m.repository.SecurityEventConnection(m.ctx)
|
|
if err != nil || connection.ConnectionID != connectionID || connection.LifecycleStatus != "bootstrap" {
|
|
return
|
|
}
|
|
delay := m.config.BootstrapDuration - time.Since(connection.UpdatedAt)
|
|
if delay > 0 {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-time.After(delay):
|
|
}
|
|
}
|
|
connection, err = m.repository.SecurityEventConnection(m.ctx)
|
|
if err == nil && connection.ConnectionID == connectionID && connection.LifecycleStatus == "bootstrap" {
|
|
mode, _, metricsErr := m.repository.SecurityEventMetrics(m.ctx, connection.TransmitterIssuer, valueOrEmpty(connection.Audience))
|
|
if metricsErr == nil && mode == "push_healthy" {
|
|
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
m.ctx, connectionID, "bootstrap", connection.Version, "enabled", nil,
|
|
)
|
|
return
|
|
}
|
|
category := "bootstrap_verification_stale"
|
|
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
m.ctx, connectionID, "bootstrap", connection.Version, "degraded", &category,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (m *ConnectionManager) finishRetirement(retirement store.SecurityEventConnection) {
|
|
retryDelay := time.Second
|
|
for {
|
|
connection, err := m.repository.SecurityEventConnection(m.ctx)
|
|
if errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
if !waitForConnectionRetry(m.ctx, retryDelay) {
|
|
return
|
|
}
|
|
retryDelay = nextConnectionRetryDelay(retryDelay)
|
|
continue
|
|
}
|
|
if connection.ConnectionID != retirement.ConnectionID || connection.LifecycleStatus != "retiring" {
|
|
return
|
|
}
|
|
if remaining := m.config.RetirementDuration - time.Since(connection.UpdatedAt); remaining > 0 {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-time.After(remaining):
|
|
}
|
|
continue
|
|
}
|
|
m.stopRuntime()
|
|
err = m.repository.FinalizeRetiringSecurityEventConnection(m.ctx, connection.ConnectionID, connection.Version)
|
|
if err == nil || errors.Is(err, store.ErrSecurityEventConnectionNotFound) {
|
|
return
|
|
}
|
|
if !waitForConnectionRetry(m.ctx, retryDelay) {
|
|
return
|
|
}
|
|
retryDelay = nextConnectionRetryDelay(retryDelay)
|
|
}
|
|
}
|
|
|
|
func waitForConnectionRetry(ctx context.Context, delay time.Duration) bool {
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-time.After(delay):
|
|
return true
|
|
}
|
|
}
|
|
|
|
func nextConnectionRetryDelay(delay time.Duration) time.Duration {
|
|
if delay < time.Minute {
|
|
return delay * 2
|
|
}
|
|
return delay
|
|
}
|
|
|
|
const identitySecretStagingTTL = 10 * time.Minute
|
|
|
|
func (m *ConnectionManager) stageIdentitySecret(ctx context.Context, reference string, value []byte) error {
|
|
if err := m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC().Add(identitySecretStagingTTL)); err != nil {
|
|
return err
|
|
}
|
|
if err := m.secrets.Put(ctx, reference, value); err != nil {
|
|
deleteErr := m.secrets.Delete(ctx, reference)
|
|
if deleteErr == nil || errors.Is(deleteErr, ErrSecretNotFound) {
|
|
_, _ = m.repository.RemovePendingIdentitySecretCleanup(ctx, reference)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) retireIdentitySecret(ctx context.Context, reference string) error {
|
|
return m.repository.QueueIdentitySecretCleanup(ctx, reference, time.Now().UTC())
|
|
}
|
|
|
|
func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID string, managementSecret []byte, requirePublicBaseURL bool) error {
|
|
if !m.config.OIDCEnabled || strings.TrimSpace(managementClientID) == "" || len(managementSecret) < 16 {
|
|
return errors.New("OIDC and RFC 7662 machine client must be configured")
|
|
}
|
|
if _, err := uuid.Parse(m.config.OIDCTenantID); err != nil {
|
|
return errors.New("OIDC tenant id is invalid")
|
|
}
|
|
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
|
|
return err
|
|
}
|
|
if !requirePublicBaseURL {
|
|
return nil
|
|
}
|
|
endpoint, err := url.Parse(strings.TrimRight(m.config.PublicBaseURL, "/") + "/api/v1/security-events/ssf")
|
|
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" {
|
|
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL is invalid")
|
|
}
|
|
if endpoint.Scheme != "https" && !(isLocalEnvironment(m.config.AppEnv) && endpoint.Scheme == "http" && isLocalHostname(endpoint.Hostname())) {
|
|
return errors.New("AI_GATEWAY_PUBLIC_BASE_URL must use HTTPS")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) discover(ctx context.Context, issuer string) (transmitterConfiguration, *http.Client, error) {
|
|
issuerURL, err := validatedIssuerURL(issuer, m.config.AppEnv)
|
|
if err != nil {
|
|
return transmitterConfiguration{}, nil, err
|
|
}
|
|
client := m.config.HTTPClient
|
|
if client == nil {
|
|
client = safeHTTPClient(issuerURL, m.config.AppEnv)
|
|
}
|
|
discoveryURL := *issuerURL
|
|
discoveryURL.Path = "/.well-known/ssf-configuration" + strings.TrimRight(issuerURL.EscapedPath(), "/")
|
|
var configuration transmitterConfiguration
|
|
if err := requestJSON(ctx, client, http.MethodGet, discoveryURL.String(), "", nil, &configuration); err != nil {
|
|
return transmitterConfiguration{}, nil, err
|
|
}
|
|
if configuration.SpecVersion != "1_0" || strings.TrimRight(configuration.Issuer, "/") != strings.TrimRight(issuer, "/") ||
|
|
!contains(configuration.DeliveryMethodsSupported, pushDeliveryMethod) {
|
|
return transmitterConfiguration{}, nil, errors.New("SSF discovery metadata is incompatible")
|
|
}
|
|
for _, endpoint := range []string{configuration.JWKSURI, configuration.ConfigurationEndpoint, configuration.StatusEndpoint, configuration.VerificationEndpoint} {
|
|
parsed, parseErr := url.Parse(endpoint)
|
|
if parseErr != nil || parsed.Scheme != issuerURL.Scheme || !strings.EqualFold(parsed.Host, issuerURL.Host) || parsed.User != nil || parsed.Fragment != "" {
|
|
return transmitterConfiguration{}, nil, errors.New("SSF discovery endpoint is not trusted")
|
|
}
|
|
}
|
|
return configuration, client, nil
|
|
}
|
|
|
|
func (m *ConnectionManager) managementToken(ctx context.Context, client *http.Client, scopes string) (string, error) {
|
|
connection, err := m.repository.SecurityEventConnection(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
managementClientID, managementSecret, err := m.managementCredential(ctx, connection)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer clear(managementSecret)
|
|
return m.requestManagementToken(ctx, client, scopes, managementClientID, managementSecret)
|
|
}
|
|
|
|
func (m *ConnectionManager) requestManagementToken(ctx context.Context, client *http.Client, scopes, managementClientID string, managementSecret []byte) (string, error) {
|
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {scopes}}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(m.config.OIDCIssuer, "/")+"/token", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
request.SetBasicAuth(managementClientID, string(managementSecret))
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
|
|
return "", fmt.Errorf("machine token request returned HTTP %d", response.StatusCode)
|
|
}
|
|
var payload struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := decodeLimitedJSON(response.Body, &payload); err != nil || payload.AccessToken == "" || !strings.EqualFold(payload.TokenType, "Bearer") {
|
|
return "", errors.New("machine token response is invalid")
|
|
}
|
|
return payload.AccessToken, nil
|
|
}
|
|
|
|
func (m *ConnectionManager) proveManagementCredentialHandoff(ctx context.Context, connection store.SecurityEventConnection, managementClientID string, managementSecret []byte) error {
|
|
if connection.StreamID == nil || connection.Audience == nil {
|
|
return store.ErrSecurityEventConnectionConflict
|
|
}
|
|
configuration, client, err := m.discover(ctx, connection.TransmitterIssuer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token, err := m.requestManagementToken(ctx, client, "ssf.stream.read ssf.stream.manage", managementClientID, managementSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var streams []streamConfiguration
|
|
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
|
|
return err
|
|
}
|
|
for _, stream := range streams {
|
|
if stream.StreamID != *connection.StreamID {
|
|
continue
|
|
}
|
|
if err := validateCreatedStream(stream, connection.TransmitterIssuer, connection.EndpointURL); err != nil || stream.Audience != *connection.Audience {
|
|
return store.ErrSecurityEventConnectionConflict
|
|
}
|
|
return nil
|
|
}
|
|
// An authenticated empty list proves that the new credential belongs to a
|
|
// token issuer trusted by the old transmitter. Disconnect treats the absent
|
|
// Stream as an idempotent remote deletion.
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) requireOperationalBinding() error {
|
|
if m.bindingMismatch {
|
|
return safeConnectionError{category: "credential_handoff_unsafe", cause: store.ErrSecurityEventConnectionConflict}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *ConnectionManager) managementCredential(ctx context.Context, connection store.SecurityEventConnection) (string, []byte, error) {
|
|
if connection.ManagementCredentialRef != nil && connection.ManagementClientID != "" {
|
|
secret, err := m.secrets.Get(ctx, *connection.ManagementCredentialRef)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if len(secret) < 16 {
|
|
clear(secret)
|
|
return "", nil, errors.New("managed machine credential is invalid")
|
|
}
|
|
return connection.ManagementClientID, secret, nil
|
|
}
|
|
if m.config.ManagementClientID != "" && m.config.ManagementClientSecret != "" {
|
|
return m.config.ManagementClientID, []byte(m.config.ManagementClientSecret), nil
|
|
}
|
|
return "", nil, errors.New("managed machine credential is unavailable")
|
|
}
|
|
|
|
func (m *ConnectionManager) findStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, description, endpoint string) (streamConfiguration, error) {
|
|
var streams []streamConfiguration
|
|
if err := requestJSON(ctx, client, http.MethodGet, configuration.ConfigurationEndpoint, token, nil, &streams); err != nil {
|
|
return streamConfiguration{}, err
|
|
}
|
|
for _, stream := range streams {
|
|
if stream.Description != nil && *stream.Description == description && stream.Delivery.EndpointURL == endpoint {
|
|
return stream, nil
|
|
}
|
|
}
|
|
return streamConfiguration{}, store.ErrSecurityEventConnectionNotFound
|
|
}
|
|
|
|
func (m *ConnectionManager) createStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, endpoint, secret, description string) (streamConfiguration, error) {
|
|
body := map[string]any{
|
|
"delivery": map[string]any{"method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret},
|
|
"events_requested": []string{sessionRevokedEvent}, "description": description,
|
|
}
|
|
var stream streamConfiguration
|
|
err := requestJSON(ctx, client, http.MethodPost, configuration.ConfigurationEndpoint, token, body, &stream)
|
|
return stream, err
|
|
}
|
|
|
|
func (m *ConnectionManager) patchStreamCredential(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, endpoint, secret string) error {
|
|
body := map[string]any{"stream_id": streamID, "delivery": map[string]any{
|
|
"method": pushDeliveryMethod, "endpoint_url": endpoint, "authorization_header": "Bearer " + secret,
|
|
}}
|
|
return requestJSON(ctx, client, http.MethodPatch, configuration.ConfigurationEndpoint, token, body, &streamConfiguration{})
|
|
}
|
|
|
|
func (m *ConnectionManager) setStreamStatus(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID, status, reason string) error {
|
|
body := map[string]any{"stream_id": streamID, "status": status, "reason": reason}
|
|
return requestJSON(ctx, client, http.MethodPost, configuration.StatusEndpoint, token, body, &map[string]any{})
|
|
}
|
|
|
|
func (m *ConnectionManager) deleteStream(ctx context.Context, client *http.Client, configuration transmitterConfiguration, token, streamID string) error {
|
|
endpoint, _ := url.Parse(configuration.ConfigurationEndpoint)
|
|
query := endpoint.Query()
|
|
query.Set("stream_id", streamID)
|
|
endpoint.RawQuery = query.Encode()
|
|
return requestJSON(ctx, client, http.MethodDelete, endpoint.String(), token, nil, nil)
|
|
}
|
|
|
|
func (m *ConnectionManager) connectionError(ctx context.Context, connection store.SecurityEventConnection, category string, cause error) error {
|
|
_, _ = m.repository.TransitionSecurityEventConnectionLifecycle(
|
|
ctx, connection.ConnectionID, connection.LifecycleStatus, connection.Version, "error", &category,
|
|
)
|
|
return safeConnectionError{category: category, cause: cause}
|
|
}
|
|
|
|
func (m *ConnectionManager) view(connection store.SecurityEventConnection) ConnectionView {
|
|
return ConnectionView{
|
|
ConnectionID: connection.ConnectionID, TransmitterIssuer: connection.TransmitterIssuer,
|
|
ReceiverEndpoint: connection.EndpointURL, Audience: connection.Audience, StreamID: connection.StreamID,
|
|
LifecycleStatus: connection.LifecycleStatus, HealthMode: connection.HealthMode,
|
|
ManagementClientID: managementClientIDForView(connection.ManagementClientID, m.config.ManagementClientID), LastVerificationAt: connection.LastVerificationAt,
|
|
LastErrorCategory: connection.LastErrorCategory, Version: connection.Version,
|
|
}
|
|
}
|
|
|
|
func managementClientIDForView(managed, fallback string) string {
|
|
if strings.TrimSpace(managed) != "" {
|
|
return managed
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func validateCreatedStream(stream streamConfiguration, issuer, endpoint string) error {
|
|
if _, err := uuid.Parse(stream.StreamID); err != nil || stream.Issuer != issuer || stream.Audience == "" ||
|
|
stream.Delivery.Method != pushDeliveryMethod || stream.Delivery.EndpointURL != endpoint ||
|
|
len(stream.EventsRequested) != 1 || stream.EventsRequested[0] != sessionRevokedEvent {
|
|
return errors.New("created SSF stream does not match the requested receiver")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomPushBearer() ([]byte, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return nil, err
|
|
}
|
|
encoded := make([]byte, base64.RawURLEncoding.EncodedLen(len(raw)))
|
|
base64.RawURLEncoding.Encode(encoded, raw)
|
|
clear(raw)
|
|
return encoded, nil
|
|
}
|
|
|
|
func requestJSON(ctx context.Context, client *http.Client, method, endpoint, token string, body, output any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(payload)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if token != "" {
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
|
|
return remoteHTTPError{status: response.StatusCode}
|
|
}
|
|
if output == nil || response.StatusCode == http.StatusNoContent {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64*1024))
|
|
return nil
|
|
}
|
|
return decodeLimitedJSON(response.Body, output)
|
|
}
|
|
|
|
func isRemoteHTTPStatus(err error, status int) bool {
|
|
var remoteError remoteHTTPError
|
|
return errors.As(err, &remoteError) && remoteError.status == status
|
|
}
|
|
|
|
func decodeLimitedJSON(reader io.Reader, output any) error {
|
|
payload, err := io.ReadAll(io.LimitReader(reader, 64*1024+1))
|
|
if err != nil || len(payload) > 64*1024 {
|
|
return errors.New("security event response is too large")
|
|
}
|
|
if len(payload) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(payload, output)
|
|
}
|
|
|
|
func validatedIssuerURL(raw, appEnv string) (*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return nil, errors.New("transmitter issuer is invalid")
|
|
}
|
|
if parsed.Scheme != "https" && !(isLocalEnvironment(appEnv) && parsed.Scheme == "http" && isLocalHostname(parsed.Hostname())) {
|
|
return nil, errors.New("transmitter issuer must use HTTPS")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func safeHTTPClient(issuer *url.URL, appEnv string) *http.Client {
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.Proxy = nil
|
|
transport.DisableCompression = true
|
|
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil || len(addresses) == 0 {
|
|
return nil, errors.New("transmitter DNS resolution failed")
|
|
}
|
|
allowLocal := isLocalEnvironment(appEnv) && isLocalHostname(issuer.Hostname())
|
|
for _, address := range addresses {
|
|
if blockedAddress(address.IP) && !allowLocal {
|
|
return nil, errors.New("transmitter resolved to a blocked network")
|
|
}
|
|
}
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
return dialResolvedAddresses(ctx, network, port, addresses, dialer)
|
|
}
|
|
return &http.Client{Timeout: 10 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
|
|
func dialResolvedAddresses(ctx context.Context, network, port string, addresses []net.IPAddr, dialer *net.Dialer) (net.Conn, error) {
|
|
var attempts []error
|
|
for _, address := range addresses {
|
|
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.IP.String(), port))
|
|
if err == nil {
|
|
return connection, nil
|
|
}
|
|
attempts = append(attempts, err)
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("transmitter connection failed for every resolved address: %w", errors.Join(attempts...))
|
|
}
|
|
|
|
func blockedAddress(ip net.IP) bool {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
|
ip.IsUnspecified() || ip.IsMulticast() {
|
|
return true
|
|
}
|
|
address, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return true
|
|
}
|
|
address = address.Unmap()
|
|
for _, prefix := range blockedNetworkPrefixes {
|
|
if prefix.Contains(address) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var blockedNetworkPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"),
|
|
netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"),
|
|
netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"),
|
|
netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"),
|
|
netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001:db8::/32"),
|
|
}
|
|
|
|
func isLocalHostname(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(value)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func isLocalEnvironment(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "development", "dev", "local", "test":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func contains(values []string, expected string) bool {
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func valueOrEmpty(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|