첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
30
baron-sso/backend/internal/domain/api_key.go
Normal file
30
baron-sso/backend/internal/domain/api_key.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiKey represents an internal API key for Machine-to-Machine communication.
|
||||
type ApiKey struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
ClientID string `gorm:"uniqueIndex;not null" json:"clientId"`
|
||||
ClientSecretHash string `gorm:"not null" json:"-"`
|
||||
Scopes string `json:"scopes"` // Space or comma separated
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if k.ID == "" {
|
||||
k.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
123
baron-sso/backend/internal/domain/auth_models.go
Normal file
123
baron-sso/backend/internal/domain/auth_models.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package domain
|
||||
|
||||
type EnchantedLinkInitRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
DrySend bool `json:"drySend,omitempty"`
|
||||
}
|
||||
|
||||
type EnchantedLinkInitResponse struct {
|
||||
LinkID string `json:"linkId"`
|
||||
PendingRef string `json:"pendingRef"`
|
||||
MaskedEmail string `json:"maskedEmail"`
|
||||
}
|
||||
|
||||
type EnchantedLinkPollRequest struct {
|
||||
PendingRef string `json:"pendingRef"`
|
||||
}
|
||||
|
||||
type EnchantedLinkPollResponse struct {
|
||||
SessionToken string `json:"sessionToken"` // JWT
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
}
|
||||
|
||||
type MagicLinkVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
||||
}
|
||||
|
||||
type QRInitResponse struct {
|
||||
QRCode string `json:"qrCode"` // Base64 or URL
|
||||
PendingRef string `json:"pendingRef"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
|
||||
// Signup Flow Models
|
||||
|
||||
type CheckEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type SendSignupCodeRequest struct {
|
||||
Target string `json:"target"` // Email or Phone
|
||||
Type string `json:"type"` // "email" or "phone"
|
||||
}
|
||||
|
||||
type VerifySignupCodeRequest struct {
|
||||
Target string `json:"target"` // Email or Phone
|
||||
Type string `json:"type"` // "email" or "phone"
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type SignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
Department string `json:"department"`
|
||||
Metadata JSONMap `json:"metadata,omitempty"`
|
||||
TermsAccepted bool `json:"termsAccepted"`
|
||||
}
|
||||
|
||||
// User Profile Models
|
||||
|
||||
type UserProfileResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"` // 추가
|
||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||
Department string `json:"department"`
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Department string `json:"department"`
|
||||
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||
type PasswordResetInitiateRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
DrySend bool `json:"drySend,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
||||
type PasswordResetCompleteRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다.
|
||||
type PasswordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type CheckLoginIDRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
CompanyCode string `json:"companyCode,omitempty"`
|
||||
}
|
||||
33
baron-sso/backend/internal/domain/client_consent.go
Normal file
33
baron-sso/backend/internal/domain/client_consent.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClientConsent struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ClientID string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"clientId"`
|
||||
Subject string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"subject"` // User UUID
|
||||
GrantedScopes pq.StringArray `gorm:"type:text[];not null" json:"grantedScopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// ClientConsentWithTenantInfo is a struct to hold joined data for API responses
|
||||
type ClientConsentWithTenantInfo struct {
|
||||
ClientConsent
|
||||
TenantID string `gorm:"column:tenant_id" json:"tenantId"`
|
||||
TenantName string `gorm:"column:tenant_name" json:"tenantName"`
|
||||
}
|
||||
|
||||
func (c *ClientConsent) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
21
baron-sso/backend/internal/domain/client_secret.go
Normal file
21
baron-sso/backend/internal/domain/client_secret.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClientSecret represents the stored client secret for OIDC clients.
|
||||
// Since Hydra only returns the secret once during creation, we store it here.
|
||||
type ClientSecret struct {
|
||||
ClientID string `gorm:"primaryKey;column:client_id"`
|
||||
ClientSecret string `gorm:"column:client_secret;not null"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientSecretRepository interface {
|
||||
Upsert(ctx context.Context, clientID, secret string) error
|
||||
GetByID(ctx context.Context, clientID string) (string, error)
|
||||
Delete(ctx context.Context, clientID string) error
|
||||
}
|
||||
60
baron-sso/backend/internal/domain/data_integrity.go
Normal file
60
baron-sso/backend/internal/domain/data_integrity.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type DataIntegrityStatus string
|
||||
|
||||
const (
|
||||
DataIntegrityStatusPass DataIntegrityStatus = "pass"
|
||||
DataIntegrityStatusWarning DataIntegrityStatus = "warning"
|
||||
DataIntegrityStatusFail DataIntegrityStatus = "fail"
|
||||
)
|
||||
|
||||
type DataIntegrityReport struct {
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
Summary DataIntegritySummary `json:"summary"`
|
||||
Sections []DataIntegritySection `json:"sections"`
|
||||
}
|
||||
|
||||
type DataIntegritySummary struct {
|
||||
TotalChecks int `json:"totalChecks"`
|
||||
Passed int `json:"passed"`
|
||||
Warnings int `json:"warnings"`
|
||||
Failures int64 `json:"failures"`
|
||||
}
|
||||
|
||||
type DataIntegritySection struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Checks []DataIntegrityCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type DataIntegrityCheck struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type OrphanUserLoginID struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
UserDeletedAt *time.Time `json:"userDeletedAt,omitempty"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||
TenantDeletedAt *time.Time `json:"tenantDeletedAt,omitempty"`
|
||||
FieldKey string `json:"fieldKey"`
|
||||
LoginID string `json:"loginId"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
type DeleteOrphanUserLoginIDsResult struct {
|
||||
DeletedCount int64 `json:"deletedCount"`
|
||||
Deleted []OrphanUserLoginID `json:"deleted"`
|
||||
SkippedIDs []string `json:"skippedIds"`
|
||||
}
|
||||
29
baron-sso/backend/internal/domain/developer_request.go
Normal file
29
baron-sso/backend/internal/domain/developer_request.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DeveloperRequestStatusPending = "pending"
|
||||
DeveloperRequestStatusApproved = "approved"
|
||||
DeveloperRequestStatusRejected = "rejected"
|
||||
DeveloperRequestStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// DeveloperRequest represents a user's application to become a developer.
|
||||
type DeveloperRequest struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"index;not null" json:"userId"` // Kratos User ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Organization string `json:"organization"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
Reason string `json:"reason"`
|
||||
Status string `gorm:"default:'pending';not null" json:"status"` // pending, approved, rejected, cancelled
|
||||
AdminNotes string `json:"adminNotes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
6
baron-sso/backend/internal/domain/email_models.go
Normal file
6
baron-sso/backend/internal/domain/email_models.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package domain
|
||||
|
||||
// EmailService defines the interface for sending emails.
|
||||
type EmailService interface {
|
||||
SendEmail(to, subject, body string) error
|
||||
}
|
||||
50
baron-sso/backend/internal/domain/federation_models.go
Normal file
50
baron-sso/backend/internal/domain/federation_models.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ProviderType defines the type of the identity provider.
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderTypeOIDC ProviderType = "oidc"
|
||||
ProviderTypeSAML ProviderType = "saml"
|
||||
)
|
||||
|
||||
// IdentityProviderConfig stores the configuration for an external Identity Provider.
|
||||
type IdentityProviderConfig struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
|
||||
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
|
||||
DisplayName string `gorm:"not null" json:"display_name"`
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
|
||||
// OIDC Specific Fields
|
||||
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
|
||||
OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
|
||||
OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
|
||||
// Scopes are space-separated
|
||||
Scopes *string `gorm:"null" json:"scopes,omitempty"`
|
||||
|
||||
// SAML Specific Fields
|
||||
MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"`
|
||||
MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"`
|
||||
EntityID *string `gorm:"null" json:"entity_id,omitempty"`
|
||||
AcsURL *string `gorm:"null" json:"acs_url,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if idc.ID == "" {
|
||||
idc.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
196
baron-sso/backend/internal/domain/hanmac_email.go
Normal file
196
baron-sso/backend/internal/domain/hanmac_email.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var hanmacSurnameRomanization = map[rune]string{
|
||||
'한': "han",
|
||||
'김': "kim",
|
||||
'이': "lee",
|
||||
'박': "park",
|
||||
'최': "choi",
|
||||
'정': "jung",
|
||||
'조': "cho",
|
||||
'강': "kang",
|
||||
'윤': "yoon",
|
||||
'장': "jang",
|
||||
'임': "lim",
|
||||
'림': "lim",
|
||||
'신': "shin",
|
||||
'오': "oh",
|
||||
'서': "seo",
|
||||
'권': "kwon",
|
||||
'황': "hwang",
|
||||
'안': "ahn",
|
||||
'송': "song",
|
||||
'전': "jeon",
|
||||
'홍': "hong",
|
||||
'유': "yoo",
|
||||
'고': "ko",
|
||||
'문': "moon",
|
||||
'양': "yang",
|
||||
'손': "son",
|
||||
'배': "bae",
|
||||
'백': "baek",
|
||||
'허': "heo",
|
||||
'남': "nam",
|
||||
'심': "sim",
|
||||
'노': "noh",
|
||||
'하': "ha",
|
||||
'곽': "kwak",
|
||||
'성': "sung",
|
||||
'차': "cha",
|
||||
'주': "joo",
|
||||
'우': "woo",
|
||||
'구': "koo",
|
||||
'민': "min",
|
||||
'류': "ryu",
|
||||
'나': "na",
|
||||
'진': "jin",
|
||||
'지': "ji",
|
||||
'엄': "um",
|
||||
'채': "chae",
|
||||
'원': "won",
|
||||
'천': "cheon",
|
||||
'방': "bang",
|
||||
'공': "gong",
|
||||
'현': "hyun",
|
||||
'함': "ham",
|
||||
'여': "yeo",
|
||||
'추': "choo",
|
||||
'도': "do",
|
||||
'소': "so",
|
||||
'석': "seok",
|
||||
'선': "sun",
|
||||
'설': "seol",
|
||||
'마': "ma",
|
||||
'길': "gil",
|
||||
'연': "yeon",
|
||||
'위': "wi",
|
||||
'표': "pyo",
|
||||
'명': "myung",
|
||||
'기': "ki",
|
||||
'반': "ban",
|
||||
'라': "ra",
|
||||
'왕': "wang",
|
||||
'금': "geum",
|
||||
'옥': "ok",
|
||||
'육': "yook",
|
||||
'인': "in",
|
||||
'맹': "maeng",
|
||||
'제': "je",
|
||||
'모': "mo",
|
||||
'탁': "tak",
|
||||
'국': "guk",
|
||||
'어': "eo",
|
||||
'은': "eun",
|
||||
'편': "pyeon",
|
||||
'용': "yong",
|
||||
}
|
||||
|
||||
var hanmacInitialRomanization = []string{
|
||||
"g", "g", "n", "d", "d", "r", "m", "b", "b", "s",
|
||||
"s", "y", "j", "j", "c", "k", "t", "p", "h",
|
||||
}
|
||||
|
||||
func SplitEmailDomain(email string) (string, string, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(email))
|
||||
before, after, ok := strings.Cut(normalized, "@")
|
||||
if !ok {
|
||||
return "", "", errors.New("email must contain @")
|
||||
}
|
||||
if strings.Count(normalized, "@") != 1 {
|
||||
return "", "", errors.New("email must contain one @")
|
||||
}
|
||||
localPart := strings.TrimSpace(before)
|
||||
domainPart := strings.TrimSpace(after)
|
||||
if domainPart == "" || !strings.Contains(domainPart, ".") {
|
||||
return "", "", errors.New("email domain is invalid")
|
||||
}
|
||||
return localPart, domainPart, nil
|
||||
}
|
||||
|
||||
func ExtractNormalizedEmailLocalPart(email string) (string, error) {
|
||||
localPart, _, err := SplitEmailDomain(email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if localPart == "" {
|
||||
return "", errors.New("email local-part is empty")
|
||||
}
|
||||
return localPart, nil
|
||||
}
|
||||
|
||||
func BuildKoreanNameEmailBase(name string) (string, bool, error) {
|
||||
runes := compactNameRunes(name)
|
||||
if len(runes) < 2 {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
surname, ok := hanmacSurnameRomanization[runes[0]]
|
||||
if !ok {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, r := range runes[1:] {
|
||||
initial, ok := romanizedHangulInitial(r)
|
||||
if !ok {
|
||||
return "", true, nil
|
||||
}
|
||||
builder.WriteString(initial)
|
||||
}
|
||||
builder.WriteString(surname)
|
||||
return builder.String(), false, nil
|
||||
}
|
||||
|
||||
func MatchesSuggestedNameRule(localPart string, base string) bool {
|
||||
localPart = strings.ToLower(strings.TrimSpace(localPart))
|
||||
base = strings.ToLower(strings.TrimSpace(base))
|
||||
if localPart == "" || base == "" {
|
||||
return false
|
||||
}
|
||||
if localPart == base {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(localPart, base) {
|
||||
return false
|
||||
}
|
||||
suffix := localPart[len(base):]
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compactNameRunes(name string) []rune {
|
||||
var runes []rune
|
||||
for _, r := range strings.TrimSpace(name) {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
runes = append(runes, r)
|
||||
}
|
||||
return runes
|
||||
}
|
||||
|
||||
func romanizedHangulInitial(r rune) (string, bool) {
|
||||
const hangulBase = 0xAC00
|
||||
const hangulEnd = 0xD7A3
|
||||
if r < hangulBase || r > hangulEnd {
|
||||
return "", false
|
||||
}
|
||||
index := int(r-hangulBase) / 588
|
||||
if index < 0 || index >= len(hanmacInitialRomanization) {
|
||||
return "", false
|
||||
}
|
||||
return hanmacInitialRomanization[index], true
|
||||
}
|
||||
76
baron-sso/backend/internal/domain/hanmac_email_test.go
Normal file
76
baron-sso/backend/internal/domain/hanmac_email_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitEmailDomainAllowsDomainOnlyImportInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantLocal string
|
||||
wantDomain string
|
||||
}{
|
||||
{name: "full address", email: " Han@SamanEng.com ", wantLocal: "han", wantDomain: "samaneng.com"},
|
||||
{name: "domain only", email: "@hanmaceng.co.kr", wantLocal: "", wantDomain: "hanmaceng.co.kr"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
local, domain, err := SplitEmailDomain(tt.email)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitEmailDomain() error = %v", err)
|
||||
}
|
||||
if local != tt.wantLocal || domain != tt.wantDomain {
|
||||
t.Fatalf("SplitEmailDomain() = (%q, %q), want (%q, %q)", local, domain, tt.wantLocal, tt.wantDomain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKoreanNameEmailBase(t *testing.T) {
|
||||
base, needsReview, err := BuildKoreanNameEmailBase("한치영")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||
}
|
||||
if needsReview {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() needsReview = true")
|
||||
}
|
||||
if base != "cyhan" {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() = %q, want %q", base, "cyhan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKoreanNameEmailBaseNeedsReviewForUnknownName(t *testing.T) {
|
||||
base, needsReview, err := BuildKoreanNameEmailBase("A치영")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() error = %v", err)
|
||||
}
|
||||
if base != "" {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() base = %q, want empty", base)
|
||||
}
|
||||
if !needsReview {
|
||||
t.Fatalf("BuildKoreanNameEmailBase() needsReview = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesSuggestedNameRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
localPart string
|
||||
base string
|
||||
want bool
|
||||
}{
|
||||
{localPart: "cyhan", base: "cyhan", want: true},
|
||||
{localPart: "cyhan1", base: "cyhan", want: true},
|
||||
{localPart: "cyhan20", base: "cyhan", want: true},
|
||||
{localPart: "hcy", base: "cyhan", want: false},
|
||||
{localPart: "han.cy", base: "cyhan", want: false},
|
||||
{localPart: "cyhan-a", base: "cyhan", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.localPart, func(t *testing.T) {
|
||||
if got := MatchesSuggestedNameRule(tt.localPart, tt.base); got != tt.want {
|
||||
t.Fatalf("MatchesSuggestedNameRule(%q, %q) = %v, want %v", tt.localPart, tt.base, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
30
baron-sso/backend/internal/domain/headless_jwks_cache.go
Normal file
30
baron-sso/backend/internal/domain/headless_jwks_cache.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type HeadlessJWKSParsedKey struct {
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Kty string `json:"kty,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"`
|
||||
}
|
||||
|
||||
// HeadlessJWKSCacheState는 headless login용 JWKS 캐시 상태와 최근 동기화 결과를 나타냅니다.
|
||||
type HeadlessJWKSCacheState struct {
|
||||
ClientID string `json:"clientId"`
|
||||
JWKSURI string `json:"jwksUri"`
|
||||
CachedAt *time.Time `json:"cachedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
||||
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||
LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"`
|
||||
LastRefreshStatus string `json:"lastRefreshStatus,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
||||
CachedKids []string `json:"cachedKids,omitempty"`
|
||||
ParsedKeys []HeadlessJWKSParsedKey `json:"parsedKeys,omitempty"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
RawJWKS string `json:"-"`
|
||||
}
|
||||
145
baron-sso/backend/internal/domain/hydra_models.go
Normal file
145
baron-sso/backend/internal/domain/hydra_models.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||
MetadataHeadlessJWKS = "headless_jwks"
|
||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||
MetadataIDTokenClaims = "id_token_claims"
|
||||
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
|
||||
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
|
||||
MetadataAutoLoginSupported = "auto_login_supported"
|
||||
MetadataAutoLoginURL = "auto_login_url"
|
||||
)
|
||||
|
||||
type HydraClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||
JWKS any `json:"jwks,omitempty"`
|
||||
BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"`
|
||||
BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||
// Headless login now supports jwksUri only.
|
||||
hasPublicKey := c.HeadlessJWKSURI() != ""
|
||||
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
|
||||
return hasPublicKey && isPrivateKeyJwt
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.TokenEndpointAuthMethod)
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessJWKSURI() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.JWKSUri)
|
||||
}
|
||||
|
||||
func (c *HydraClient) HeadlessJWKS() any {
|
||||
if c.Metadata != nil {
|
||||
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return c.JWKS
|
||||
}
|
||||
|
||||
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
||||
if !c.SupportsHeadlessLogin() {
|
||||
return false
|
||||
}
|
||||
if c.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
val, ok := c.Metadata[MetadataHeadlessLoginEnabled]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if b, ok := val.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutURI() string {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok {
|
||||
if value := strings.TrimSpace(raw); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.BackChannelLogoutURI)
|
||||
}
|
||||
|
||||
func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool {
|
||||
if c.Metadata != nil {
|
||||
if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if c.BackChannelLogoutSessionRequired != nil {
|
||||
return *c.BackChannelLogoutSessionRequired
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type HydraConsentRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RequestedScope []string `json:"requested_scope"`
|
||||
RequestedAudience []string `json:"requested_access_token_audience"`
|
||||
Skip bool `json:"skip"`
|
||||
Subject string `json:"subject"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraLoginRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Subject string `json:"subject"`
|
||||
Skip bool `json:"skip"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraConsentSession struct {
|
||||
ConsentRequestID string `json:"consent_request_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
GrantedScope []string `json:"grant_scope,omitempty"`
|
||||
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
|
||||
Remember bool `json:"remember"`
|
||||
RememberFor int `json:"remember_for,omitempty"`
|
||||
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
|
||||
RequestedAt *time.Time `json:"requested_at,omitempty"`
|
||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||
Client HydraClient `json:"client"`
|
||||
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
|
||||
}
|
||||
182
baron-sso/backend/internal/domain/hydra_models_test.go
Normal file
182
baron-sso/backend/internal/domain/hydra_models_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}
|
||||
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected metadata-backed headless login client")
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected metadata-backed headless login enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline jwks without jwks uri does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA",
|
||||
}},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled without jwks uri")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "none",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled when prerequisites are missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": "true",
|
||||
},
|
||||
}
|
||||
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login client")
|
||||
}
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled for non-bool metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHydraClientHeadlessMetadataAccessors(t *testing.T) {
|
||||
t.Run("metadata values override inline values", func(t *testing.T) {
|
||||
metadataJWKS := map[string]any{"keys": []any{"metadata-key"}}
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "client_secret_post",
|
||||
JWKSUri: "https://inline.example.com/jwks.json",
|
||||
JWKS: map[string]any{"keys": []any{"inline-key"}},
|
||||
Metadata: map[string]any{
|
||||
MetadataHeadlessTokenEndpointAuthMethod: " private_key_jwt ",
|
||||
MetadataHeadlessJWKSURI: " https://metadata.example.com/jwks.json ",
|
||||
MetadataHeadlessJWKS: metadataJWKS,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" {
|
||||
t.Fatalf("unexpected auth method: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKSURI(); got != "https://metadata.example.com/jwks.json" {
|
||||
t.Fatalf("unexpected jwks uri: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, metadataJWKS) {
|
||||
t.Fatalf("unexpected jwks value: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) {
|
||||
inlineJWKS := map[string]any{"keys": []any{"inline-key"}}
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: " private_key_jwt ",
|
||||
JWKSUri: " https://inline.example.com/jwks.json ",
|
||||
JWKS: inlineJWKS,
|
||||
Metadata: map[string]any{
|
||||
MetadataHeadlessTokenEndpointAuthMethod: " ",
|
||||
MetadataHeadlessJWKSURI: " ",
|
||||
MetadataHeadlessJWKS: nil,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.HeadlessTokenEndpointAuthMethod(); got != "private_key_jwt" {
|
||||
t.Fatalf("unexpected auth method: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKSURI(); got != "https://inline.example.com/jwks.json" {
|
||||
t.Fatalf("unexpected jwks uri: %q", got)
|
||||
}
|
||||
if got := client.HeadlessJWKS(); !reflect.DeepEqual(got, inlineJWKS) {
|
||||
t.Fatalf("unexpected jwks value: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHydraClientBackchannelLogoutAccessors(t *testing.T) {
|
||||
t.Run("metadata values override inline values", func(t *testing.T) {
|
||||
inlineRequired := false
|
||||
client := HydraClient{
|
||||
BackChannelLogoutURI: "https://inline.example.com/logout",
|
||||
BackChannelLogoutSessionRequired: &inlineRequired,
|
||||
Metadata: map[string]any{
|
||||
MetadataBackChannelLogoutURI: " https://metadata.example.com/logout ",
|
||||
MetadataBackChannelLogoutSessionRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "https://metadata.example.com/logout" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if !client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected metadata session_required value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank or missing metadata values fall back to inline values", func(t *testing.T) {
|
||||
inlineRequired := true
|
||||
client := HydraClient{
|
||||
BackChannelLogoutURI: " https://inline.example.com/logout ",
|
||||
BackChannelLogoutSessionRequired: &inlineRequired,
|
||||
Metadata: map[string]any{
|
||||
MetadataBackChannelLogoutURI: " ",
|
||||
MetadataBackChannelLogoutSessionRequired: "true",
|
||||
},
|
||||
}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "https://inline.example.com/logout" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if !client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected inline session_required value")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing session required defaults to false", func(t *testing.T) {
|
||||
client := HydraClient{}
|
||||
|
||||
if got := client.BackchannelLogoutURI(); got != "" {
|
||||
t.Fatalf("unexpected logout uri: %q", got)
|
||||
}
|
||||
if client.BackchannelLogoutSessionRequiredValue() {
|
||||
t.Fatalf("expected default session_required false")
|
||||
}
|
||||
})
|
||||
}
|
||||
19
baron-sso/backend/internal/domain/identity_cache.go
Normal file
19
baron-sso/backend/internal/domain/identity_cache.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type IdentityCacheStatus struct {
|
||||
Status string `json:"status"`
|
||||
RedisReady bool `json:"redisReady"`
|
||||
ObservedCount int64 `json:"observedCount"`
|
||||
KeyCount int64 `json:"keyCount"`
|
||||
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type IdentityCacheFlushResult struct {
|
||||
Status string `json:"status"`
|
||||
FlushedKeys int64 `json:"flushedKeys"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
92
baron-sso/backend/internal/domain/idp_models.go
Normal file
92
baron-sso/backend/internal/domain/idp_models.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다.
|
||||
var ErrNotSupported = errors.New("idp: not supported")
|
||||
|
||||
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||
type BrokerUser struct {
|
||||
ID string `json:"id" required:"true"`
|
||||
Email string `json:"email" required:"true"`
|
||||
LoginID string `json:"login_id"`
|
||||
CustomLoginIDs []string `json:"custom_login_ids"` // [New] 다중 로그인 ID
|
||||
Name string `json:"name"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
// Attributes stores custom user attributes.
|
||||
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
|
||||
Attributes map[string]any `json:"attributes" required_keys:"grade,department"`
|
||||
}
|
||||
|
||||
// IDPMetadata represents the schema capabilities of an Identity Provider.
|
||||
type IDPMetadata struct {
|
||||
// SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports.
|
||||
// For custom attributes, use the key name directly (e.g., "grade").
|
||||
SupportedFields []string
|
||||
}
|
||||
|
||||
// PasswordPolicy는 비밀번호 정책 정보를 표현합니다.
|
||||
type PasswordPolicy struct {
|
||||
MinLength int
|
||||
Lowercase bool
|
||||
Uppercase bool
|
||||
Number bool
|
||||
NonAlphanumeric bool
|
||||
MinCharacterTypes int
|
||||
}
|
||||
|
||||
// Token represents a session or refresh token.
|
||||
type Token struct {
|
||||
JWT string
|
||||
Expiration time.Time
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// AuthInfo contains authentication information after a successful login.
|
||||
type AuthInfo struct {
|
||||
SessionToken *Token
|
||||
RefreshToken *Token
|
||||
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
|
||||
Subject string
|
||||
SetCookies []*http.Cookie
|
||||
}
|
||||
|
||||
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
|
||||
type LinkLoginInit struct {
|
||||
FlowID string
|
||||
ExpiresAt time.Time
|
||||
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
||||
Mode string
|
||||
// LoginID는 IDP에 실제 전달된 식별자입니다.
|
||||
LoginID string
|
||||
}
|
||||
|
||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||
type IdentityProvider interface {
|
||||
Name() string
|
||||
// GetMetadata returns the schema support information for this IDP.
|
||||
// This is used for startup-time validation.
|
||||
GetMetadata() (*IDPMetadata, error)
|
||||
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
|
||||
CreateUser(user *BrokerUser, password string) (string, error)
|
||||
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
|
||||
SignIn(loginID, password string) (*AuthInfo, error)
|
||||
// UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다.
|
||||
UserExists(loginID string) (bool, error)
|
||||
// IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다.
|
||||
IssueSession(loginID string) (*AuthInfo, error)
|
||||
// InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다.
|
||||
InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error)
|
||||
// VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다.
|
||||
VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error)
|
||||
// GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다.
|
||||
GetPasswordPolicy() (*PasswordPolicy, error)
|
||||
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
||||
}
|
||||
42
baron-sso/backend/internal/domain/json_map.go
Normal file
42
baron-sso/backend/internal/domain/json_map.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
|
||||
type JSONMap map[string]any
|
||||
|
||||
// Value implements the driver.Valuer interface
|
||||
func (m JSONMap) Value() (driver.Value, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ba, err := json.Marshal(m)
|
||||
return string(ba), err
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface
|
||||
func (m *JSONMap) Scan(value any) error {
|
||||
if value == nil {
|
||||
*m = make(JSONMap)
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
|
||||
}
|
||||
|
||||
result := make(JSONMap)
|
||||
err := json.Unmarshal(bytes, &result)
|
||||
*m = result
|
||||
return err
|
||||
}
|
||||
93
baron-sso/backend/internal/domain/json_map_test.go
Normal file
93
baron-sso/backend/internal/domain/json_map_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONMapValue(t *testing.T) {
|
||||
t.Run("nil map returns nil database value", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
value, err := payload.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if value != nil {
|
||||
t.Fatalf("expected nil value, got %v", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map marshals to JSON string", func(t *testing.T) {
|
||||
payload := JSONMap{"enabled": true, "name": "baron"}
|
||||
|
||||
value, err := payload.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string value, got %T", value)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatalf("value should be valid json: %v", err)
|
||||
}
|
||||
if decoded["enabled"] != true || decoded["name"] != "baron" {
|
||||
t.Fatalf("unexpected decoded value: %#v", decoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONMapScan(t *testing.T) {
|
||||
t.Run("nil value becomes empty map", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload == nil || len(payload) != 0 {
|
||||
t.Fatalf("expected empty map, got %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("byte slice value decodes JSON", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan([]byte(`{"count":2,"name":"baron"}`)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["name"] != "baron" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string value decodes JSON", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(`{"active":true}`); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload["active"] != true {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported value type returns error", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(42); err == nil {
|
||||
t.Fatalf("expected unsupported type error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||
var payload JSONMap
|
||||
|
||||
if err := payload.Scan(`{invalid`); err == nil {
|
||||
t.Fatalf("expected invalid JSON error")
|
||||
}
|
||||
})
|
||||
}
|
||||
48
baron-sso/backend/internal/domain/keto_outbox.go
Normal file
48
baron-sso/backend/internal/domain/keto_outbox.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// KetoOutbox status
|
||||
const (
|
||||
KetoOutboxStatusPending = "pending"
|
||||
KetoOutboxStatusProcessed = "processed"
|
||||
KetoOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// KetoOutbox action
|
||||
const (
|
||||
KetoOutboxActionCreate = "CREATE"
|
||||
KetoOutboxActionDelete = "DELETE"
|
||||
)
|
||||
|
||||
// KetoOutbox represents a Keto relationship tuple update event.
|
||||
type KetoOutbox struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Namespace string `gorm:"not null" json:"namespace"`
|
||||
Object string `gorm:"not null" json:"object"`
|
||||
Relation string `gorm:"not null" json:"relation"`
|
||||
Subject string `gorm:"not null" json:"subject"` // format: "User:ID" or "Tenant:ID#members"
|
||||
Action string `gorm:"not null" json:"action"` // CREATE, DELETE
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) TableName() string {
|
||||
return "keto_outbox"
|
||||
}
|
||||
|
||||
func (ko *KetoOutbox) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ko.ID == "" {
|
||||
ko.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
357
baron-sso/backend/internal/domain/model_hooks_test.go
Normal file
357
baron-sso/backend/internal/domain/model_hooks_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func requireGeneratedUUID(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
|
||||
if value == "" {
|
||||
t.Fatalf("expected generated uuid")
|
||||
}
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
t.Fatalf("expected valid uuid, got %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCreateGeneratesMissingIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "api key",
|
||||
run: func(t *testing.T) {
|
||||
model := ApiKey{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client consent",
|
||||
run: func(t *testing.T) {
|
||||
model := ClientConsent{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity provider config",
|
||||
run: func(t *testing.T) {
|
||||
model := IdentityProviderConfig{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keto outbox",
|
||||
run: func(t *testing.T) {
|
||||
model := KetoOutbox{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
run: func(t *testing.T) {
|
||||
model := Tenant{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant domain",
|
||||
run: func(t *testing.T) {
|
||||
model := TenantDomain{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
run: func(t *testing.T) {
|
||||
model := User{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user group",
|
||||
run: func(t *testing.T) {
|
||||
model := UserGroup{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worksmobile resource mapping",
|
||||
run: func(t *testing.T) {
|
||||
model := WorksmobileResourceMapping{}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, model.ID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, tc.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCreatePreservesExistingIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "api key",
|
||||
run: func(t *testing.T) {
|
||||
model := ApiKey{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client consent",
|
||||
run: func(t *testing.T) {
|
||||
model := ClientConsent{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "identity provider config",
|
||||
run: func(t *testing.T) {
|
||||
model := IdentityProviderConfig{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keto outbox",
|
||||
run: func(t *testing.T) {
|
||||
model := KetoOutbox{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
run: func(t *testing.T) {
|
||||
model := Tenant{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tenant domain",
|
||||
run: func(t *testing.T) {
|
||||
model := TenantDomain{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
run: func(t *testing.T) {
|
||||
model := User{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user group",
|
||||
run: func(t *testing.T) {
|
||||
model := UserGroup{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worksmobile resource mapping",
|
||||
run: func(t *testing.T) {
|
||||
model := WorksmobileResourceMapping{ID: "existing-id"}
|
||||
if err := model.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if model.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, tc.run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
expected string
|
||||
}{
|
||||
{name: "keto outbox", got: (&KetoOutbox{}).TableName(), expected: "keto_outbox"},
|
||||
{name: "rp usage event", got: (&RPUsageEvent{}).TableName(), expected: "rp_usage_outbox"},
|
||||
{name: "rp user metadata", got: (RPUserMetadata{}).TableName(), expected: "rp_user_metadata"},
|
||||
{name: "user group", got: (&UserGroup{}).TableName(), expected: "user_groups"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.expected {
|
||||
t.Fatalf("unexpected table name: got=%s expected=%s", tc.got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantIsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
expected bool
|
||||
}{
|
||||
{name: "active", status: TenantStatusActive, expected: true},
|
||||
{name: "pending", status: TenantStatusPending, expected: false},
|
||||
{name: "suspended", status: TenantStatusSuspended, expected: false},
|
||||
{name: "deleted", status: TenantStatusDeleted, expected: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tenant := Tenant{Status: tc.status}
|
||||
if got := tenant.IsActive(); got != tc.expected {
|
||||
t.Fatalf("unexpected active state: got=%v expected=%v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPUsageEventBeforeCreateDefaults(t *testing.T) {
|
||||
event := RPUsageEvent{}
|
||||
|
||||
if err := event.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, event.ID)
|
||||
if event.Status != RPUsageOutboxStatusPending {
|
||||
t.Fatalf("unexpected status: %s", event.Status)
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
t.Fatalf("expected occurred_at default")
|
||||
}
|
||||
if event.Payload == nil {
|
||||
t.Fatalf("expected empty payload default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPUsageEventBeforeCreatePreservesExplicitValues(t *testing.T) {
|
||||
occurredAt := time.Date(2026, 5, 29, 1, 2, 3, 0, time.UTC)
|
||||
event := RPUsageEvent{
|
||||
ID: "existing-id",
|
||||
Status: RPUsageOutboxStatusProcessing,
|
||||
OccurredAt: occurredAt,
|
||||
Payload: JSONMap{"source": "test"},
|
||||
}
|
||||
|
||||
if err := event.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if event.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
if event.Status != RPUsageOutboxStatusProcessing {
|
||||
t.Fatalf("expected status to be preserved")
|
||||
}
|
||||
if !event.OccurredAt.Equal(occurredAt) {
|
||||
t.Fatalf("expected occurred_at to be preserved")
|
||||
}
|
||||
if event.Payload["source"] != "test" {
|
||||
t.Fatalf("expected payload to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxBeforeCreateDefaults(t *testing.T) {
|
||||
outbox := WorksmobileOutbox{}
|
||||
|
||||
if err := outbox.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireGeneratedUUID(t, outbox.ID)
|
||||
if outbox.Status != WorksmobileOutboxStatusPending {
|
||||
t.Fatalf("unexpected status: %s", outbox.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorksmobileOutboxBeforeCreatePreservesExplicitValues(t *testing.T) {
|
||||
outbox := WorksmobileOutbox{
|
||||
ID: "existing-id",
|
||||
Status: WorksmobileOutboxStatusProcessing,
|
||||
}
|
||||
|
||||
if err := outbox.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if outbox.ID != "existing-id" {
|
||||
t.Fatalf("expected existing id to be preserved")
|
||||
}
|
||||
if outbox.Status != WorksmobileOutboxStatusProcessing {
|
||||
t.Fatalf("expected status to be preserved")
|
||||
}
|
||||
}
|
||||
48
baron-sso/backend/internal/domain/models.go
Normal file
48
baron-sso/backend/internal/domain/models.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditLog represents a single audit event
|
||||
type AuditLog struct {
|
||||
EventID string `json:"event_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
||||
Status string `json:"status"` // e.g., "success", "failure"
|
||||
AuthMethod string `json:"auth_method,omitempty"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Details string `json:"details,omitempty"` // JSON string or simple text
|
||||
}
|
||||
|
||||
// AuditRepository defines interface for storing logs
|
||||
type AuditRepository interface {
|
||||
Create(log *AuditLog) error
|
||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error)
|
||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||
CountEventsSince(ctx context.Context, since time.Time) (int64, error)
|
||||
CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||
CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error)
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
type AuditCursor struct {
|
||||
Timestamp time.Time
|
||||
EventID string
|
||||
}
|
||||
|
||||
// RedisRepository defines interface for KV storage (Redis)
|
||||
type RedisRepository interface {
|
||||
Set(key string, value string, expiration time.Duration) error
|
||||
Get(key string) (string, error)
|
||||
Delete(key string) error
|
||||
StoreVerificationCode(phone, code string) error
|
||||
GetVerificationCode(phone string) (string, error)
|
||||
DeleteVerificationCode(phone string) error
|
||||
}
|
||||
31
baron-sso/backend/internal/domain/oathkeeper_models.go
Normal file
31
baron-sso/backend/internal/domain/oathkeeper_models.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OathkeeperAccessLog struct {
|
||||
Timestamp time.Time
|
||||
RequestID string
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
LatencyMs int
|
||||
ClientID string
|
||||
RP string
|
||||
Action string
|
||||
Target string
|
||||
Subject string
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
Decision string
|
||||
TraceID string
|
||||
SpanID string
|
||||
Raw string
|
||||
}
|
||||
|
||||
type OathkeeperLogRepository interface {
|
||||
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
19
baron-sso/backend/internal/domain/relying_party.go
Normal file
19
baron-sso/backend/internal/domain/relying_party.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RelyingParty represents an OAuth2 Client owner by a Tenant.
|
||||
// It maps 1:1 to a Hydra Client.
|
||||
type RelyingParty struct {
|
||||
ClientID string `json:"clientId"` // Maps to Hydra Client ID
|
||||
TenantID string `json:"tenantId"`
|
||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// DeletedAt removed as it's not a DB model anymore
|
||||
}
|
||||
|
||||
// TableName removed
|
||||
101
baron-sso/backend/internal/domain/rp_usage_event.go
Normal file
101
baron-sso/backend/internal/domain/rp_usage_event.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageOutboxStatusPending = "pending"
|
||||
RPUsageOutboxStatusProcessing = "processing"
|
||||
RPUsageOutboxStatusProcessed = "processed"
|
||||
RPUsageOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageEventTypeAuthorizationGranted = "rp_usage.authorization_granted"
|
||||
RPUsageEventTypeAuthorizationRevoked = "rp_usage.authorization_revoked"
|
||||
)
|
||||
|
||||
const (
|
||||
RPUsageTenantTypeCompany = TenantTypeCompany
|
||||
RPUsageTenantTypeOrganization = TenantTypeOrganization
|
||||
)
|
||||
|
||||
type RPUsageEvent struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
EventType string `gorm:"not null;index:idx_rp_usage_outbox_event" json:"eventType"`
|
||||
Subject string `gorm:"not null;index:idx_rp_usage_outbox_subject" json:"subject"`
|
||||
TenantID string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantId,omitempty"`
|
||||
TenantType string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantType,omitempty"`
|
||||
ClientID string `gorm:"not null;index:idx_rp_usage_outbox_client" json:"clientId"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
SessionID string `gorm:"index" json:"sessionId,omitempty"`
|
||||
Scopes pq.StringArray `gorm:"type:text[]" json:"scopes,omitempty"`
|
||||
Source string `gorm:"not null;index" json:"source"`
|
||||
CorrelationID string `gorm:"index" json:"correlationId,omitempty"`
|
||||
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
|
||||
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (e *RPUsageEvent) TableName() string {
|
||||
return "rp_usage_outbox"
|
||||
}
|
||||
|
||||
func (e *RPUsageEvent) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.NewString()
|
||||
}
|
||||
if e.Status == "" {
|
||||
e.Status = RPUsageOutboxStatusPending
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
e.OccurredAt = time.Now()
|
||||
}
|
||||
if e.Payload == nil {
|
||||
e.Payload = JSONMap{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RPUsageEventSink interface {
|
||||
EmitRPUsageEvent(ctx context.Context, event RPUsageEvent) error
|
||||
}
|
||||
|
||||
type RPUsageProjectionRepository interface {
|
||||
CreateRPUsageEvent(ctx context.Context, event RPUsageEvent) error
|
||||
}
|
||||
|
||||
type RPUsageDailyMetric struct {
|
||||
Date string `json:"date"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantType string `json:"tenantType"`
|
||||
TenantName string `json:"tenantName,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
LoginRequests uint64 `json:"loginRequests"`
|
||||
OtherRequests uint64 `json:"otherRequests"`
|
||||
UniqueSubjects uint64 `json:"uniqueSubjects"`
|
||||
}
|
||||
|
||||
type RPUsageQuery struct {
|
||||
Days int
|
||||
Period string
|
||||
TenantID string
|
||||
}
|
||||
|
||||
type RPUsageQueryRepository interface {
|
||||
FindRPUsage(ctx context.Context, query RPUsageQuery) ([]RPUsageDailyMetric, error)
|
||||
}
|
||||
16
baron-sso/backend/internal/domain/rp_user_metadata.go
Normal file
16
baron-sso/backend/internal/domain/rp_user_metadata.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type RPUserMetadata struct {
|
||||
ClientID string `gorm:"column:client_id;primaryKey" json:"clientId"`
|
||||
UserID string `gorm:"column:user_id;type:uuid;primaryKey" json:"userId"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (RPUserMetadata) TableName() string {
|
||||
return "rp_user_metadata"
|
||||
}
|
||||
53
baron-sso/backend/internal/domain/shared_link.go
Normal file
53
baron-sso/backend/internal/domain/shared_link.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SharedLink struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"`
|
||||
Token string `gorm:"uniqueIndex;not null" json:"token"`
|
||||
Name string `gorm:"not null" json:"name"` // 링크 식별을 위한 이름 (예: "24년 상반기 채용공고용")
|
||||
Description string `json:"description"`
|
||||
AccessLevel string `gorm:"default:'READ_ONLY'" json:"accessLevel"`
|
||||
IsActive bool `gorm:"default:true" json:"isActive"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
Password string `json:"-"` // 필요 시 비밀번호 (선택 사항)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relation
|
||||
Tenant Tenant `gorm:"foreignKey:TenantID" json:"-"`
|
||||
}
|
||||
|
||||
func (s *SharedLink) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if s.ID == "" {
|
||||
s.ID = uuid.NewString()
|
||||
}
|
||||
if s.Token == "" {
|
||||
// 32바이트(64자)의 강력한 난수 토큰 생성
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Token = hex.EncodeToString(b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *SharedLink) IsValid() bool {
|
||||
if !s.IsActive {
|
||||
return false
|
||||
}
|
||||
if s.ExpiresAt != nil && s.ExpiresAt.Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
80
baron-sso/backend/internal/domain/shared_link_test.go
Normal file
80
baron-sso/backend/internal/domain/shared_link_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSharedLinkBeforeCreate(t *testing.T) {
|
||||
t.Run("generates id and token when missing", func(t *testing.T) {
|
||||
link := SharedLink{}
|
||||
|
||||
if err := link.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if link.ID == "" {
|
||||
t.Fatalf("expected generated id")
|
||||
}
|
||||
if len(link.Token) != 64 {
|
||||
t.Fatalf("expected 64-character token, got %q", link.Token)
|
||||
}
|
||||
if _, err := hex.DecodeString(link.Token); err != nil {
|
||||
t.Fatalf("expected hex token: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing id and token", func(t *testing.T) {
|
||||
link := SharedLink{
|
||||
ID: "existing-id",
|
||||
Token: "existing-token",
|
||||
}
|
||||
|
||||
if err := link.BeforeCreate(nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if link.ID != "existing-id" || link.Token != "existing-token" {
|
||||
t.Fatalf("expected existing fields to be preserved: %#v", link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSharedLinkIsValid(t *testing.T) {
|
||||
future := time.Now().Add(time.Hour)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
link SharedLink
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "active link without expiration is valid",
|
||||
link: SharedLink{IsActive: true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "active link with future expiration is valid",
|
||||
link: SharedLink{IsActive: true, ExpiresAt: &future},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "inactive link is invalid",
|
||||
link: SharedLink{IsActive: false, ExpiresAt: &future},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired link is invalid",
|
||||
link: SharedLink{IsActive: true, ExpiresAt: &past},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.link.IsValid(); got != tc.expected {
|
||||
t.Fatalf("unexpected validity: got=%v expected=%v", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
baron-sso/backend/internal/domain/sms_models.go
Normal file
42
baron-sso/backend/internal/domain/sms_models.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
// SmsService defines the interface for sending SMS messages.
|
||||
type SmsService interface {
|
||||
SendSms(to, content string) error
|
||||
}
|
||||
|
||||
// NaverSmsRequest represents the request body for the Naver Cloud SMS API.
|
||||
type NaverSmsRequest struct {
|
||||
Type string `json:"type"`
|
||||
ContentType string `json:"contentType"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Messages []SmsMessage `json:"messages"`
|
||||
}
|
||||
|
||||
// SmsMessage represents a single message to be sent.
|
||||
type SmsMessage struct {
|
||||
To string `json:"to"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// NaverSmsResponse represents the response from the Naver Cloud SMS API.
|
||||
type NaverSmsResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RequestTime string `json:"requestTime"`
|
||||
StatusCode string `json:"statusCode"`
|
||||
StatusName string `json:"statusName"`
|
||||
}
|
||||
|
||||
// SmsRequest represents the request body for sending an SMS.
|
||||
type SmsRequest struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
}
|
||||
|
||||
// SmsVerifyRequest represents the request body for verifying an SMS code.
|
||||
type SmsVerifyRequest struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
11
baron-sso/backend/internal/domain/system_setting.go
Normal file
11
baron-sso/backend/internal/domain/system_setting.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemSetting stores small global configuration documents.
|
||||
type SystemSetting struct {
|
||||
Key string `gorm:"primaryKey;size:128" json:"key"`
|
||||
Value JSONMap `gorm:"type:jsonb" json:"value"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
53
baron-sso/backend/internal/domain/tenant.go
Normal file
53
baron-sso/backend/internal/domain/tenant.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tenant statuses
|
||||
const (
|
||||
TenantStatusPending = "pending"
|
||||
TenantStatusActive = "active"
|
||||
TenantStatusSuspended = "suspended"
|
||||
TenantStatusDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Tenant types
|
||||
const (
|
||||
TenantTypePersonal = "PERSONAL"
|
||||
TenantTypeCompany = "COMPANY"
|
||||
TenantTypeCompanyGroup = "COMPANY_GROUP"
|
||||
TenantTypeOrganization = "ORGANIZATION"
|
||||
TenantTypeUserGroup = "USER_GROUP"
|
||||
)
|
||||
|
||||
// Tenant represents a tenant model stored in PostgreSQL.
|
||||
type Tenant struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `gorm:"default:'pending'" json:"status"`
|
||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (t *Tenant) IsActive() bool {
|
||||
return t.Status == TenantStatusActive
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
27
baron-sso/backend/internal/domain/tenant_domain.go
Normal file
27
baron-sso/backend/internal/domain/tenant_domain.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantDomain represents a domain associated with a tenant for auto-assignment.
|
||||
type TenantDomain struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"tenantId"`
|
||||
Domain string `gorm:"not null;uniqueIndex:idx_tenant_domains_tenant_domain" json:"domain"` // e.g. "example.com"
|
||||
Verified bool `gorm:"default:false" json:"verified"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present.
|
||||
func (td *TenantDomain) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if td.ID == "" {
|
||||
td.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
247
baron-sso/backend/internal/domain/user.go
Normal file
247
baron-sso/backend/internal/domain/user.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User roles
|
||||
const (
|
||||
RoleSuperAdmin = "super_admin" // 시스템 전역 관리자
|
||||
RoleUser = "user" // 일반 사용자
|
||||
)
|
||||
|
||||
// User statuses
|
||||
const (
|
||||
UserStatusActive = "active"
|
||||
UserStatusSuspended = "suspended"
|
||||
UserStatusTemporaryLeave = "temporary_leave"
|
||||
UserStatusPreboarding = "preboarding"
|
||||
UserStatusBaronGuest = "baron_guest"
|
||||
UserStatusExtendedLeave = "extended_leave"
|
||||
UserStatusArchived = "archived"
|
||||
)
|
||||
|
||||
func NormalizeUserStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "", UserStatusActive:
|
||||
return UserStatusActive
|
||||
case "blocked", UserStatusSuspended:
|
||||
return UserStatusSuspended
|
||||
case "inactive", UserStatusPreboarding:
|
||||
return UserStatusPreboarding
|
||||
case "leave_of_absence", UserStatusTemporaryLeave:
|
||||
return UserStatusTemporaryLeave
|
||||
case "baron_only", UserStatusBaronGuest:
|
||||
return UserStatusBaronGuest
|
||||
case UserStatusExtendedLeave:
|
||||
return UserStatusExtendedLeave
|
||||
case UserStatusArchived:
|
||||
return UserStatusArchived
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
}
|
||||
|
||||
func IsBaronActivityAllowedStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusBaronGuest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsOrgVisibleUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksProvisionedUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusActive, UserStatusTemporaryLeave, UserStatusSuspended:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsWorksDeprovisionUserStatus(status string) bool {
|
||||
switch NormalizeUserStatus(status) {
|
||||
case UserStatusBaronGuest, UserStatusExtendedLeave, UserStatusArchived:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeRole maps legacy/synonym role values to canonical role keys.
|
||||
func NormalizeRole(role string) string {
|
||||
if normalized, ok := NormalizeRoleAlias(role); ok {
|
||||
return normalized
|
||||
}
|
||||
return RoleUser
|
||||
}
|
||||
|
||||
func NormalizeRoleAlias(role string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||
switch normalized {
|
||||
case RoleSuperAdmin, RoleUser:
|
||||
return normalized, true
|
||||
case "tenant_admin", "rp_admin", "tenant_member", "member", "admin", "tenantadmin", "tenant-admin":
|
||||
return RoleUser, true
|
||||
case "superadmin", "super-admin":
|
||||
return RoleSuperAdmin, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// User represents the user model stored in PostgreSQL
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash *string `gorm:"column:password_hash" json:"-"`
|
||||
Name string `gorm:"column:name;not null" json:"name"`
|
||||
Phone string `gorm:"column:phone" json:"phone"`
|
||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, user
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
CompanyCode string `gorm:"-" json:"companyCode,omitempty"`
|
||||
CompanyCodes pq.StringArray `gorm:"-" json:"companyCodes,omitempty"`
|
||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `gorm:"column:department" json:"department"`
|
||||
Grade string `gorm:"column:grade" json:"grade"` // 직급 (예: 수석, 책임, 선임)
|
||||
Position string `gorm:"column:position" json:"position"` // 직책 (예: 팀장, 센터장)
|
||||
JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"column:status;default:'active'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
// Multiple identifiers support
|
||||
UserLoginIDs []UserLoginID `gorm:"foreignKey:UserID" json:"userLoginIds,omitempty"`
|
||||
}
|
||||
|
||||
// UserLoginID represents multiple custom identifiers for a user
|
||||
type UserLoginID struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
UserID string `gorm:"type:uuid;not null;index" json:"userId"`
|
||||
TenantID string `gorm:"type:uuid;not null;index" json:"tenantId"` // 발급 테넌트
|
||||
FieldKey string `gorm:"not null" json:"fieldKey"` // 스키마 필드 키 (예: emp_id)
|
||||
LoginID string `gorm:"uniqueIndex;not null" json:"loginId"` // 실제 값 (예: EMP001)
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateLoginID checks if the loginID violates any collision, length, or security rules.
|
||||
func ValidateLoginID(loginID string, emails []string, phone string) error {
|
||||
loginID = strings.TrimSpace(loginID)
|
||||
if loginID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(loginID) < 4 || len(loginID) > 30 {
|
||||
return fmt.Errorf("ID must be between 4 and 30 characters")
|
||||
}
|
||||
|
||||
if strings.Contains(loginID, "@") {
|
||||
return fmt.Errorf("ID cannot be an email format")
|
||||
}
|
||||
|
||||
for _, email := range emails {
|
||||
if email != "" && strings.EqualFold(loginID, email) {
|
||||
return fmt.Errorf("ID cannot be the same as the email address")
|
||||
}
|
||||
}
|
||||
|
||||
if phone != "" {
|
||||
normalizedPhone := NormalizePhoneNumber(phone)
|
||||
|
||||
if loginID == phone || loginID == normalizedPhone {
|
||||
return fmt.Errorf("ID cannot be the same as the phone number")
|
||||
}
|
||||
}
|
||||
|
||||
isPureNumber := true
|
||||
loginIDDigits := strings.ReplaceAll(loginID, "-", "")
|
||||
loginIDDigits = strings.ReplaceAll(loginIDDigits, " ", "")
|
||||
for _, c := range loginIDDigits {
|
||||
if (c < '0' || c > '9') && c != '+' {
|
||||
isPureNumber = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isPureNumber && len(loginIDDigits) >= 10 && len(loginIDDigits) <= 12 {
|
||||
if strings.HasPrefix(loginIDDigits, "010") || strings.HasPrefix(loginIDDigits, "82") || strings.HasPrefix(loginIDDigits, "+82") {
|
||||
return fmt.Errorf("ID cannot be a phone number format")
|
||||
}
|
||||
}
|
||||
|
||||
reserved := []string{"admin", "system", "root", "master", "superuser", "guest", "operator"}
|
||||
lowerID := strings.ToLower(loginID)
|
||||
if slices.Contains(reserved, lowerID) {
|
||||
return fmt.Errorf("reserved ID cannot be used")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizePhoneNumber(phone string) string {
|
||||
trimmed := strings.TrimSpace(phone)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
hasLeadingPlus := false
|
||||
digits := strings.Builder{}
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits.WriteRune(r)
|
||||
case r == '+' && digits.Len() == 0 && !hasLeadingPlus:
|
||||
hasLeadingPlus = true
|
||||
}
|
||||
}
|
||||
|
||||
number := digits.String()
|
||||
if number == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(number, "010") {
|
||||
return "+82" + number[1:]
|
||||
}
|
||||
if strings.HasPrefix(number, "82") {
|
||||
rest := number[2:]
|
||||
for strings.HasPrefix(rest, "82") {
|
||||
rest = rest[2:]
|
||||
}
|
||||
if strings.HasPrefix(rest, "0") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
return "+82" + rest
|
||||
}
|
||||
if hasLeadingPlus {
|
||||
return "+" + number
|
||||
}
|
||||
return number
|
||||
}
|
||||
50
baron-sso/backend/internal/domain/user_group.go
Normal file
50
baron-sso/backend/internal/domain/user_group.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserGroup represents a collection of users within a tenant.
|
||||
type UserGroup struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
|
||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 상위 조직 ID
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"index" json:"slug"` // 추가
|
||||
Description string `json:"description"`
|
||||
UnitType string `json:"unitType"` // 부, 국, 팀, 셀 등
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Relationships
|
||||
Parent *UserGroup `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Members []User `gorm:"-" json:"members,omitempty"`
|
||||
}
|
||||
|
||||
type GroupCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Description string `json:"description"`
|
||||
UnitType string `json:"unitType"`
|
||||
}
|
||||
|
||||
type GroupRole struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantName"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
|
||||
func (ug *UserGroup) TableName() string {
|
||||
return "user_groups"
|
||||
}
|
||||
|
||||
func (ug *UserGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if ug.ID == "" {
|
||||
ug.ID = uuid.NewString()
|
||||
}
|
||||
return
|
||||
}
|
||||
29
baron-sso/backend/internal/domain/user_projection.go
Normal file
29
baron-sso/backend/internal/domain/user_projection.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
UserProjectionNameKratos = "kratos_users"
|
||||
|
||||
UserProjectionStatusSyncing = "syncing"
|
||||
UserProjectionStatusReady = "ready"
|
||||
UserProjectionStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type UserProjectionState struct {
|
||||
Name string `gorm:"primaryKey;column:name" json:"name"`
|
||||
Status string `gorm:"column:status;not null" json:"status"`
|
||||
LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"`
|
||||
LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserProjectionStatus struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
ProjectedUsers int64 `json:"projectedUsers"`
|
||||
}
|
||||
73
baron-sso/backend/internal/domain/user_test.go
Normal file
73
baron-sso/backend/internal/domain/user_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "super admin unchanged", in: "super_admin", want: RoleSuperAdmin},
|
||||
{name: "tenant admin mapped to user", in: "tenant_admin", want: RoleUser},
|
||||
{name: "rp admin mapped to user", in: "rp_admin", want: RoleUser},
|
||||
{name: "user unchanged", in: "user", want: RoleUser},
|
||||
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
|
||||
{name: "super admin compact alias", in: "superadmin", want: RoleSuperAdmin},
|
||||
{name: "legacy admin mapped to user", in: "admin", want: RoleUser},
|
||||
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
|
||||
{name: "trim and lower", in: " ADMIN ", want: RoleUser},
|
||||
{name: "unknown role mapped to user", in: "custom_role", want: RoleUser},
|
||||
{name: "empty string mapped to user", in: " ", want: RoleUser},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := NormalizeRole(tc.in); got != tc.want {
|
||||
t.Fatalf("NormalizeRole(%q)=%q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserStatusPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
normalized string
|
||||
baronAllowed bool
|
||||
orgVisible bool
|
||||
worksProvisioned bool
|
||||
worksDeprovisioned bool
|
||||
}{
|
||||
{status: UserStatusActive, normalized: UserStatusActive, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusTemporaryLeave, normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusSuspended, normalized: UserStatusSuspended, orgVisible: true, worksProvisioned: true},
|
||||
{status: UserStatusPreboarding, normalized: UserStatusPreboarding},
|
||||
{status: UserStatusBaronGuest, normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||
{status: UserStatusExtendedLeave, normalized: UserStatusExtendedLeave, worksDeprovisioned: true},
|
||||
{status: UserStatusArchived, normalized: UserStatusArchived, worksDeprovisioned: true},
|
||||
{status: "inactive", normalized: UserStatusPreboarding},
|
||||
{status: "leave_of_absence", normalized: UserStatusTemporaryLeave, baronAllowed: true, orgVisible: true, worksProvisioned: true},
|
||||
{status: "BARON_ONLY", normalized: UserStatusBaronGuest, baronAllowed: true, worksDeprovisioned: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.status, func(t *testing.T) {
|
||||
if got := NormalizeUserStatus(tc.status); got != tc.normalized {
|
||||
t.Fatalf("NormalizeUserStatus(%q)=%q, want %q", tc.status, got, tc.normalized)
|
||||
}
|
||||
if got := IsBaronActivityAllowedStatus(tc.status); got != tc.baronAllowed {
|
||||
t.Fatalf("IsBaronActivityAllowedStatus(%q)=%v, want %v", tc.status, got, tc.baronAllowed)
|
||||
}
|
||||
if got := IsOrgVisibleUserStatus(tc.status); got != tc.orgVisible {
|
||||
t.Fatalf("IsOrgVisibleUserStatus(%q)=%v, want %v", tc.status, got, tc.orgVisible)
|
||||
}
|
||||
if got := IsWorksProvisionedUserStatus(tc.status); got != tc.worksProvisioned {
|
||||
t.Fatalf("IsWorksProvisionedUserStatus(%q)=%v, want %v", tc.status, got, tc.worksProvisioned)
|
||||
}
|
||||
if got := IsWorksDeprovisionUserStatus(tc.status); got != tc.worksDeprovisioned {
|
||||
t.Fatalf("IsWorksDeprovisionUserStatus(%q)=%v, want %v", tc.status, got, tc.worksDeprovisioned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
64
baron-sso/backend/internal/domain/user_validate_test.go
Normal file
64
baron-sso/backend/internal/domain/user_validate_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateLoginID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loginID string
|
||||
emails []string
|
||||
phone string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Empty", "", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid alphanumeric", "user123", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Too short", "us", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Too long", "thisisaverylongloginidthatiswayoverthirtycharacters", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Email format", "user@domain.com", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Exact email match", "Test@Email.Com", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Secondary email match", "sub@test.com", []string{"test@email.com", "sub@test.com"}, "01012345678", true},
|
||||
{"Phone number match", "010-1234-5678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone number match +82", "+821012345678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone number match digits", "01012345678", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Phone format (11 digits)", "01098765432", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Valid pure digits (employee ID)", "20230001", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid pure digits long", "123456789", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Valid pure digits 10 chars", "1234567890", []string{"test@email.com"}, "01012345678", false},
|
||||
{"Reserved word admin", "ADMIN", []string{"test@email.com"}, "01012345678", true},
|
||||
{"Reserved word root", "root", []string{"test@email.com"}, "01012345678", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLoginID(tt.loginID, tt.emails, tt.phone)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateLoginID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneNumberDeduplicatesKoreanCountryCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Local mobile", "010-9191-7771", "+821091917771"},
|
||||
{"Korean country code", "+82 10-9191-7771", "+821091917771"},
|
||||
{"Duplicate plus Korean country code", "+82 +821091917771", "+821091917771"},
|
||||
{"Duplicate compact Korean country code", "+82821091917771", "+821091917771"},
|
||||
{"Duplicate spaced Korean country code", "+82 8210 9191 7771", "+821091917771"},
|
||||
{"Non Korean international phone preserved", "+1 914 481 2222", "+19144812222"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NormalizePhoneNumber(tt.input); got != tt.want {
|
||||
t.Fatalf("NormalizePhoneNumber(%q)=%q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
baron-sso/backend/internal/domain/worksmobile.go
Normal file
73
baron-sso/backend/internal/domain/worksmobile.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileOutboxStatusPending = "pending"
|
||||
WorksmobileOutboxStatusProcessing = "processing"
|
||||
WorksmobileOutboxStatusProcessed = "processed"
|
||||
WorksmobileOutboxStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileResourceOrgUnit = "ORGUNIT"
|
||||
WorksmobileResourceUser = "USER"
|
||||
)
|
||||
|
||||
const (
|
||||
WorksmobileActionUpsert = "UPSERT"
|
||||
WorksmobileActionDelete = "DELETE"
|
||||
WorksmobileActionDryRun = "DRY_RUN"
|
||||
WorksmobileActionSuspend = "SUSPEND"
|
||||
WorksmobileActionPasswordReset = "PASSWORD_RESET"
|
||||
)
|
||||
|
||||
type WorksmobileOutbox struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
ResourceType string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceType"`
|
||||
ResourceID string `gorm:"not null;index:idx_worksmobile_outbox_resource" json:"resourceId"`
|
||||
Action string `gorm:"not null" json:"action"`
|
||||
Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"`
|
||||
DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"`
|
||||
Status string `gorm:"default:'pending';index" json:"status"`
|
||||
RetryCount int `gorm:"default:0" json:"retryCount"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
ProcessedAt *time.Time `json:"processedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (w *WorksmobileOutbox) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.ID == "" {
|
||||
w.ID = uuid.NewString()
|
||||
}
|
||||
if w.Status == "" {
|
||||
w.Status = WorksmobileOutboxStatusPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorksmobileResourceMapping struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
BaronResourceType string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceType"`
|
||||
BaronResourceID string `gorm:"not null;uniqueIndex:idx_worksmobile_mapping_baron" json:"baronResourceId"`
|
||||
ExternalKey string `gorm:"not null;uniqueIndex" json:"externalKey"`
|
||||
WorksmobileResourceID string `json:"worksmobileResourceId,omitempty"`
|
||||
DomainID int64 `json:"domainId"`
|
||||
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (w *WorksmobileResourceMapping) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.ID == "" {
|
||||
w.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user