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:
2026-07-31 14:24:40 +08:00
parent 88c971564a
commit c0296dbf06
29 changed files with 1061 additions and 84 deletions
+44 -13
View File
@@ -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,
)