327 lines
12 KiB
Go
327 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(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 JIT PostgreSQL integration tests")
|
|
}
|
|
ctx := context.Background()
|
|
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
|
|
|
db, err := Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
|
subject := "platform-jit-" + suffix
|
|
input := ResolveOrProvisionOIDCUserInput{
|
|
Issuer: "https://auth.test.example/realms/easyai",
|
|
Subject: subject,
|
|
Username: "jit-user-" + suffix,
|
|
Roles: []string{"basic"},
|
|
TenantID: "auth-center-test-tenant",
|
|
GatewayTenantKey: "default",
|
|
ProvisioningEnabled: true,
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.pool.Exec(context.Background(), `
|
|
DELETE FROM gateway_audit_logs
|
|
WHERE target_gateway_user_id IN (
|
|
SELECT id FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1
|
|
)`, subject)
|
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject)
|
|
})
|
|
|
|
const callers = 12
|
|
results := make([]ResolveOrProvisionOIDCUserResult, callers)
|
|
errs := make([]error, callers)
|
|
var wg sync.WaitGroup
|
|
for index := 0; index < callers; index++ {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
results[index], errs[index] = db.ResolveOrProvisionOIDCUser(ctx, input)
|
|
}(index)
|
|
}
|
|
wg.Wait()
|
|
|
|
firstID := ""
|
|
createdCount := 0
|
|
auditID := ""
|
|
for index, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("concurrent resolve %d: %v", index, err)
|
|
}
|
|
result := results[index]
|
|
if result.User == nil || result.User.GatewayUserID == "" {
|
|
t.Fatalf("concurrent resolve %d returned no local user: %+v", index, result)
|
|
}
|
|
if firstID == "" {
|
|
firstID = result.User.GatewayUserID
|
|
}
|
|
if result.User.GatewayUserID != firstID {
|
|
t.Fatalf("concurrent resolve returned different users: %q and %q", firstID, result.User.GatewayUserID)
|
|
}
|
|
if result.Created {
|
|
createdCount++
|
|
auditID = result.AuditID
|
|
}
|
|
}
|
|
if createdCount != 1 {
|
|
t.Fatalf("created count = %d, want 1", createdCount)
|
|
}
|
|
if auditID == "" {
|
|
t.Fatal("first provision must return an audit ID")
|
|
}
|
|
|
|
var users, wallets, audits int
|
|
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil {
|
|
t.Fatalf("count OIDC users: %v", err)
|
|
}
|
|
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_wallet_accounts WHERE gateway_user_id = $1::uuid AND currency = 'resource'`, firstID).Scan(&wallets); err != nil {
|
|
t.Fatalf("count wallets: %v", err)
|
|
}
|
|
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_audit_logs WHERE action = 'identity.oidc_user.provisioned' AND target_gateway_user_id = $1::uuid`, firstID).Scan(&audits); err != nil {
|
|
t.Fatalf("count audits: %v", err)
|
|
}
|
|
if users != 1 || wallets != 1 || audits != 1 {
|
|
t.Fatalf("users=%d wallets=%d audits=%d, want one of each", users, wallets, audits)
|
|
}
|
|
var auditProjection string
|
|
if err := db.pool.QueryRow(ctx, `
|
|
SELECT COALESCE(actor_user_id, '') || metadata::text || after_state::text
|
|
FROM gateway_audit_logs
|
|
WHERE id = $1::uuid`, auditID).Scan(&auditProjection); err != nil {
|
|
t.Fatalf("read OIDC provisioning audit: %v", err)
|
|
}
|
|
if strings.Contains(auditProjection, subject) || strings.Contains(auditProjection, input.Issuer) {
|
|
t.Fatal("OIDC provisioning audit exposed raw external identity claims")
|
|
}
|
|
if _, err := db.pool.Exec(ctx, `
|
|
UPDATE gateway_users
|
|
SET display_name = 'Manual Display Name',
|
|
email = 'manual-profile@example.test',
|
|
metadata = metadata || '{"manualProfile":true}'::jsonb
|
|
WHERE id = $1::uuid`, firstID); err != nil {
|
|
t.Fatalf("seed manually managed profile fields: %v", err)
|
|
}
|
|
|
|
input.Username = "jit-user-renamed-" + suffix
|
|
input.Roles = []string{"basic", "admin"}
|
|
input.ProvisioningEnabled = false
|
|
repeated, err := db.ResolveOrProvisionOIDCUser(ctx, input)
|
|
if err != nil {
|
|
t.Fatalf("repeat resolve: %v", err)
|
|
}
|
|
if repeated.Created || repeated.AuditID != "" || repeated.User.GatewayUserID != firstID {
|
|
t.Fatalf("unexpected repeat result: %+v", repeated)
|
|
}
|
|
if repeated.User.Username != input.Username || !containsOIDCTestRole(repeated.User.Roles, "admin") {
|
|
t.Fatalf("repeat resolve did not sync token projection: %+v", repeated.User)
|
|
}
|
|
var displayName, email string
|
|
var manualProfile bool
|
|
if err := db.pool.QueryRow(ctx, `
|
|
SELECT COALESCE(display_name, ''), COALESCE(email, ''), COALESCE((metadata->>'manualProfile')::boolean, false)
|
|
FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &manualProfile); err != nil {
|
|
t.Fatalf("read manually managed profile fields: %v", err)
|
|
}
|
|
if displayName != "Manual Display Name" || email != "manual-profile@example.test" || !manualProfile {
|
|
t.Fatalf("repeat resolve overwrote manually managed profile fields")
|
|
}
|
|
|
|
if _, err := db.pool.Exec(ctx, `UPDATE gateway_users SET status = 'disabled' WHERE id = $1::uuid`, firstID); err != nil {
|
|
t.Fatalf("disable OIDC user: %v", err)
|
|
}
|
|
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserDisabled) {
|
|
t.Fatalf("disabled resolve error = %v, want ErrOIDCUserDisabled", err)
|
|
}
|
|
var status string
|
|
if err := db.pool.QueryRow(ctx, `SELECT status FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&status); err != nil {
|
|
t.Fatalf("read disabled status: %v", err)
|
|
}
|
|
if status != "disabled" {
|
|
t.Fatalf("disabled OIDC user was reactivated: %q", status)
|
|
}
|
|
}
|
|
|
|
func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(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 JIT PostgreSQL integration tests")
|
|
}
|
|
ctx := context.Background()
|
|
applyOIDCJITTestMigrations(t, ctx, databaseURL)
|
|
db, err := Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
subject := "platform-jit-missing-" + time.Now().UTC().Format("20060102150405.000000000")
|
|
input := ResolveOrProvisionOIDCUserInput{
|
|
Issuer: "https://auth.test.example/realms/easyai",
|
|
Subject: subject,
|
|
Username: "missing-user",
|
|
Roles: []string{"basic"},
|
|
TenantID: "auth-center-test-tenant",
|
|
GatewayTenantKey: "missing-tenant-key",
|
|
}
|
|
|
|
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCUserNotProvisioned) {
|
|
t.Fatalf("disabled JIT error = %v, want ErrOIDCUserNotProvisioned", err)
|
|
}
|
|
input.ProvisioningEnabled = true
|
|
if _, err := db.ResolveOrProvisionOIDCUser(ctx, input); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
|
t.Fatalf("missing tenant error = %v, want ErrOIDCTenantUnavailable", err)
|
|
}
|
|
var users int
|
|
if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_users WHERE source = 'oidc' AND external_user_id = $1`, subject).Scan(&users); err != nil {
|
|
t.Fatalf("count rejected users: %v", err)
|
|
}
|
|
if users != 0 {
|
|
t.Fatalf("rejected OIDC request created %d users", users)
|
|
}
|
|
|
|
suffix := time.Now().UTC().Format("20060102150405.000000000")
|
|
groupKey := "jit-disabled-group-" + suffix
|
|
tenantKey := "jit-disabled-tenant-" + suffix
|
|
var groupID string
|
|
if err := db.pool.QueryRow(ctx, `
|
|
INSERT INTO gateway_user_groups (group_key, name, status)
|
|
VALUES ($1, 'OIDC JIT disabled group test', 'active')
|
|
RETURNING id::text`, groupKey).Scan(&groupID); err != nil {
|
|
t.Fatalf("create disabled-mapping test group: %v", err)
|
|
}
|
|
var tenantID string
|
|
if err := db.pool.QueryRow(ctx, `
|
|
INSERT INTO gateway_tenants (tenant_key, name, default_user_group_id, status)
|
|
VALUES ($1, 'OIDC JIT disabled tenant test', $2::uuid, 'disabled')
|
|
RETURNING id::text`, tenantKey, groupID).Scan(&tenantID); err != nil {
|
|
t.Fatalf("create disabled-mapping test tenant: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_tenants WHERE id = $1::uuid`, tenantID)
|
|
_, _ = db.pool.Exec(context.Background(), `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, groupID)
|
|
})
|
|
|
|
disabledMappingInput := input
|
|
disabledMappingInput.Subject += "-disabled-mapping"
|
|
disabledMappingInput.GatewayTenantKey = tenantKey
|
|
if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
|
t.Fatalf("disabled tenant error = %v, want ErrOIDCTenantUnavailable", err)
|
|
}
|
|
if _, err := db.pool.Exec(ctx, `UPDATE gateway_tenants SET status = 'active' WHERE id = $1::uuid`, tenantID); err != nil {
|
|
t.Fatalf("enable test tenant: %v", err)
|
|
}
|
|
if _, err := db.pool.Exec(ctx, `UPDATE gateway_user_groups SET status = 'disabled' WHERE id = $1::uuid`, groupID); err != nil {
|
|
t.Fatalf("disable test group: %v", err)
|
|
}
|
|
if _, err := db.ResolveOrProvisionOIDCUser(ctx, disabledMappingInput); !errorsIs(err, ErrOIDCTenantUnavailable) {
|
|
t.Fatalf("disabled user group error = %v, want ErrOIDCTenantUnavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestOIDCUserKeyIsStableAndDoesNotExposeClaims(t *testing.T) {
|
|
issuer := "https://auth.test.example/realms/easyai"
|
|
subject := "platform-sensitive-subject"
|
|
first := deriveOIDCUserKey(issuer, subject)
|
|
second := deriveOIDCUserKey(issuer+"/", subject)
|
|
if first == "" || first != second {
|
|
t.Fatalf("OIDC user key is not stable: %q != %q", first, second)
|
|
}
|
|
if strings.Contains(first, subject) || strings.Contains(first, issuer) {
|
|
t.Fatalf("OIDC user key exposes raw claims: %q", first)
|
|
}
|
|
if first == deriveOIDCUserKey(issuer, subject+"-other") {
|
|
t.Fatal("different subjects produced the same OIDC user key")
|
|
}
|
|
}
|
|
|
|
func errorsIs(err error, target error) bool {
|
|
for err != nil {
|
|
if err == target {
|
|
return true
|
|
}
|
|
type unwrapper interface{ Unwrap() error }
|
|
wrapped, ok := err.(unwrapper)
|
|
if !ok {
|
|
return false
|
|
}
|
|
err = wrapped.Unwrap()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsOIDCTestRole(roles []string, expected string) bool {
|
|
for _, role := range roles {
|
|
if role == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func applyOIDCJITTestMigrations(t *testing.T, ctx context.Context, databaseURL string) {
|
|
t.Helper()
|
|
_, filename, _, _ := runtime.Caller(0)
|
|
migrationFiles, err := filepath.Glob(filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "*.sql"))
|
|
if err != nil {
|
|
t.Fatalf("read migration files: %v", err)
|
|
}
|
|
sort.Strings(migrationFiles)
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect migration db: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
|
t.Fatalf("ensure schema migrations: %v", err)
|
|
}
|
|
for _, migrationPath := range migrationFiles {
|
|
version := strings.TrimSuffix(filepath.Base(migrationPath), filepath.Ext(migrationPath))
|
|
var exists bool
|
|
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&exists); err != nil {
|
|
t.Fatalf("check migration %s: %v", version, err)
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
migration, err := os.ReadFile(migrationPath)
|
|
if err != nil {
|
|
t.Fatalf("read migration %s: %v", version, err)
|
|
}
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin migration %s: %v", version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, string(migration)); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatalf("apply migration %s: %v", version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatalf("record migration %s: %v", version, err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatalf("commit migration %s: %v", version, err)
|
|
}
|
|
}
|
|
}
|