fix(identity): 支持平台用户显式登录 AI Gateway
修复多租户身份配置将所有人类登录都强制解释为租户上下文的问题。Web 现在提供平台与租户两个受控入口,API 严格绑定 context_type、tid、issuer、application 和 subject,并为平台用户建立独立本地投影与可刷新会话。\n\n风险:新增会话身份列保持旧会话可读,新会话一律使用严格约束;未改变租户数据隔离和 API Key 行为。\n\n验证:Go 全量测试与 go vet 通过;PostgreSQL 平台投影、会话和安全事件集成测试通过;前端 lint、141 项测试与生产 build 通过;OpenAPI 生成和迁移安全测试通过。
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const oidcPlatformTenantKey = "default"
|
||||
|
||||
func (s *Store) resolveOrProvisionOIDCPlatformUser(
|
||||
ctx context.Context,
|
||||
input ResolveOrProvisionOIDCUserInput,
|
||||
) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
if input.Issuer == "" || input.Subject == "" ||
|
||||
input.ContextType != "platform" ||
|
||||
input.TenantMode != "multi_tenant" ||
|
||||
uuid.Validate(input.ApplicationID) != nil ||
|
||||
input.TenantID != "" {
|
||||
return ResolveOrProvisionOIDCUserResult{},
|
||||
errors.New("invalid platform OIDC user projection input")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
defer rollbackTransaction(tx)
|
||||
|
||||
userKey := deriveOIDCPlatformUserKey(
|
||||
input.Issuer,
|
||||
input.ApplicationID,
|
||||
input.Subject,
|
||||
)
|
||||
user, groupKey, err := loadOIDCPlatformUser(ctx, tx, userKey)
|
||||
created := false
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if !input.ProvisioningEnabled {
|
||||
return ResolveOrProvisionOIDCUserResult{},
|
||||
ErrOIDCUserNotProvisioned
|
||||
}
|
||||
user, groupKey, created, err = s.createOIDCPlatformUser(
|
||||
ctx,
|
||||
tx,
|
||||
userKey,
|
||||
input,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
||||
}
|
||||
rolesJSON, err := json.Marshal(input.Roles)
|
||||
if err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
user, err = scanUser(tx.QueryRow(ctx, `UPDATE gateway_users SET
|
||||
username=COALESCE(NULLIF($2,''),username),roles=$3::jsonb,
|
||||
last_login_at=now(),synced_at=now(),source_updated_at=now(),
|
||||
updated_at=now()
|
||||
WHERE id=$1::uuid AND source='oidc_v2_platform'
|
||||
AND status='active' AND deleted_at IS NULL
|
||||
RETURNING `+userColumns,
|
||||
user.ID,
|
||||
input.Username,
|
||||
string(rolesJSON),
|
||||
))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
var auditID string
|
||||
if created {
|
||||
subjectHash := sha256.Sum256([]byte(input.Subject))
|
||||
audit, auditErr := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
|
||||
Category: "identity",
|
||||
Action: "identity.oidc_platform_user.provisioned",
|
||||
ActorGatewayUserID: user.ID,
|
||||
ActorUsername: user.Username,
|
||||
ActorSource: "oidc",
|
||||
ActorRoles: user.Roles,
|
||||
TargetType: "gateway_user",
|
||||
TargetID: user.ID,
|
||||
TargetGatewayUserID: user.ID,
|
||||
TargetGatewayTenantID: user.GatewayTenantID,
|
||||
RequestIP: input.RequestIP,
|
||||
UserAgent: input.UserAgent,
|
||||
AfterState: map[string]any{
|
||||
"source": "oidc_v2_platform",
|
||||
"contextType": "platform",
|
||||
"tenantKey": user.TenantKey,
|
||||
"userGroupId": user.DefaultUserGroupID,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"provisioningMode": "oidc-platform-jit",
|
||||
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
|
||||
},
|
||||
})
|
||||
if auditErr != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, auditErr
|
||||
}
|
||||
auditID = audit.ID
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ResolveOrProvisionOIDCUserResult{}, err
|
||||
}
|
||||
return ResolveOrProvisionOIDCUserResult{
|
||||
User: platformAuthUser(user, groupKey, input),
|
||||
Created: created,
|
||||
AuditID: auditID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadOIDCPlatformUser(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
userKey string,
|
||||
) (GatewayUser, string, error) {
|
||||
var userID string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text
|
||||
FROM gateway_users
|
||||
WHERE user_key=$1 AND source='oidc_v2_platform'
|
||||
FOR UPDATE`, userKey).Scan(&userID); err != nil {
|
||||
return GatewayUser{}, "", err
|
||||
}
|
||||
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
|
||||
FROM gateway_users WHERE id=$1::uuid`, userID))
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", err
|
||||
}
|
||||
var groupKey string
|
||||
if err := tx.QueryRow(ctx, `SELECT group_record.group_key
|
||||
FROM gateway_users user_record
|
||||
JOIN gateway_tenants tenant
|
||||
ON tenant.id=user_record.gateway_tenant_id
|
||||
AND tenant.status='active' AND tenant.deleted_at IS NULL
|
||||
JOIN gateway_user_groups group_record
|
||||
ON group_record.id=user_record.default_user_group_id
|
||||
AND group_record.status='active'
|
||||
WHERE user_record.id=$1::uuid`, userID).Scan(&groupKey); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GatewayUser{}, "", ErrOIDCTenantUnavailable
|
||||
}
|
||||
return GatewayUser{}, "", err
|
||||
}
|
||||
return user, groupKey, nil
|
||||
}
|
||||
|
||||
func (s *Store) createOIDCPlatformUser(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
userKey string,
|
||||
input ResolveOrProvisionOIDCUserInput,
|
||||
) (GatewayUser, string, bool, error) {
|
||||
gatewayTenantID, groupID, groupKey, err :=
|
||||
loadOIDCProvisioningTenant(ctx, tx, oidcPlatformTenantKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GatewayUser{}, "", false, ErrOIDCTenantUnavailable
|
||||
}
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
rolesJSON, err := json.Marshal(input.Roles)
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
metadataJSON, err := json.Marshal(map[string]any{
|
||||
"provisioningMode": "oidc-platform-jit",
|
||||
"contextType": "platform",
|
||||
"applicationId": input.ApplicationID,
|
||||
})
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
username := input.Username
|
||||
if username == "" {
|
||||
username = "oidc-platform-" +
|
||||
strings.TrimPrefix(userKey, "oidc2-platform:")[:12]
|
||||
}
|
||||
user, err := scanUser(tx.QueryRow(ctx, `INSERT INTO gateway_users(
|
||||
user_key,source,external_user_id,username,gateway_tenant_id,
|
||||
tenant_id,tenant_key,default_user_group_id,roles,auth_profile,
|
||||
metadata,status,last_login_at,synced_at,source_updated_at
|
||||
) VALUES(
|
||||
$1,'oidc_v2_platform',NULL,$2,$3::uuid,NULL,$4,$5::uuid,
|
||||
$6::jsonb,'{}'::jsonb,$7::jsonb,'active',now(),now(),now()
|
||||
)
|
||||
ON CONFLICT(user_key) DO NOTHING
|
||||
RETURNING `+userColumns,
|
||||
userKey,
|
||||
username,
|
||||
gatewayTenantID,
|
||||
oidcPlatformTenantKey,
|
||||
groupID,
|
||||
string(rolesJSON),
|
||||
string(metadataJSON),
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
user, groupKey, err = loadOIDCPlatformUser(ctx, tx, userKey)
|
||||
return user, groupKey, false, err
|
||||
}
|
||||
if err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
|
||||
return GatewayUser{}, "", false, err
|
||||
}
|
||||
return user, groupKey, true, nil
|
||||
}
|
||||
|
||||
func deriveOIDCPlatformUserKey(
|
||||
issuer string,
|
||||
applicationID string,
|
||||
subject string,
|
||||
) string {
|
||||
sum := sha256.Sum256([]byte(
|
||||
strings.TrimRight(issuer, "/") + "\x00" +
|
||||
applicationID + "\x00" + subject,
|
||||
))
|
||||
return fmt.Sprintf("oidc2-platform:%x", sum)
|
||||
}
|
||||
|
||||
func platformAuthUser(
|
||||
user GatewayUser,
|
||||
groupKey string,
|
||||
input ResolveOrProvisionOIDCUserInput,
|
||||
) *auth.User {
|
||||
result := authUserFromOIDCProjection(user, groupKey)
|
||||
result.ID = input.Subject
|
||||
result.ContextType = "platform"
|
||||
result.TenantID = ""
|
||||
result.Issuer = input.Issuer
|
||||
result.ApplicationID = input.ApplicationID
|
||||
result.OIDCClientID = input.OIDCClientID
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOIDCProjectionKindRequiresExplicitCompatibleContext(t *testing.T) {
|
||||
applicationID := "11111111-1111-4111-8111-111111111111"
|
||||
tests := []struct {
|
||||
name string
|
||||
input ResolveOrProvisionOIDCUserInput
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "platform in multi-tenant application",
|
||||
input: ResolveOrProvisionOIDCUserInput{
|
||||
ContextType: "platform", TenantMode: "multi_tenant",
|
||||
ApplicationID: applicationID,
|
||||
},
|
||||
want: "platform",
|
||||
},
|
||||
{
|
||||
name: "tenant in multi-tenant application",
|
||||
input: ResolveOrProvisionOIDCUserInput{
|
||||
ContextType: "tenant", TenantMode: "multi_tenant",
|
||||
ApplicationID: applicationID,
|
||||
TenantID: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
want: "multi_tenant",
|
||||
},
|
||||
{
|
||||
name: "tenant in single-tenant application",
|
||||
input: ResolveOrProvisionOIDCUserInput{
|
||||
ContextType: "tenant", TenantMode: "single_tenant",
|
||||
TenantID: "tenant-contract-id",
|
||||
},
|
||||
want: "single_tenant",
|
||||
},
|
||||
{
|
||||
name: "missing context",
|
||||
input: ResolveOrProvisionOIDCUserInput{
|
||||
TenantMode: "multi_tenant", ApplicationID: applicationID,
|
||||
TenantID: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "platform context with tenant",
|
||||
input: ResolveOrProvisionOIDCUserInput{
|
||||
ContextType: "platform", TenantMode: "multi_tenant",
|
||||
ApplicationID: applicationID,
|
||||
TenantID: "22222222-2222-4222-8222-222222222222",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := oidcProjectionKind(test.input); got != test.want {
|
||||
t.Fatalf("projection kind=%q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type OIDCSession struct {
|
||||
ExternalUserID string
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
ContextType string
|
||||
TenantID string
|
||||
OIDCClientID string
|
||||
UserStatus string
|
||||
@@ -43,6 +44,8 @@ type CreateOIDCSessionInput struct {
|
||||
OIDCClientID string
|
||||
Issuer string
|
||||
ApplicationID string
|
||||
ContextType string
|
||||
Subject string
|
||||
TenantID string
|
||||
TokenCiphertext []byte
|
||||
AccessTokenExpiresAt time.Time
|
||||
@@ -57,26 +60,51 @@ func (s *Store) CreateOIDCSession(ctx context.Context, input CreateOIDCSessionIn
|
||||
INSERT INTO gateway_oidc_sessions (
|
||||
session_token_hash, gateway_user_id, gateway_tenant_id, token_ciphertext,
|
||||
access_token_expires_at, last_seen_at, idle_expires_at, absolute_expires_at,
|
||||
oidc_user_binding_id, oidc_client_id
|
||||
oidc_user_binding_id, oidc_client_id, issuer, application_id,
|
||||
context_type, subject, tenant_id
|
||||
)
|
||||
SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8, NULLIF($9, '')::uuid, NULLIF($10, '')
|
||||
SELECT $1, u.id, $3::uuid, $4, $5, $6, $7, $8,
|
||||
NULLIF($9, '')::uuid, NULLIF($10, ''), $11, $12, $13, $14,
|
||||
NULLIF($15, '')
|
||||
FROM gateway_users u
|
||||
LEFT JOIN gateway_oidc_user_bindings ub ON ub.id = NULLIF($9, '')::uuid
|
||||
LEFT JOIN gateway_oidc_tenant_bindings tb ON tb.id = ub.tenant_binding_id
|
||||
WHERE u.id = $2::uuid
|
||||
AND u.gateway_tenant_id = $3::uuid
|
||||
AND ($9 = '' OR (
|
||||
ub.gateway_user_id = u.id
|
||||
AND tb.gateway_tenant_id = u.gateway_tenant_id
|
||||
AND tb.issuer = $11
|
||||
AND tb.application_id = $12
|
||||
AND tb.external_tenant_id = $13
|
||||
))
|
||||
AND (
|
||||
(
|
||||
$13 = 'platform'
|
||||
AND $9 = ''
|
||||
AND $15 = ''
|
||||
AND u.source = 'oidc_v2_platform'
|
||||
)
|
||||
OR
|
||||
(
|
||||
$13 = 'tenant'
|
||||
AND (
|
||||
(
|
||||
$9 = ''
|
||||
AND u.source = 'oidc'
|
||||
AND u.external_user_id = $14
|
||||
)
|
||||
OR
|
||||
(
|
||||
ub.gateway_user_id = u.id
|
||||
AND ub.subject = $14
|
||||
AND tb.gateway_tenant_id = u.gateway_tenant_id
|
||||
AND tb.issuer = $11
|
||||
AND tb.application_id = $12
|
||||
AND tb.external_tenant_id = $15
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
RETURNING id::text`,
|
||||
input.SessionTokenHash, input.GatewayUserID, input.GatewayTenantID, input.TokenCiphertext,
|
||||
input.AccessTokenExpiresAt, input.LastSeenAt, input.IdleExpiresAt, input.AbsoluteExpiresAt,
|
||||
input.OIDCUserBindingID, input.OIDCClientID,
|
||||
input.Issuer, input.ApplicationID, input.TenantID,
|
||||
input.Issuer, input.ApplicationID, input.ContextType,
|
||||
input.Subject, input.TenantID,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return OIDCSession{}, err
|
||||
@@ -165,8 +193,10 @@ WHERE idle_expires_at <= $1 OR absolute_expires_at <= $1`, now)
|
||||
|
||||
const oidcSessionColumns = `
|
||||
s.id::text, s.session_token_hash, s.gateway_user_id::text, s.gateway_tenant_id::text,
|
||||
COALESCE(s.oidc_user_binding_id::text, ''), COALESCE(ub.subject, u.external_user_id, ''),
|
||||
COALESCE(tb.issuer, ''), COALESCE(tb.application_id, ''), COALESCE(tb.external_tenant_id, ''),
|
||||
COALESCE(s.oidc_user_binding_id::text, ''),
|
||||
COALESCE(s.subject, ub.subject, u.external_user_id, ''),
|
||||
COALESCE(s.issuer, tb.issuer, ''), COALESCE(s.application_id, tb.application_id, ''),
|
||||
COALESCE(s.context_type, ''), COALESCE(s.tenant_id, tb.external_tenant_id, ''),
|
||||
COALESCE(s.oidc_client_id, ''), u.status, u.deleted_at IS NOT NULL,
|
||||
s.token_ciphertext, s.access_token_expires_at, s.last_seen_at, s.idle_expires_at,
|
||||
s.absolute_expires_at, s.refresh_version, COALESCE(s.refresh_lock_id::text, ''),
|
||||
@@ -177,7 +207,8 @@ func scanOIDCSession(row pgx.Row) (OIDCSession, error) {
|
||||
err := row.Scan(
|
||||
&item.ID, &item.SessionTokenHash, &item.GatewayUserID, &item.GatewayTenantID,
|
||||
&item.OIDCUserBindingID, &item.ExternalUserID, &item.Issuer, &item.ApplicationID,
|
||||
&item.TenantID, &item.OIDCClientID, &item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
|
||||
&item.ContextType, &item.TenantID, &item.OIDCClientID,
|
||||
&item.UserStatus, &item.UserDeleted, &item.TokenCiphertext,
|
||||
&item.AccessTokenExpiresAt, &item.LastSeenAt, &item.IdleExpiresAt, &item.AbsoluteExpiresAt,
|
||||
&item.RefreshVersion, &item.RefreshLockID, &item.RefreshLockUntil, &item.CreatedAt, &item.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,7 @@ type ResolveOrProvisionOIDCUserInput struct {
|
||||
Subject string
|
||||
Username string
|
||||
Roles []string
|
||||
ContextType string
|
||||
TenantID string
|
||||
TenantMode string
|
||||
TenantName string
|
||||
@@ -58,8 +60,16 @@ type oidcUserProjection struct {
|
||||
|
||||
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
input = normalizeOIDCUserInput(input)
|
||||
if input.TenantMode == "multi_tenant" {
|
||||
switch oidcProjectionKind(input) {
|
||||
case "platform":
|
||||
return s.resolveOrProvisionOIDCPlatformUser(ctx, input)
|
||||
case "multi_tenant":
|
||||
return s.resolveOrProvisionOIDCMultiTenantUser(ctx, input)
|
||||
case "single_tenant":
|
||||
// Continue through the fixed local Tenant projection below.
|
||||
default:
|
||||
return ResolveOrProvisionOIDCUserResult{},
|
||||
errors.New("invalid OIDC user context")
|
||||
}
|
||||
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
|
||||
@@ -187,6 +197,31 @@ RETURNING `+userColumns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func oidcProjectionKind(input ResolveOrProvisionOIDCUserInput) string {
|
||||
switch input.ContextType {
|
||||
case "platform":
|
||||
if input.TenantMode == "multi_tenant" &&
|
||||
uuid.Validate(input.ApplicationID) == nil &&
|
||||
input.TenantID == "" {
|
||||
return "platform"
|
||||
}
|
||||
case "tenant":
|
||||
if input.TenantID == "" {
|
||||
return ""
|
||||
}
|
||||
switch input.TenantMode {
|
||||
case "multi_tenant":
|
||||
if uuid.Validate(input.ApplicationID) == nil &&
|
||||
uuid.Validate(input.TenantID) == nil {
|
||||
return "multi_tenant"
|
||||
}
|
||||
case "single_tenant":
|
||||
return "single_tenant"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Store) syncExistingOIDCUser(ctx context.Context, tx pgx.Tx, projection oidcUserProjection, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
||||
if projection.userDeleted || projection.user.Status != "active" {
|
||||
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
||||
@@ -308,6 +343,7 @@ func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProv
|
||||
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
|
||||
input.Subject = strings.TrimSpace(input.Subject)
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.ContextType = strings.TrimSpace(input.ContextType)
|
||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||
input.ApplicationID = strings.TrimSpace(input.ApplicationID)
|
||||
input.TenantMode = strings.TrimSpace(input.TenantMode)
|
||||
@@ -355,6 +391,7 @@ func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.Use
|
||||
ID: user.ExternalUserID,
|
||||
Username: user.Username,
|
||||
Roles: user.Roles,
|
||||
ContextType: "tenant",
|
||||
TenantID: user.TenantID,
|
||||
GatewayTenantID: user.GatewayTenantID,
|
||||
TenantKey: user.TenantKey,
|
||||
|
||||
@@ -2,8 +2,10 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -35,7 +37,8 @@ func TestResolveOrProvisionOIDCMultiTenantUserIsIdempotentIsolatedAndReusable(t
|
||||
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",
|
||||
Roles: []string{"basic"}, ContextType: "tenant",
|
||||
TenantID: tenantID, TenantMode: "multi_tenant",
|
||||
TenantName: name, TenantSlug: slug, TenantMetadataStatus: "synced",
|
||||
TenantMetadataVersion: "v1", TenantMetadataETag: `"v1"`,
|
||||
TenantMetadataUpdatedAt: time.Unix(1_780_000_000, 0).UTC(),
|
||||
@@ -173,7 +176,9 @@ func TestResolveOrProvisionOIDCUserLifecycleAndConcurrency(t *testing.T) {
|
||||
Subject: subject,
|
||||
Username: "jit-user-" + suffix,
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "auth-center-test-tenant",
|
||||
TenantMode: "single_tenant",
|
||||
GatewayTenantKey: "default",
|
||||
ProvisioningEnabled: true,
|
||||
}
|
||||
@@ -299,6 +304,120 @@ FROM gateway_users WHERE id = $1::uuid`, firstID).Scan(&displayName, &email, &ma
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrProvisionOIDCPlatformUserUsesExplicitContextWithoutExternalTenant(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-context-" + suffix
|
||||
applicationID := uuid.NewString()
|
||||
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_v2_platform'
|
||||
AND user_key=$1
|
||||
)`, deriveOIDCPlatformUserKey(
|
||||
"https://auth.test.example/issuer/shared",
|
||||
applicationID,
|
||||
subject,
|
||||
))
|
||||
_, _ = db.pool.Exec(context.Background(), `
|
||||
DELETE FROM gateway_users
|
||||
WHERE source='oidc_v2_platform'
|
||||
AND user_key=$1`, deriveOIDCPlatformUserKey(
|
||||
"https://auth.test.example/issuer/shared",
|
||||
applicationID,
|
||||
subject,
|
||||
))
|
||||
})
|
||||
|
||||
result, err := db.ResolveOrProvisionOIDCUser(
|
||||
ctx,
|
||||
ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: "https://auth.test.example/issuer/shared",
|
||||
ApplicationID: applicationID,
|
||||
Subject: subject,
|
||||
Username: "platform-user-" + suffix,
|
||||
Roles: []string{"admin"},
|
||||
ContextType: "platform",
|
||||
TenantMode: "multi_tenant",
|
||||
ProvisioningEnabled: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve platform user: %v", err)
|
||||
}
|
||||
if !result.Created || result.User == nil ||
|
||||
result.User.ID != subject ||
|
||||
result.User.ContextType != "platform" ||
|
||||
result.User.TenantID != "" ||
|
||||
result.User.TenantKey != oidcPlatformTenantKey ||
|
||||
result.User.GatewayTenantID == "" ||
|
||||
result.AuditID == "" {
|
||||
t.Fatalf("platform projection=%#v", result)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
sessionHash := sha256.Sum256([]byte("platform-session-" + suffix))
|
||||
session, err := db.CreateOIDCSession(ctx, CreateOIDCSessionInput{
|
||||
SessionTokenHash: sessionHash[:],
|
||||
GatewayUserID: result.User.GatewayUserID,
|
||||
GatewayTenantID: result.User.GatewayTenantID,
|
||||
OIDCClientID: "gateway-browser",
|
||||
Issuer: result.User.Issuer,
|
||||
ApplicationID: applicationID,
|
||||
ContextType: "platform",
|
||||
Subject: subject,
|
||||
TokenCiphertext: []byte("encrypted-test-token"),
|
||||
AccessTokenExpiresAt: now.Add(time.Hour),
|
||||
LastSeenAt: now,
|
||||
IdleExpiresAt: now.Add(time.Hour),
|
||||
AbsoluteExpiresAt: now.Add(2 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create platform session: %v", err)
|
||||
}
|
||||
if session.ContextType != "platform" ||
|
||||
session.ExternalUserID != subject ||
|
||||
session.Issuer != result.User.Issuer ||
|
||||
session.ApplicationID != applicationID ||
|
||||
session.TenantID != "" ||
|
||||
session.OIDCUserBindingID != "" {
|
||||
t.Fatalf("platform session=%#v", session)
|
||||
}
|
||||
|
||||
repeated, err := db.ResolveOrProvisionOIDCUser(
|
||||
ctx,
|
||||
ResolveOrProvisionOIDCUserInput{
|
||||
Issuer: "https://auth.test.example/issuer/shared",
|
||||
ApplicationID: applicationID,
|
||||
Subject: subject,
|
||||
Username: "platform-user-renamed-" + suffix,
|
||||
Roles: []string{"viewer"},
|
||||
ContextType: "platform",
|
||||
TenantMode: "multi_tenant",
|
||||
ProvisioningEnabled: false,
|
||||
},
|
||||
)
|
||||
if err != nil || repeated.Created ||
|
||||
repeated.User.GatewayUserID != result.User.GatewayUserID ||
|
||||
!reflect.DeepEqual(repeated.User.Roles, []string{"viewer"}) {
|
||||
t.Fatalf("repeated platform projection=%#v err=%v", repeated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
@@ -318,7 +437,9 @@ func TestResolveOrProvisionOIDCUserRejectsMissingMappingWithoutWrites(t *testing
|
||||
Subject: subject,
|
||||
Username: "missing-user",
|
||||
Roles: []string{"basic"},
|
||||
ContextType: "tenant",
|
||||
TenantID: "auth-center-test-tenant",
|
||||
TenantMode: "single_tenant",
|
||||
GatewayTenantKey: "missing-tenant-key",
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,8 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
|
||||
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",
|
||||
Roles: []string{"basic"}, ContextType: "tenant",
|
||||
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,
|
||||
@@ -225,6 +226,7 @@ func TestApplicationScopedSecurityEventsRevokeOnlyMatchingTenantSessions(t *test
|
||||
SessionTokenHash: sessionHash[:], GatewayUserID: user.GatewayUserID,
|
||||
GatewayTenantID: user.GatewayTenantID, OIDCUserBindingID: user.OIDCUserBindingID,
|
||||
OIDCClientID: "gateway-browser", Issuer: subjectIssuer, ApplicationID: applicationID,
|
||||
ContextType: "tenant", Subject: user.ID,
|
||||
TenantID: user.TenantID, TokenCiphertext: []byte{marker},
|
||||
AccessTokenExpiresAt: now.Add(time.Hour), LastSeenAt: now,
|
||||
IdleExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
|
||||
|
||||
Reference in New Issue
Block a user