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:
2026-07-28 17:28:35 +08:00
parent 0b02e62c72
commit 5c679ff13f
45 changed files with 2986 additions and 139 deletions
@@ -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
}
+2 -1
View File
@@ -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{
+2 -2
View File
@@ -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
}
+21 -5
View File
@@ -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 {