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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user