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
@@ -0,0 +1,261 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Store) resolveOrProvisionOIDCMultiTenantUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
if input.Issuer == "" || uuid.Validate(input.ApplicationID) != nil || uuid.Validate(input.TenantID) != nil ||
input.Subject == "" || input.TenantMetadataStatus != "synced" && input.TenantMetadataStatus != "metadata_pending" {
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid multi-tenant OIDC user projection input")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err :=
loadOIDCTenantBinding(ctx, tx, input)
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
}
bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err =
createOIDCTenantBinding(ctx, tx, input)
accessStatus = "active"
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if accessStatus != "active" {
if input.TenantMetadataStatus != "synced" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
}
tag, reactivateErr := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings
SET access_status='active',metadata_status='synced',last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='disabled'`, bindingID)
if reactivateErr != nil {
return ResolveOrProvisionOIDCUserResult{}, reactivateErr
}
if tag.RowsAffected() != 1 {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
}
accessStatus = "active"
}
if input.TenantMetadataStatus == "synced" {
if _, err := tx.Exec(ctx, `UPDATE gateway_oidc_tenant_bindings SET
metadata_status='synced',display_name=$2,slug=$3,metadata_version=NULLIF($4,''),
metadata_etag=NULLIF($5,''),metadata_updated_at=$6,
last_sync_at=now(),next_sync_at=now()+interval '15 minutes',sync_failure_count=0,last_error_category=NULL,updated_at=now()
WHERE id=$1::uuid AND access_status='active'`,
bindingID, input.TenantName, input.TenantSlug, input.TenantMetadataVersion,
input.TenantMetadataETag, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if _, err := tx.Exec(ctx, `UPDATE gateway_tenants SET name=$2,synced_at=now(),source_updated_at=$3,updated_at=now()
WHERE id=$1::uuid AND source='oidc_v2' AND status='active' AND deleted_at IS NULL`,
gatewayTenantID, input.TenantName, input.TenantMetadataUpdatedAt,
); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
}
user, userBindingID, err := loadOIDCMultiTenantUser(ctx, tx, bindingID, input.Subject)
created := false
if errors.Is(err, pgx.ErrNoRows) {
if !input.ProvisioningEnabled {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
}
user, userBindingID, created, err = s.createOIDCMultiTenantUser(
ctx, tx, bindingID, gatewayTenantID, tenantKey, groupID, input,
)
}
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
if user.Status != "active" {
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
}
rolesJSON, _ := json.Marshal(input.Roles)
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' AND status='active' AND deleted_at IS NULL
RETURNING `+userColumns, user.ID, input.Username, string(rolesJSON)))
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
var auditID string
if created {
subjectHash := sha256.Sum256([]byte(input.Subject))
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
Category: "identity", Action: "identity.oidc_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", "tenantKey": user.TenantKey, "userGroupId": user.DefaultUserGroupID},
Metadata: map[string]any{
"provisioningMode": "oidc-multi-tenant-jit",
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
},
})
if err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
auditID = audit.ID
}
if err := tx.Commit(ctx); err != nil {
return ResolveOrProvisionOIDCUserResult{}, err
}
return ResolveOrProvisionOIDCUserResult{
User: multiTenantAuthUser(user, groupKey, input, userBindingID),
Created: created, AuditID: auditID,
}, nil
}
func loadOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, string, error) {
var bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus string
err := tx.QueryRow(ctx, `SELECT binding.id::text,tenant.id::text,tenant.tenant_key,
tenant.default_user_group_id::text,group_record.group_key,binding.access_status
FROM gateway_oidc_tenant_bindings binding
JOIN gateway_tenants tenant ON tenant.id=binding.gateway_tenant_id
JOIN gateway_user_groups group_record ON group_record.id=tenant.default_user_group_id
WHERE binding.issuer=$1 AND binding.application_id=$2 AND binding.external_tenant_id=$3
AND tenant.status='active' AND tenant.deleted_at IS NULL AND group_record.status='active'
FOR UPDATE OF binding,tenant`,
input.Issuer, input.ApplicationID, input.TenantID,
).Scan(&bindingID, &gatewayTenantID, &tenantKey, &groupID, &groupKey, &accessStatus)
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, accessStatus, err
}
func createOIDCTenantBinding(ctx context.Context, tx pgx.Tx, input ResolveOrProvisionOIDCUserInput) (string, string, string, string, string, error) {
var groupID, groupKey string
if err := tx.QueryRow(ctx, `SELECT id::text,group_key FROM gateway_user_groups
WHERE group_key='default' AND status='active'`).Scan(&groupID, &groupKey); err != nil {
return "", "", "", "", "", ErrOIDCTenantUnavailable
}
tenantKey := deriveOIDCMultiTenantKey(input.Issuer, input.ApplicationID, input.TenantID)
name := input.TenantName
if name == "" {
name = "认证中心租户 " + strings.ReplaceAll(input.TenantID, "-", "")[:8]
}
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit", "metadataStatus": input.TenantMetadataStatus})
var gatewayTenantID string
err := tx.QueryRow(ctx, `INSERT INTO gateway_tenants(
tenant_key,source,external_tenant_id,name,default_user_group_id,metadata,status,synced_at,source_updated_at
) VALUES($1,'oidc_v2',NULL,$2,$3::uuid,$4::jsonb,'active',
CASE WHEN $5='synced' THEN now() ELSE NULL END,$6)
ON CONFLICT(tenant_key) DO UPDATE SET updated_at=gateway_tenants.updated_at
RETURNING id::text`, tenantKey, name, groupID, string(metadata), input.TenantMetadataStatus, nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt)).Scan(&gatewayTenantID)
if err != nil {
return "", "", "", "", "", err
}
var bindingID string
err = tx.QueryRow(ctx, `INSERT INTO gateway_oidc_tenant_bindings(
issuer,application_id,external_tenant_id,gateway_tenant_id,access_status,metadata_status,
display_name,slug,metadata_version,metadata_etag,metadata_updated_at,last_sync_at,next_sync_at
) VALUES($1,$2,$3,$4::uuid,'active',$5,NULLIF($6,''),NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),
$10,
CASE WHEN $5='synced' THEN now() ELSE NULL END,
CASE WHEN $5='synced' THEN now()+interval '15 minutes' ELSE now()+interval '30 seconds' END)
ON CONFLICT(issuer,application_id,external_tenant_id) DO UPDATE SET updated_at=gateway_oidc_tenant_bindings.updated_at
RETURNING id::text`,
input.Issuer, input.ApplicationID, input.TenantID, gatewayTenantID, input.TenantMetadataStatus,
input.TenantName, input.TenantSlug, input.TenantMetadataVersion, input.TenantMetadataETag,
nullableOIDCTenantMetadataTime(input.TenantMetadataUpdatedAt),
).Scan(&bindingID)
return bindingID, gatewayTenantID, tenantKey, groupID, groupKey, err
}
func loadOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, subject string) (GatewayUser, string, error) {
var userBindingID, gatewayUserID string
if err := tx.QueryRow(ctx, `SELECT binding.id::text,binding.gateway_user_id::text
FROM gateway_oidc_user_bindings binding
JOIN gateway_users ON gateway_users.id=binding.gateway_user_id
WHERE binding.tenant_binding_id=$1::uuid AND binding.subject=$2
AND gateway_users.deleted_at IS NULL
FOR UPDATE OF binding,gateway_users`, bindingID, subject).Scan(&userBindingID, &gatewayUserID); err != nil {
return GatewayUser{}, "", err
}
user, err := scanUser(tx.QueryRow(ctx, `SELECT `+userColumns+`
FROM gateway_users WHERE id=$1::uuid`, gatewayUserID))
return user, userBindingID, err
}
func (s *Store) createOIDCMultiTenantUser(ctx context.Context, tx pgx.Tx, bindingID, gatewayTenantID, tenantKey, groupID string, input ResolveOrProvisionOIDCUserInput) (GatewayUser, string, bool, error) {
userKey := deriveOIDCMultiTenantUserKey(input.Issuer, input.ApplicationID, input.TenantID, input.Subject)
username := input.Username
if username == "" {
username = "oidc-" + strings.TrimPrefix(userKey, "oidc2:")[:12]
}
rolesJSON, _ := json.Marshal(input.Roles)
metadata, _ := json.Marshal(map[string]any{"provisioningMode": "oidc-multi-tenant-jit"})
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',NULL,$2,$3::uuid,$4,$5,$6::uuid,$7::jsonb,'{}'::jsonb,$8::jsonb,
'active',now(),now(),now())
ON CONFLICT(user_key) DO UPDATE SET updated_at=gateway_users.updated_at
RETURNING `+userColumns,
userKey, username, gatewayTenantID, input.TenantID, tenantKey, groupID, string(rolesJSON), string(metadata),
))
if err != nil {
return GatewayUser{}, "", false, err
}
var userBindingID string
tag, err := tx.Exec(ctx, `INSERT INTO gateway_oidc_user_bindings(tenant_binding_id,subject,gateway_user_id)
VALUES($1::uuid,$2,$3::uuid) ON CONFLICT(tenant_binding_id,subject) DO NOTHING`,
bindingID, input.Subject, user.ID)
if err != nil {
return GatewayUser{}, "", false, err
}
created := tag.RowsAffected() == 1
if err := tx.QueryRow(ctx, `SELECT id::text FROM gateway_oidc_user_bindings
WHERE tenant_binding_id=$1::uuid AND subject=$2`, bindingID, input.Subject).Scan(&userBindingID); err != nil {
return GatewayUser{}, "", false, err
}
if _, err := s.ensureWalletAccount(ctx, tx, user.ID, "resource"); err != nil {
return GatewayUser{}, "", false, err
}
return user, userBindingID, created, nil
}
func deriveOIDCMultiTenantKey(issuer, applicationID, tenantID string) string {
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID))
return fmt.Sprintf("oidc2-tenant-%x", sum[:16])
}
func nullableOIDCTenantMetadataTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
func deriveOIDCMultiTenantUserKey(issuer, applicationID, tenantID, subject string) string {
sum := sha256.Sum256([]byte(strings.TrimRight(issuer, "/") + "\x00" + applicationID + "\x00" + tenantID + "\x00" + subject))
return fmt.Sprintf("oidc2:%x", sum)
}
func multiTenantAuthUser(user GatewayUser, groupKey string, input ResolveOrProvisionOIDCUserInput, userBindingID string) *auth.User {
result := authUserFromOIDCProjection(user, groupKey)
result.ID = input.Subject
result.TenantName = input.TenantName
result.Issuer = input.Issuer
result.ApplicationID = input.ApplicationID
result.OIDCClientID = input.OIDCClientID
result.OIDCUserBindingID = userBindingID
return result
}