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
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -55,7 +56,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
if err := db.AdvanceSecurityEventStreamState(ctx, issuer, audience, now, 180*time.Second); err != nil {
t.Fatalf("advance fresh stream state: %v", err)
}
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, err := db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "bootstrap" {
t.Fatalf("bootstrap evaluation=%#v error=%v", evaluation, err)
}
@@ -77,12 +78,12 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
}
confirmAt("first-verification-state", now)
confirmAt("second-verification-state", now.Add(time.Minute))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(time.Minute), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("bootstrap overlap evaluation=%#v error=%v", evaluation, err)
}
confirmAt("post-bootstrap-verification-state", now.Add(361*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(361*time.Second), 180*time.Second)
if err != nil || evaluation.RequireIntrospection || evaluation.Mode != "push_healthy" {
t.Fatalf("healthy evaluation=%#v error=%v", evaluation, err)
}
@@ -97,7 +98,7 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
}
confirmAt("paused-verification-one", now.Add(363*time.Second))
confirmAt("paused-verification-two", now.Add(364*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now.Add(364*time.Second), 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("stream update fallback=%#v error=%v", evaluation, err)
}
@@ -114,22 +115,22 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
if err != nil || !duplicate.Duplicate {
t.Fatalf("duplicate result=%#v error=%v", duplicate, err)
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, revokedAt, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, revokedAt, now, 180*time.Second)
if !evaluation.Revoked {
t.Fatal("token at watermark was accepted")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", tenantID, subject, revokedAt, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, "https://other.test/issuer", "", tenantID, subject, revokedAt, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("watermark crossed OIDC issuer boundary")
}
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, _ = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if evaluation.Revoked {
t.Fatal("token after watermark was rejected")
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state SET last_verification_at=$3
WHERE issuer=$1 AND audience=$2`, issuer, audience, now.Add(-181*time.Second))
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, tenantID, subject, now, now, 180*time.Second)
evaluation, err = db.EvaluateOIDCSecurityEvent(ctx, issuer, audience, subjectIssuer, "", tenantID, subject, now, now, 180*time.Second)
if err != nil || !evaluation.RequireIntrospection || evaluation.Mode != "introspection_fallback" {
t.Fatalf("fallback evaluation=%#v error=%v", evaluation, err)
}
@@ -183,3 +184,136 @@ func TestSecurityEventWatermarkVerificationAndFallbackLifecycle(t *testing.T) {
t.Fatalf("rebind discarded security history receipts=%d watermarks=%d", retainedReceipts, retainedWatermarks)
}
}
func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run application-scoped security event integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
transmitterIssuer := "https://auth.test.example/ssf/" + uuid.NewString()
subjectIssuer := "https://auth.test.example/issuer/shared"
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
projection := func(tenantID string) *auth.User {
result, err := db.ResolveOrProvisionOIDCUser(ctx, ResolveOrProvisionOIDCUserInput{
Issuer: subjectIssuer, ApplicationID: applicationID, Subject: subject, Username: "shared-user",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: "Tenant " + tenantID[:8], TenantSlug: "tenant-" + tenantID[:8],
TenantMetadataStatus: "synced", TenantMetadataVersion: "1",
TenantMetadataUpdatedAt: time.Now().UTC(), OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
})
if err != nil {
t.Fatal(err)
}
return result.User
}
userA, userB := projection(tenantA), projection(tenantB)
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "must survive OIDC revocation"}, userA); err != nil {
t.Fatal(err)
}
createSession := func(user *auth.User, marker byte) {
now := time.Now().UTC()
sessionHash := sha256.Sum256([]byte(uuid.NewString()))
if _, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
TenantID: user.TenantID, TokenCiphertext: []byte{marker},
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
}); err != nil {
t.Fatal(err)
}
}
createSession(userA, 0xa1)
createSession(userB, 0xb1)
revokedAt := time.Now().UTC().Truncate(time.Second)
principalEvent := ApplySessionRevokedInput{
Issuer: transmitterIssuer, Audience: "urn:easyai:ssf:receiver:" + applicationID,
JTI: uuid.NewString(), TransactionID: uuid.NewString(), SubjectIssuer: subjectIssuer,
ApplicationID: applicationID, SubjectType: "principal", TenantID: tenantA, Subject: subject,
EventTimestamp: revokedAt, InitiatingEntity: "admin",
}
result, err := db.ApplySessionRevoked(ctx, principalEvent)
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
t.Fatalf("principal result=%#v error=%v", result, err)
}
var tenantASessions, tenantBSessions int
countSessions := func() {
t.Helper()
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$1::uuid),
(SELECT count(*) FROM gateway_oidc_sessions WHERE gateway_tenant_id=$2::uuid)`,
userA.GatewayTenantID, userB.GatewayTenantID,
).Scan(&tenantASessions, &tenantBSessions); err != nil {
t.Fatal(err)
}
}
countSessions()
if tenantASessions != 0 || tenantBSessions != 1 {
t.Fatalf("principal revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
}
older := principalEvent
older.JTI = uuid.NewString()
older.EventTimestamp = revokedAt.Add(-time.Minute)
result, err = db.ApplySessionRevoked(ctx, older)
if err != nil || result.WatermarkMoved || result.SessionsDeleted != 0 {
t.Fatalf("older result=%#v error=%v", result, err)
}
createSession(userA, 0xa2)
tenantEvent := principalEvent
tenantEvent.JTI, tenantEvent.TransactionID = uuid.NewString(), uuid.NewString()
tenantEvent.SubjectType, tenantEvent.Subject = "tenant", tenantA
tenantEvent.EventTimestamp = revokedAt.Add(time.Minute)
result, err = db.ApplySessionRevoked(ctx, tenantEvent)
if err != nil || result.SessionsDeleted != 1 || !result.WatermarkMoved {
t.Fatalf("tenant result=%#v error=%v", result, err)
}
countSessions()
if tenantASessions != 0 || tenantBSessions != 1 {
t.Fatalf("tenant revocation sessions A=%d B=%d", tenantASessions, tenantBSessions)
}
keysA, err := db.ListAPIKeys(ctx, userA)
if err != nil || len(keysA) != 1 {
t.Fatalf("tenant revocation changed API keys: keys=%#v error=%v", keysA, err)
}
var accessStatus string
if err := db.pool.QueryRow(ctx, `SELECT access_status FROM gateway_oidc_tenant_bindings
WHERE issuer=$1 AND application_id=$2 AND external_tenant_id=$3`,
subjectIssuer, applicationID, tenantA,
).Scan(&accessStatus); err != nil || accessStatus != "disabled" {
t.Fatalf("tenant A access status=%q error=%v", accessStatus, err)
}
streamID := uuid.NewString()
audience := tenantEvent.Audience
if err := db.EnsureSecurityEventStreamState(ctx, transmitterIssuer, audience, streamID); err != nil {
t.Fatal(err)
}
_, _ = db.pool.Exec(ctx, `UPDATE gateway_security_event_stream_state
SET stream_status='enabled',mode='push_healthy',last_verification_at=$3,bootstrap_until=$3
WHERE issuer=$1 AND audience=$2`, transmitterIssuer, audience, revokedAt.Add(2*time.Minute))
evaluationA, err := db.EvaluateOIDCSecurityEvent(
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantA, subject,
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
)
if err != nil || !evaluationA.Revoked {
t.Fatalf("tenant A evaluation=%#v error=%v", evaluationA, err)
}
evaluationB, err := db.EvaluateOIDCSecurityEvent(
ctx, transmitterIssuer, audience, subjectIssuer, applicationID, tenantB, subject,
tenantEvent.EventTimestamp, revokedAt.Add(2*time.Minute), 5*time.Minute,
)
if err != nil || evaluationB.Revoked {
t.Fatalf("tenant B evaluation=%#v error=%v", evaluationB, err)
}
}