支持 Manifest V2 动态 tid 验证、Tenant Context 同步和租户内 JIT 投影,并保留 Manifest V1 与旧 Session 兼容。\n\n增加 tenantHint、租户切换、普通注册关闭及 application/principal/tenant 两级 SSF 撤销;迁移、定向安全测试和本地双租户跨仓 E2E 已通过。\n\nrelease_required=true;未执行 Release、Staging 或真实链路。
262 lines
12 KiB
Go
262 lines
12 KiB
Go
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
|
|
}
|