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
@@ -11,9 +11,147 @@ import (
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(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 OIDC multi-tenant PostgreSQL integration tests")
}
ctx := context.Background()
applyOIDCJITTestMigrations(t, ctx, databaseURL)
db, err := Connect(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
issuer := "https://auth.test.example/issuer/shared"
applicationID, tenantA, tenantB, subject := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
input := func(tenantID, name, slug string) ResolveOrProvisionOIDCUserInput {
return ResolveOrProvisionOIDCUserInput{
Issuer: issuer, ApplicationID: applicationID, Subject: subject, Username: "shared-subject",
Roles: []string{"basic"}, TenantID: tenantID, TenantMode: "multi_tenant",
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
OIDCClientID: "gateway-browser", ProvisioningEnabled: true,
}
}
const concurrentLogins = 8
results := make([]ResolveOrProvisionOIDCUserResult, concurrentLogins)
errs := make([]error, concurrentLogins)
var wait sync.WaitGroup
for index := range concurrentLogins {
wait.Add(1)
go func(index int) {
defer wait.Done()
results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A", "tenant-a"))
}(index)
}
wait.Wait()
var userA *auth.User
created := 0
for index, result := range results {
if errs[index] != nil || result.User == nil {
t.Fatalf("tenant A login %d result=%#v error=%v", index, result, errs[index])
}
if userA == nil {
userA = result.User
}
if result.User.GatewayUserID != userA.GatewayUserID || result.User.GatewayTenantID != userA.GatewayTenantID {
t.Fatalf("concurrent tenant A projection diverged: first=%#v current=%#v", userA, result.User)
}
if result.Created {
created++
}
}
if created != 1 {
t.Fatalf("tenant A created projections=%d", created)
}
resultB, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantB, "Tenant B", "tenant-b"))
if err != nil || resultB.User == nil {
t.Fatalf("tenant B projection=%#v error=%v", resultB, err)
}
userB := resultB.User
if userA.GatewayUserID == userB.GatewayUserID || userA.GatewayTenantID == userB.GatewayTenantID ||
userA.ID != userB.ID {
t.Fatalf("same subject was not isolated by tenant: A=%#v B=%#v", userA, userB)
}
var tenants, users, wallets, audits int
if err := db.pool.QueryRow(ctx, `SELECT
(SELECT count(*) FROM gateway_oidc_tenant_bindings WHERE application_id=$1 AND external_tenant_id IN ($2,$3)),
(SELECT count(*) FROM gateway_oidc_user_bindings binding
JOIN gateway_oidc_tenant_bindings tenant ON tenant.id=binding.tenant_binding_id
WHERE tenant.application_id=$1 AND binding.subject=$4),
(SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id IN ($5::uuid,$6::uuid) AND currency='resource'),
(SELECT count(*) FROM gateway_audit_logs WHERE action='identity.oidc_user.provisioned'
AND target_gateway_user_id IN ($5::uuid,$6::uuid))`,
applicationID, tenantA, tenantB, subject, userA.GatewayUserID, userB.GatewayUserID,
).Scan(&tenants, &users, &wallets, &audits); err != nil {
t.Fatal(err)
}
if tenants != 2 || users != 2 || wallets != 2 || audits != 2 {
t.Fatalf("tenants=%d users=%d wallets=%d audits=%d", tenants, users, wallets, audits)
}
if _, err := db.CreateAPIKey(ctx, CreateAPIKeyInput{Name: "Tenant A only"}, userA); err != nil {
t.Fatal(err)
}
keysB, err := db.ListAPIKeys(ctx, userB)
if err != nil || len(keysB) != 0 {
t.Fatalf("tenant B observed tenant A API keys: keys=%#v error=%v", keysB, err)
}
if _, err := db.CreateTask(ctx, CreateTaskInput{
Kind: "multi-tenant-isolation", Model: "local-fixture", RunMode: "async", Async: true,
Request: map[string]any{"prompt": "tenant-a"},
}, userA); err != nil {
t.Fatal(err)
}
tasksB, err := db.ListTasks(ctx, userB, TaskListFilter{})
if err != nil || len(tasksB.Items) != 0 {
t.Fatalf("tenant B observed tenant A tasks: tasks=%#v error=%v", tasksB.Items, err)
}
bindingA, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
if err != nil {
t.Fatal(err)
}
if err := db.RejectOIDCTenantBinding(ctx, bindingA.ID, "tenant_application_revoked", time.Now().UTC()); err != nil {
t.Fatal(err)
}
pending := input(tenantA, "", "")
pending.TenantMetadataStatus = "metadata_pending"
if _, err := db.ResolveOrProvisionOIDCUser(ctx, pending); !errorsIs(err, ErrOIDCTenantUnavailable) {
t.Fatalf("disabled binding accepted pending context: %v", err)
}
reactivated, err := db.ResolveOrProvisionOIDCUser(ctx, input(tenantA, "Tenant A restored", "tenant-a"))
if err != nil {
t.Fatal(err)
}
if reactivated.User.GatewayTenantID != userA.GatewayTenantID || reactivated.User.GatewayUserID != userA.GatewayUserID {
t.Fatalf("reassignment did not reuse local projection: before=%#v after=%#v", userA, reactivated.User)
}
if err := db.ApplyOIDCTenantBindingSync(ctx, bindingA.ID, identity.TenantContext{
ApplicationID: applicationID, TenantID: tenantA, DisplayName: "Tenant A renamed", Slug: "tenant-a",
TenantStatus: "active", TenantApplicationStatus: "active", Version: "v2", ETag: `"v2"`,
UpdatedAt: time.Unix(1_780_000_100, 0).UTC(),
}, false, time.Now().UTC()); err != nil {
t.Fatal(err)
}
synced, err := db.OIDCTenantBindingContext(ctx, issuer, applicationID, tenantA)
if err != nil || synced.DisplayName != "Tenant A renamed" || synced.Version != "v2" {
t.Fatalf("synced tenant context=%#v error=%v", synced, err)
}
}
func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {