feat(identity): 接入认证中心多租户登录
支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
This commit is contained in:
@@ -50,6 +50,8 @@ type ConnectionManagerConfig struct {
|
||||
OIDCEnabled bool
|
||||
OIDCIssuer string
|
||||
OIDCTenantID string
|
||||
OIDCApplicationID string
|
||||
OIDCTenantMode string
|
||||
ManagementClientID string
|
||||
ManagementClientSecret string
|
||||
ExpectedTransmitterIssuer string
|
||||
@@ -145,7 +147,9 @@ 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 == "" {
|
||||
validIdentityBinding := config.OIDCTenantMode == "multi_tenant" && uuid.Validate(config.OIDCApplicationID) == nil ||
|
||||
config.OIDCTenantMode != "multi_tenant" && uuid.Validate(config.OIDCTenantID) == nil
|
||||
if repository == nil || secrets == nil || !config.OIDCEnabled || config.OIDCIssuer == "" || !validIdentityBinding {
|
||||
return nil, errors.New("security event connection prerequisites are incomplete")
|
||||
}
|
||||
if config.HeartbeatInterval <= 0 {
|
||||
@@ -773,7 +777,8 @@ func (m *ConnectionManager) Evaluate(ctx context.Context, identity auth.OIDCSecu
|
||||
return auth.OIDCSecurityEventEvaluation{Enabled: true, RequireIntrospection: true}, nil
|
||||
}
|
||||
result, evaluateErr := m.repository.EvaluateOIDCSecurityEvent(
|
||||
ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.TenantID, identity.Subject,
|
||||
ctx, connection.TransmitterIssuer, *connection.Audience, identity.Issuer, identity.ApplicationID,
|
||||
identity.TenantID, identity.Subject,
|
||||
identity.IssuedAt, time.Now().UTC(), m.config.StaleAfter,
|
||||
)
|
||||
if result.Revoked && m.metrics != nil {
|
||||
@@ -821,7 +826,9 @@ func (m *ConnectionManager) activate(ctx context.Context, connection store.Secur
|
||||
}
|
||||
verifier, err := NewVerifier(VerifierConfig{
|
||||
TransmitterIssuer: connection.TransmitterIssuer, Audience: *connection.Audience,
|
||||
SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID, StreamID: *connection.StreamID,
|
||||
SubjectIssuer: m.config.OIDCIssuer, TenantID: m.config.OIDCTenantID,
|
||||
ApplicationID: m.config.OIDCApplicationID, TenantMode: m.config.OIDCTenantMode,
|
||||
StreamID: *connection.StreamID,
|
||||
ClockSkew: m.config.ClockSkew, HTTPClient: safeClient,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1038,7 +1045,11 @@ func (m *ConnectionManager) validatePrerequisites(issuer, managementClientID str
|
||||
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 {
|
||||
if m.config.OIDCTenantMode == "multi_tenant" {
|
||||
if uuid.Validate(m.config.OIDCApplicationID) != nil {
|
||||
return errors.New("OIDC application id is invalid")
|
||||
}
|
||||
} else if uuid.Validate(m.config.OIDCTenantID) != nil {
|
||||
return errors.New("OIDC tenant id is invalid")
|
||||
}
|
||||
if _, err := validatedIssuerURL(issuer, m.config.AppEnv); err != nil {
|
||||
|
||||
@@ -123,7 +123,7 @@ func (*memoryConnectionRepository) RecordSecurityEventHeartbeatFailure(context.C
|
||||
func (*memoryConnectionRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
|
||||
func (r *memoryConnectionRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
|
||||
if r.evaluation.Mode == "" {
|
||||
return store.SecurityEventEvaluation{Mode: "bootstrap", RequireIntrospection: true}, nil
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var result store.ApplySecurityEventResult
|
||||
result, err = h.repository.ApplySessionRevoked(r.Context(), store.ApplySessionRevokedInput{
|
||||
Issuer: event.Issuer, Audience: event.Audience, JTI: event.JTI, TransactionID: event.TransactionID,
|
||||
SubjectIssuer: event.SubjectIssuer, TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
|
||||
SubjectIssuer: event.SubjectIssuer, ApplicationID: event.ApplicationID, SubjectType: event.SubjectType,
|
||||
TenantID: event.TenantID, Subject: event.Subject, EventTimestamp: event.EventTimestamp,
|
||||
InitiatingEntity: event.InitiatingEntity,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -109,6 +109,54 @@ func TestReceiverAcceptsValidSessionRevokedAndDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierAcceptsApplicationScopedPrincipalAndTenantEvents(t *testing.T) {
|
||||
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
applicationID, tenantID, subjectID := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
verifier := &Verifier{config: VerifierConfig{
|
||||
TransmitterIssuer: "https://auth.example/ssf", Audience: "urn:easyai:ssf:receiver:" + applicationID,
|
||||
SubjectIssuer: "https://auth.example/issuer/shared", ApplicationID: applicationID,
|
||||
TenantMode: "multi_tenant", StreamID: uuid.NewString(), ClockSkew: time.Minute,
|
||||
}, keys: map[string]*ecdsa.PublicKey{"current": &privateKey.PublicKey}, expiresAt: time.Now().Add(time.Hour), client: http.DefaultClient}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
claims := func(subjectType, subject, eventApplicationID string) jwt.MapClaims {
|
||||
return jwt.MapClaims{
|
||||
"iss": verifier.config.TransmitterIssuer, "aud": verifier.config.Audience, "iat": now.Unix(),
|
||||
"jti": uuid.NewString(), "txn": uuid.NewString(),
|
||||
"sub_id": map[string]any{
|
||||
"format": "complex",
|
||||
"user": map[string]any{"format": "iss_sub", "iss": verifier.config.SubjectIssuer, "sub": subject},
|
||||
"tenant": map[string]any{"format": "opaque", "id": tenantID},
|
||||
},
|
||||
"events": map[string]any{SessionRevokedEventType: map[string]any{
|
||||
"event_timestamp": now.Unix(), "initiating_entity": "admin",
|
||||
"application_id": eventApplicationID, "subject_type": subjectType,
|
||||
}},
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, subjectType, subject string
|
||||
}{
|
||||
{name: "principal", subjectType: "principal", subject: subjectID},
|
||||
{name: "tenant", subjectType: "tenant", subject: tenantID},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
event, err := verifier.Verify(context.Background(), signSET(t, privateKey, claims(test.subjectType, test.subject, applicationID)))
|
||||
if err != nil || event.ApplicationID != applicationID || event.SubjectType != test.subjectType ||
|
||||
event.Subject != test.subject || event.TenantID != tenantID {
|
||||
t.Fatalf("event=%#v error=%v", event, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, invalid := range []jwt.MapClaims{
|
||||
claims("principal", subjectID, uuid.NewString()),
|
||||
claims("tenant", subjectID, applicationID),
|
||||
} {
|
||||
if _, err := verifier.Verify(context.Background(), signSET(t, privateKey, invalid)); err == nil {
|
||||
t.Fatal("invalid application-scoped event was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRejectsAuthenticationMediaTypeAndForbiddenClaims(t *testing.T) {
|
||||
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
verifier := &Verifier{config: VerifierConfig{
|
||||
|
||||
@@ -26,7 +26,7 @@ type StateRepository interface {
|
||||
BeginSecurityEventVerification(context.Context, string, string, []byte, time.Time) error
|
||||
RecordSecurityEventHeartbeatFailure(context.Context, string, string, string) error
|
||||
AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error
|
||||
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
|
||||
EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error)
|
||||
}
|
||||
|
||||
type ServiceConfig struct {
|
||||
@@ -95,7 +95,7 @@ func (s *Service) RequestVerification(ctx context.Context) { s.sendVerification(
|
||||
|
||||
func (s *Service) Evaluate(ctx context.Context, identity auth.OIDCSecurityEventIdentity) (auth.OIDCSecurityEventEvaluation, error) {
|
||||
result, err := s.repository.EvaluateOIDCSecurityEvent(
|
||||
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.TenantID, identity.Subject,
|
||||
ctx, s.config.Issuer, s.config.Audience, identity.Issuer, identity.ApplicationID, identity.TenantID, identity.Subject,
|
||||
identity.IssuedAt, s.clock().UTC(), s.config.StaleAfter,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ func (*fakeStateRepository) RecordSecurityEventHeartbeatFailure(context.Context,
|
||||
func (*fakeStateRepository) AdvanceSecurityEventStreamState(context.Context, string, string, time.Time, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
|
||||
func (f *fakeStateRepository) EvaluateOIDCSecurityEvent(context.Context, string, string, string, string, string, string, time.Time, time.Time, time.Duration) (store.SecurityEventEvaluation, error) {
|
||||
return f.evaluation, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ type Event struct {
|
||||
EventType string
|
||||
EventTimestamp time.Time
|
||||
SubjectIssuer string
|
||||
ApplicationID string
|
||||
SubjectType string
|
||||
Subject string
|
||||
TenantID string
|
||||
InitiatingEntity string
|
||||
@@ -50,6 +52,8 @@ type VerifierConfig struct {
|
||||
Audience string
|
||||
SubjectIssuer string
|
||||
TenantID string
|
||||
ApplicationID string
|
||||
TenantMode string
|
||||
StreamID string
|
||||
ClockSkew time.Duration
|
||||
HTTPClient *http.Client
|
||||
@@ -93,8 +97,11 @@ func (e *protocolError) Error() string { return e.Code }
|
||||
func NewVerifier(config VerifierConfig) (*Verifier, error) {
|
||||
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
|
||||
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
|
||||
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
|
||||
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
|
||||
multiTenant := config.TenantMode == "multi_tenant"
|
||||
validBinding := multiTenant && uuid.Validate(config.ApplicationID) == nil ||
|
||||
!multiTenant && uuid.Validate(config.TenantID) == nil
|
||||
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || !validBinding {
|
||||
return nil, errors.New("SSF transmitter, audience, subject issuer, and identity binding are required")
|
||||
}
|
||||
if _, err := uuid.Parse(config.StreamID); err != nil {
|
||||
return nil, errors.New("SSF stream id must be a UUID")
|
||||
@@ -179,6 +186,7 @@ func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
}
|
||||
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
|
||||
event.ApplicationID, event.SubjectType = stringValue(value["application_id"]), stringValue(value["subject_type"])
|
||||
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
|
||||
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
|
||||
return Event{}, &protocolError{Code: "invalid_request"}
|
||||
@@ -259,11 +267,19 @@ func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
|
||||
return false
|
||||
}
|
||||
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
|
||||
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
|
||||
if event.SubjectIssuer != v.config.SubjectIssuer || uuid.Validate(event.TenantID) != nil {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(event.Subject)
|
||||
return err == nil
|
||||
if v.config.TenantMode == "multi_tenant" {
|
||||
if event.ApplicationID != v.config.ApplicationID ||
|
||||
event.SubjectType != "principal" && event.SubjectType != "tenant" ||
|
||||
event.SubjectType == "tenant" && event.Subject != event.TenantID {
|
||||
return false
|
||||
}
|
||||
} else if event.ApplicationID != "" || event.SubjectType != "" || event.TenantID != v.config.TenantID {
|
||||
return false
|
||||
}
|
||||
return uuid.Validate(event.Subject) == nil
|
||||
}
|
||||
|
||||
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
|
||||
|
||||
Reference in New Issue
Block a user