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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user