1
0
forked from baron/baron-sso

feat(user): support fixed UUID registration and enhance bulk import results

- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -14,6 +14,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"net"
"net/http"
"net/url"
@@ -104,21 +105,21 @@ type devRPUsageDailyResponse struct {
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
SkipConsent bool `json:"skipConsent"`
JwksUri string `json:"jwksUri,omitempty"`
Jwks interface{} `json:"jwks,omitempty"`
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"`
ClientSecret string `json:"clientSecret,omitempty"`
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
SkipConsent bool `json:"skipConsent"`
JwksUri string `json:"jwksUri,omitempty"`
Jwks any `json:"jwks,omitempty"`
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type clientListResponse struct {
@@ -198,21 +199,21 @@ type consentListResponse struct {
}
type clientUpsertRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
Type *string `json:"type"`
Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
SkipConsent *bool `json:"skipConsent"`
JwksUri *string `json:"jwksUri"`
Jwks interface{} `json:"jwks"`
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
Metadata *map[string]interface{} `json:"metadata"`
ID *string `json:"id"`
Name *string `json:"name"`
Type *string `json:"type"`
Status *string `json:"status"`
RedirectURIs *[]string `json:"redirectUris"`
Scopes *[]string `json:"scopes"`
GrantTypes *[]string `json:"grantTypes"`
ResponseTypes *[]string `json:"responseTypes"`
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
SkipConsent *bool `json:"skipConsent"`
JwksUri *string `json:"jwksUri"`
Jwks any `json:"jwks"`
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
Metadata *map[string]any `json:"metadata"`
}
type normalizedIDTokenClaim struct {
@@ -303,7 +304,7 @@ func tenantIDFromProfile(profile *domain.UserProfileResponse) string {
func addClientIDToSet(set map[string]struct{}, raw any) {
switch value := raw.(type) {
case string:
for _, chunk := range strings.Split(value, ",") {
for chunk := range strings.SplitSeq(value, ",") {
id := strings.TrimSpace(chunk)
if id != "" {
set[id] = struct{}{}
@@ -672,7 +673,7 @@ func isProtectedSystemClientID(clientID string) bool {
return ok
}
func tenantAccessPolicyChanged(before, after map[string]interface{}) bool {
func tenantAccessPolicyChanged(before, after map[string]any) bool {
if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) {
return true
}
@@ -1162,7 +1163,7 @@ func extractAuthClaimsFromBearer(authHeader string) (string, string) {
}
}
var claims map[string]interface{}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return "", ""
}
@@ -1295,10 +1296,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
if offset > len(allItems) {
offset = len(allItems)
}
end := offset + limit
if end > len(allItems) {
end = len(allItems)
}
end := min(offset+limit, len(allItems))
items = allItems[offset:end]
if len(allItems) > end && len(items) > 0 {
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
@@ -1788,7 +1786,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
metadata := mergeMetadata(nil, req.Metadata)
if metadata == nil {
metadata = map[string]interface{}{}
metadata = map[string]any{}
}
// [Tenant Isolation] Record owner information
@@ -1858,11 +1856,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
SkipConsent: new(valueOrBool(req.SkipConsent, true)),
JWKSUri: jwksURI,
JWKS: jwks,
BackChannelLogoutURI: backchannelLogoutURI,
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
BackChannelLogoutSessionRequired: new(backchannelLogoutSessionRequired),
Metadata: metadata,
}
@@ -2005,7 +2003,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
metadata := mergeMetadata(current.Metadata, req.Metadata)
if status != "" {
if metadata == nil {
metadata = map[string]interface{}{}
metadata = map[string]any{}
}
metadata["status"] = status
}
@@ -2061,11 +2059,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
SkipConsent: boolPtr(resolvedSkipConsent),
SkipConsent: new(resolvedSkipConsent),
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
BackChannelLogoutSessionRequired: new(resolvedBackchannelLogoutSessionRequired),
Metadata: metadata,
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
@@ -2359,10 +2357,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
if offset > len(items) {
offset = len(items)
}
end := offset + limit
if end > len(items) {
end = len(items)
}
end := min(offset+limit, len(items))
pageItems := items[offset:end]
if len(items) > end && len(pageItems) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
@@ -2948,7 +2943,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
}
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
func readMetadataStringValue(metadata map[string]any, key string) string {
if metadata == nil {
return ""
}
@@ -2956,7 +2951,7 @@ func readMetadataStringValue(metadata map[string]interface{}, key string) string
return strings.TrimSpace(raw)
}
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
func readMetadataBoolValue(metadata map[string]any, key string) bool {
if metadata == nil {
return false
}
@@ -2964,7 +2959,7 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
return value
}
func readStringSliceMetadata(metadata map[string]interface{}, key string) []string {
func readStringSliceMetadata(metadata map[string]any, key string) []string {
if metadata == nil {
return nil
}
@@ -2981,7 +2976,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
}
}
return result
case []interface{}:
case []any:
result := make([]string, 0, len(typed))
for _, item := range typed {
if str, ok := item.(string); ok {
@@ -2996,7 +2991,7 @@ func readStringSliceMetadata(metadata map[string]interface{}, key string) []stri
}
}
func readMetadataValueOrNil(metadata map[string]interface{}, key string) interface{} {
func readMetadataValueOrNil(metadata map[string]any, key string) any {
if metadata == nil {
return nil
}
@@ -3007,9 +3002,9 @@ func readMetadataValueOrNil(metadata map[string]interface{}, key string) interfa
return value
}
func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) {
func normalizeBackchannelLogoutMetadata(metadata map[string]any, logoutURI string, sessionRequired bool) (map[string]any, error) {
if metadata == nil {
metadata = map[string]interface{}{}
metadata = map[string]any{}
}
trimmedURI := strings.TrimSpace(logoutURI)
@@ -3078,7 +3073,7 @@ func isAllowedLocalBackchannelLogoutHost(rawHost string) bool {
return !strings.Contains(host, ".")
}
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
func normalizeClientAutoLoginMetadata(metadata map[string]any) (map[string]any, error) {
if metadata == nil {
return metadata, nil
}
@@ -3105,11 +3100,11 @@ func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[stri
func normalizeHeadlessClientConfig(
tokenAuthMethod string,
jwksURI string,
jwks interface{},
metadata map[string]interface{},
) (string, string, interface{}, map[string]interface{}) {
jwks any,
metadata map[string]any,
) (string, string, any, map[string]any) {
if metadata == nil {
metadata = map[string]interface{}{}
metadata = map[string]any{}
}
delete(metadata, domain.MetadataRequestObjectSigningAlg)
@@ -3145,7 +3140,7 @@ func normalizeHeadlessClientConfig(
return tokenAuthMethod, jwksURI, jwks, metadata
}
func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
func validateHeadlessClientInput(jwksURI string, jwks any, metadata map[string]any) error {
if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return nil
}
@@ -3164,14 +3159,14 @@ func validateHeadlessClientInput(jwksURI string, jwks interface{}, metadata map[
return nil
}
func normalizeClientTypeForHeadless(clientType string, metadata map[string]interface{}) string {
func normalizeClientTypeForHeadless(clientType string, metadata map[string]any) string {
if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
return "private"
}
return clientType
}
func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, error) {
if metadata == nil {
return nil, nil
}
@@ -3189,16 +3184,16 @@ func normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string
return metadata, nil
}
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) {
rawList, ok := rawClaims.([]interface{})
func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) {
rawList, ok := rawClaims.([]any)
if !ok {
if typedList, ok := rawClaims.([]map[string]interface{}); ok {
rawList = make([]interface{}, 0, len(typedList))
if typedList, ok := rawClaims.([]map[string]any); ok {
rawList = make([]any, 0, len(typedList))
for _, item := range typedList {
rawList = append(rawList, item)
}
} else if typedList, ok := rawClaims.([]map[string]any); ok {
rawList = make([]interface{}, 0, len(typedList))
rawList = make([]any, 0, len(typedList))
for _, item := range typedList {
rawList = append(rawList, item)
}
@@ -3211,13 +3206,11 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
seen := make(map[string]struct{}, len(rawList))
for _, item := range rawList {
record, ok := item.(map[string]interface{})
record, ok := item.(map[string]any)
if !ok {
if typedRecord, ok := item.(map[string]any); ok {
record = make(map[string]interface{}, len(typedRecord))
for key, value := range typedRecord {
record[key] = value
}
record = make(map[string]any, len(typedRecord))
maps.Copy(record, typedRecord)
} else {
return nil, errors.New("metadata.id_token_claims items must be objects")
}
@@ -3271,7 +3264,7 @@ func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, er
return normalized, nil
}
func readInterfaceString(value interface{}, fallback string) string {
func readInterfaceString(value any, fallback string) string {
if value == nil {
return fallback
}
@@ -3373,8 +3366,9 @@ func valueOr(ptr *string, fallback string) string {
return *ptr
}
//go:fix inline
func boolPtr(value bool) *bool {
return &value
return new(value)
}
func valueOrBool(ptr *bool, fallback bool) bool {
@@ -3398,17 +3392,13 @@ func derefSlice(ptr *[]string, fallback []string) []string {
return *ptr
}
func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} {
func mergeMetadata(current map[string]any, incoming *map[string]any) map[string]any {
if incoming == nil {
return current
}
merged := map[string]interface{}{}
for k, v := range current {
merged[k] = v
}
for k, v := range *incoming {
merged[k] = v
}
merged := map[string]any{}
maps.Copy(merged, current)
maps.Copy(merged, *incoming)
return merged
}
@@ -3433,9 +3423,7 @@ func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
}
if existing := c.Locals("audit_details_extra"); existing != nil {
if m, ok := existing.(map[string]any); ok {
for k, v := range extra {
m[k] = v
}
maps.Copy(m, extra)
c.Locals("audit_details_extra", m)
return
}
@@ -3494,7 +3482,7 @@ func resolveDevAuditClientID(logItem domain.AuditLog, details map[string]any) st
return resolvedID
}
func resolveStatusFromMetadata(metadata map[string]interface{}) string {
func resolveStatusFromMetadata(metadata map[string]any) string {
if metadata != nil {
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
return "inactive"