첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View 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
}

View 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"`
}

View 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
}

View 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
}

View 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"`
}

View 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"`
}

View File

@@ -0,0 +1,6 @@
package domain
// EmailService defines the interface for sending emails.
type EmailService interface {
SendEmail(to, subject, body string) error
}

View 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
}

View 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
}

View 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)
}
})
}
}

View 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:"-"`
}

View 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"`
}

View 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")
}
})
}

View 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"`
}

View 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
}

View 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
}

View 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")
}
})
}

View 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
}

View 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")
}
}

View 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
}

View 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
}

View 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

View 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)
}

View 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"
}

View 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
}

View 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)
}
})
}
}

View 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"`
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"`
}

View 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)
}
})
}
}

View 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)
}
})
}
}

View 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
}