348 lines
11 KiB
Go
348 lines
11 KiB
Go
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/jackc/pgx/v5"
|
|
)
|
|
|
|
var (
|
|
ErrOIDCUserNotProvisioned = errors.New("OIDC gateway user is not provisioned")
|
|
ErrOIDCUserDisabled = errors.New("OIDC gateway user is disabled")
|
|
ErrOIDCTenantUnavailable = errors.New("OIDC gateway tenant is unavailable")
|
|
)
|
|
|
|
type ResolveOrProvisionOIDCUserInput struct {
|
|
Issuer string
|
|
Subject string
|
|
Username string
|
|
Roles []string
|
|
TenantID string
|
|
GatewayTenantKey string
|
|
ProvisioningEnabled bool
|
|
RequestIP string
|
|
UserAgent string
|
|
}
|
|
|
|
type ResolveOrProvisionOIDCUserResult struct {
|
|
User *auth.User
|
|
Created bool
|
|
AuditID string
|
|
}
|
|
|
|
type oidcUserProjection struct {
|
|
user GatewayUser
|
|
userGroupKey string
|
|
tenantStatus string
|
|
tenantDeleted bool
|
|
groupStatus string
|
|
userDeleted bool
|
|
}
|
|
|
|
func (s *Store) ResolveOrProvisionOIDCUser(ctx context.Context, input ResolveOrProvisionOIDCUserInput) (ResolveOrProvisionOIDCUserResult, error) {
|
|
input = normalizeOIDCUserInput(input)
|
|
if input.Issuer == "" || input.Subject == "" || input.TenantID == "" {
|
|
return ResolveOrProvisionOIDCUserResult{}, errors.New("invalid OIDC user projection input")
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
projection, err := loadOIDCUserProjection(ctx, tx, input.Subject)
|
|
if err == nil {
|
|
result, resolveErr := s.syncExistingOIDCUser(ctx, tx, projection, input)
|
|
if resolveErr != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, resolveErr
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
if !input.ProvisioningEnabled {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserNotProvisioned
|
|
}
|
|
if input.GatewayTenantKey == "" {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
|
}
|
|
|
|
tenantID, userGroupID, userGroupKey, err := loadOIDCProvisioningTenant(ctx, tx, input.GatewayTenantKey)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
|
}
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
rolesJSON, err := json.Marshal(input.Roles)
|
|
if err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
userKey := deriveOIDCUserKey(input.Issuer, input.Subject)
|
|
username := input.Username
|
|
if username == "" {
|
|
username = "oidc-" + strings.TrimPrefix(userKey, "oidc:")[:12]
|
|
}
|
|
metadataJSON := `{"provisioningMode":"oidc-jit"}`
|
|
|
|
createdUser, 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', $2, $3, $4::uuid, $5, $6, $7::uuid, $8::jsonb, '{}'::jsonb, $9::jsonb,
|
|
'active', now(), now(), now())
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING `+userColumns,
|
|
userKey,
|
|
input.Subject,
|
|
username,
|
|
tenantID,
|
|
input.TenantID,
|
|
input.GatewayTenantKey,
|
|
userGroupID,
|
|
string(rolesJSON),
|
|
metadataJSON,
|
|
))
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
projection, err = loadOIDCUserProjection(ctx, tx, input.Subject)
|
|
if err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
result, resolveErr := s.syncExistingOIDCUser(ctx, tx, projection, input)
|
|
if resolveErr != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, resolveErr
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
if _, err := s.ensureWalletAccount(ctx, tx, createdUser.ID, "resource"); err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
subjectHash := sha256.Sum256([]byte(input.Subject))
|
|
audit, err := s.RecordAuditLogTx(ctx, tx, AuditLogInput{
|
|
Category: "identity",
|
|
Action: "identity.oidc_user.provisioned",
|
|
ActorGatewayUserID: createdUser.ID,
|
|
ActorUsername: createdUser.Username,
|
|
ActorSource: "oidc",
|
|
ActorRoles: createdUser.Roles,
|
|
TargetType: "gateway_user",
|
|
TargetID: createdUser.ID,
|
|
TargetGatewayUserID: createdUser.ID,
|
|
TargetGatewayTenantID: createdUser.GatewayTenantID,
|
|
RequestIP: input.RequestIP,
|
|
UserAgent: input.UserAgent,
|
|
AfterState: map[string]any{
|
|
"source": "oidc",
|
|
"tenantKey": createdUser.TenantKey,
|
|
"userGroupId": createdUser.DefaultUserGroupID,
|
|
},
|
|
Metadata: map[string]any{
|
|
"provisioningMode": "oidc-jit",
|
|
"externalSubjectHash": hex.EncodeToString(subjectHash[:])[:16],
|
|
},
|
|
})
|
|
if err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
return ResolveOrProvisionOIDCUserResult{
|
|
User: authUserFromOIDCProjection(createdUser, userGroupKey),
|
|
Created: true,
|
|
AuditID: audit.ID,
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if projection.tenantDeleted || projection.tenantStatus != "active" || projection.groupStatus != "active" ||
|
|
projection.user.GatewayTenantID == "" || projection.user.DefaultUserGroupID == "" || projection.userGroupKey == "" {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
|
}
|
|
if input.GatewayTenantKey != "" && projection.user.TenantKey != input.GatewayTenantKey {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
|
}
|
|
if projection.user.TenantID != "" && projection.user.TenantID != input.TenantID {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCTenantUnavailable
|
|
}
|
|
|
|
rolesJSON, err := json.Marshal(input.Roles)
|
|
if err != nil {
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
updated, 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'
|
|
AND deleted_at IS NULL
|
|
AND status = 'active'
|
|
RETURNING `+userColumns,
|
|
projection.user.ID,
|
|
input.Username,
|
|
string(rolesJSON),
|
|
))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ResolveOrProvisionOIDCUserResult{}, ErrOIDCUserDisabled
|
|
}
|
|
return ResolveOrProvisionOIDCUserResult{}, err
|
|
}
|
|
return ResolveOrProvisionOIDCUserResult{User: authUserFromOIDCProjection(updated, projection.userGroupKey)}, nil
|
|
}
|
|
|
|
func loadOIDCUserProjection(ctx context.Context, tx pgx.Tx, subject string) (oidcUserProjection, error) {
|
|
var projection oidcUserProjection
|
|
var roles []byte
|
|
var authProfile []byte
|
|
var metadata []byte
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT
|
|
u.id::text, u.user_key, u.source, COALESCE(u.external_user_id, ''), u.username,
|
|
COALESCE(u.display_name, ''), COALESCE(u.email, ''), COALESCE(u.phone, ''), COALESCE(u.avatar_url, ''),
|
|
COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''),
|
|
COALESCE(u.default_user_group_id::text, ''), u.roles, u.auth_profile, u.metadata,
|
|
u.status, COALESCE(u.last_login_at::text, ''), COALESCE(u.synced_at::text, ''), COALESCE(u.source_updated_at::text, ''),
|
|
u.created_at, u.updated_at,
|
|
COALESCE(g.group_key, ''), COALESCE(t.status, ''), t.deleted_at IS NOT NULL,
|
|
COALESCE(g.status, ''), u.deleted_at IS NOT NULL
|
|
FROM gateway_users u
|
|
LEFT JOIN gateway_tenants t ON t.id = u.gateway_tenant_id
|
|
LEFT JOIN gateway_user_groups g ON g.id = u.default_user_group_id
|
|
WHERE u.source = 'oidc' AND u.external_user_id = $1
|
|
FOR UPDATE OF u`, subject).Scan(
|
|
&projection.user.ID,
|
|
&projection.user.UserKey,
|
|
&projection.user.Source,
|
|
&projection.user.ExternalUserID,
|
|
&projection.user.Username,
|
|
&projection.user.DisplayName,
|
|
&projection.user.Email,
|
|
&projection.user.Phone,
|
|
&projection.user.AvatarURL,
|
|
&projection.user.GatewayTenantID,
|
|
&projection.user.TenantID,
|
|
&projection.user.TenantKey,
|
|
&projection.user.DefaultUserGroupID,
|
|
&roles,
|
|
&authProfile,
|
|
&metadata,
|
|
&projection.user.Status,
|
|
&projection.user.LastLoginAt,
|
|
&projection.user.SyncedAt,
|
|
&projection.user.SourceUpdatedAt,
|
|
&projection.user.CreatedAt,
|
|
&projection.user.UpdatedAt,
|
|
&projection.userGroupKey,
|
|
&projection.tenantStatus,
|
|
&projection.tenantDeleted,
|
|
&projection.groupStatus,
|
|
&projection.userDeleted,
|
|
)
|
|
if err != nil {
|
|
return oidcUserProjection{}, err
|
|
}
|
|
projection.user.Roles = decodeStringArray(roles)
|
|
projection.user.AuthProfile = decodeObject(authProfile)
|
|
projection.user.Metadata = decodeObject(metadata)
|
|
return projection, nil
|
|
}
|
|
|
|
func loadOIDCProvisioningTenant(ctx context.Context, tx pgx.Tx, tenantKey string) (string, string, string, error) {
|
|
var tenantID string
|
|
var groupID string
|
|
var groupKey string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT t.id::text, t.default_user_group_id::text, g.group_key
|
|
FROM gateway_tenants t
|
|
JOIN gateway_user_groups g ON g.id = t.default_user_group_id
|
|
WHERE t.tenant_key = $1
|
|
AND t.status = 'active'
|
|
AND t.deleted_at IS NULL
|
|
AND g.status = 'active'`, tenantKey).Scan(&tenantID, &groupID, &groupKey)
|
|
return tenantID, groupID, groupKey, err
|
|
}
|
|
|
|
func normalizeOIDCUserInput(input ResolveOrProvisionOIDCUserInput) ResolveOrProvisionOIDCUserInput {
|
|
input.Issuer = strings.TrimRight(strings.TrimSpace(input.Issuer), "/")
|
|
input.Subject = strings.TrimSpace(input.Subject)
|
|
input.Username = strings.TrimSpace(input.Username)
|
|
input.TenantID = strings.TrimSpace(input.TenantID)
|
|
input.GatewayTenantKey = strings.TrimSpace(input.GatewayTenantKey)
|
|
input.RequestIP = strings.TrimSpace(input.RequestIP)
|
|
input.UserAgent = strings.TrimSpace(input.UserAgent)
|
|
input.Roles = normalizeOIDCRoles(input.Roles)
|
|
return input
|
|
}
|
|
|
|
func normalizeOIDCRoles(roles []string) []string {
|
|
result := make([]string, 0, len(roles))
|
|
seen := make(map[string]struct{}, len(roles))
|
|
for _, role := range roles {
|
|
role = strings.TrimSpace(role)
|
|
if role == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[role]; ok {
|
|
continue
|
|
}
|
|
seen[role] = struct{}{}
|
|
result = append(result, role)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func deriveOIDCUserKey(issuer string, subject string) string {
|
|
issuer = strings.TrimRight(strings.TrimSpace(issuer), "/")
|
|
sum := sha256.Sum256([]byte(issuer + "\x00" + strings.TrimSpace(subject)))
|
|
return fmt.Sprintf("oidc:%x", sum)
|
|
}
|
|
|
|
func authUserFromOIDCProjection(user GatewayUser, userGroupKey string) *auth.User {
|
|
groupKeys := []string(nil)
|
|
if userGroupKey != "" {
|
|
groupKeys = []string{userGroupKey}
|
|
}
|
|
return &auth.User{
|
|
ID: user.ExternalUserID,
|
|
Username: user.Username,
|
|
Roles: user.Roles,
|
|
TenantID: user.TenantID,
|
|
GatewayTenantID: user.GatewayTenantID,
|
|
TenantKey: user.TenantKey,
|
|
Source: "oidc",
|
|
GatewayUserID: user.ID,
|
|
UserGroupID: user.DefaultUserGroupID,
|
|
UserGroupKey: userGroupKey,
|
|
UserGroupKeys: groupKeys,
|
|
}
|
|
}
|