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

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,236 @@
package main
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"flag"
"fmt"
"log"
"log/slog"
"os"
"strings"
"time"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type createSuperAdminConfig struct {
Email string
Password string
Name string
UpdatePassword bool
}
type clearOrphanUserTenantMembershipsConfig struct {
DryRun bool
}
func main() {
loadEnv()
logger.Init(logger.Config{
ServiceName: "baron-sso-adminctl",
Environment: getenv("APP_ENV", getenv("GO_ENV", "dev")),
LevelOverride: getenv("BACKEND_LOG_LEVEL", ""),
})
if len(os.Args) < 2 {
printUsage()
os.Exit(2)
}
switch os.Args[1] {
case "create-super-admin":
if err := runCreateSuperAdmin(os.Args[2:]); err != nil {
slog.Error("create-super-admin failed", "error", err)
os.Exit(1)
}
case "clear-orphan-user-tenant-memberships":
if err := runClearOrphanUserTenantMemberships(os.Args[2:]); err != nil {
slog.Error("clear-orphan-user-tenant-memberships failed", "error", err)
os.Exit(1)
}
case "worksmobile-sync":
if err := runWorksmobileSync(os.Args[2:]); err != nil {
slog.Error("worksmobile-sync failed", "error", err)
os.Exit(1)
}
default:
printUsage()
os.Exit(2)
}
}
func runCreateSuperAdmin(args []string) error {
config, err := resolveCreateSuperAdminConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
if err := bootstrap.Run(db); err != nil {
return err
}
provider, err := idp.InitializeProvider()
if err != nil {
return err
}
if provider == nil {
return fmt.Errorf("idp provider is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := bootstrap.EnsureSuperAdmin(
ctx,
service.NewKratosAdminService(),
bootstrap.NewGormSuperAdminStore(db, repository.NewKetoOutboxRepository(db)),
bootstrap.EnsureSuperAdminOptions{
Email: config.Email,
Password: config.Password,
Name: config.Name,
Source: "adminctl",
UpdatePassword: config.UpdatePassword,
},
)
if err != nil {
return err
}
fmt.Printf("super admin ensured: email=%s identity_id=%s user_id=%s identity_created=%t local_created=%t local_updated=%t password_updated=%t keto_relation_queued=%t\n",
result.Email,
result.IdentityID,
result.LocalUserID,
result.IdentityCreated,
result.LocalUserCreated,
result.LocalUserUpdated,
result.PasswordUpdated,
result.KetoRelationQueued,
)
return nil
}
func runClearOrphanUserTenantMemberships(args []string) error {
config, err := resolveClearOrphanUserTenantMembershipsConfig(args)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if config.DryRun {
count, err := repository.CountOrphanUserTenantMemberships(ctx, db)
if err != nil {
return err
}
fmt.Printf("orphan user tenant memberships dry-run: count=%d\n", count)
return nil
}
affected, err := repository.ClearOrphanUserTenantMemberships(ctx, db)
if err != nil {
return err
}
fmt.Printf("orphan user tenant memberships cleared: count=%d\n", affected)
return nil
}
func resolveCreateSuperAdminConfig(args []string) (createSuperAdminConfig, error) {
fs := flag.NewFlagSet("create-super-admin", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := createSuperAdminConfig{}
fs.StringVar(&config.Email, "email", getenv("ADMIN_EMAIL", ""), "admin email")
fs.StringVar(&config.Password, "password", getenv("ADMIN_PASSWORD", ""), "admin password")
fs.StringVar(&config.Name, "name", getenv("ADMIN_NAME", "System Admin"), "admin display name")
fs.BoolVar(&config.UpdatePassword, "update-password", false, "update password when identity already exists")
if err := fs.Parse(args); err != nil {
return config, err
}
config.Email = strings.TrimSpace(config.Email)
config.Name = strings.TrimSpace(config.Name)
if config.Email == "" {
return config, fmt.Errorf("admin email is required; pass --email or set ADMIN_EMAIL")
}
if strings.TrimSpace(config.Password) == "" {
return config, fmt.Errorf("admin password is required; pass --password or set ADMIN_PASSWORD")
}
if config.Name == "" {
config.Name = "System Admin"
}
return config, nil
}
func resolveClearOrphanUserTenantMembershipsConfig(args []string) (clearOrphanUserTenantMembershipsConfig, error) {
fs := flag.NewFlagSet("clear-orphan-user-tenant-memberships", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
config := clearOrphanUserTenantMembershipsConfig{}
fs.BoolVar(&config.DryRun, "dry-run", false, "count orphan memberships without updating users")
if err := fs.Parse(args); err != nil {
return config, err
}
return config, nil
}
func openDB() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
getenv("DB_HOST", "localhost"),
getenv("DB_USER", "baron"),
getenv("DB_PASSWORD", "password"),
getenv("DB_NAME", "baron_sso"),
getenv("DB_PORT", "5432"),
)
return gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormLogger.Config{
SlowThreshold: time.Second,
LogLevel: gormLogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
),
})
}
func loadEnv() {
_ = godotenv.Load(".env")
_ = godotenv.Load("../.env")
_ = godotenv.Load("../../.env")
}
func getenv(key string, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func printUsage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " adminctl create-super-admin [--email EMAIL] [--password PASSWORD] [--name NAME] [--update-password]")
fmt.Fprintln(os.Stderr, " adminctl clear-orphan-user-tenant-memberships [--dry-run]")
fmt.Fprintln(os.Stderr, " adminctl worksmobile-sync [--orgunits] [--users-csv PATH] [--credential-batch-id ID] [--process] [--serialize-orgunits] [--serialize-users-batch ID] [--batch-size N] [--delay DURATION]")
}

View File

@@ -0,0 +1,140 @@
package main
import (
"baron-sso-backend/internal/service"
"context"
"strings"
"testing"
)
func TestResolveCreateSuperAdminConfigUsesEnvDefaults(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "admin@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "Password!123" {
t.Fatal("password was not read from ADMIN_PASSWORD")
}
if config.Name != "Env Admin" {
t.Fatalf("name = %q", config.Name)
}
}
func TestResolveCreateSuperAdminConfigAllowsFlagOverrides(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "admin@example.com")
t.Setenv("ADMIN_PASSWORD", "Password!123")
t.Setenv("ADMIN_NAME", "Env Admin")
config, err := resolveCreateSuperAdminConfig([]string{
"--email", "flag@example.com",
"--password", "FlagPassword!123",
"--name", "Flag Admin",
"--update-password",
})
if err != nil {
t.Fatalf("resolveCreateSuperAdminConfig returned error: %v", err)
}
if config.Email != "flag@example.com" {
t.Fatalf("email = %q", config.Email)
}
if config.Password != "FlagPassword!123" {
t.Fatal("password flag was not used")
}
if config.Name != "Flag Admin" {
t.Fatalf("name = %q", config.Name)
}
if !config.UpdatePassword {
t.Fatal("update password flag was not set")
}
}
func TestResolveCreateSuperAdminConfigRequiresEmailAndPassword(t *testing.T) {
t.Setenv("ADMIN_EMAIL", "")
t.Setenv("ADMIN_PASSWORD", "")
if _, err := resolveCreateSuperAdminConfig([]string{}); err == nil {
t.Fatal("expected error")
}
}
func TestResolveClearOrphanUserTenantMembershipsConfig(t *testing.T) {
config, err := resolveClearOrphanUserTenantMembershipsConfig([]string{"--dry-run"})
if err != nil {
t.Fatalf("resolveClearOrphanUserTenantMembershipsConfig returned error: %v", err)
}
if !config.DryRun {
t.Fatal("dry-run flag was not set")
}
}
func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T) {
client := &fakeWorksmobilePhoneAuditClient{
users: []service.WorksmobileRemoteUser{
{
ID: "works-user-1",
ExternalID: "baron-user-1",
Email: "one@example.com",
DisplayName: "One",
CellPhone: "+82 +821091917771",
DomainID: 1001,
DomainName: "samaneng.com",
},
{
ID: "works-user-2",
Email: "two@example.com",
CellPhone: "+821012345678",
DomainID: 1001,
},
},
}
output := &strings.Builder{}
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
if err != nil {
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
}
if count != 1 {
t.Fatalf("count=%d, want 1", count)
}
if !strings.Contains(output.String(), "one@example.com") || !strings.Contains(output.String(), "+821091917771") {
t.Fatalf("audit output did not include normalized duplicate phone row: %s", output.String())
}
if len(client.patches) != 1 {
t.Fatalf("patch count=%d, want 1", len(client.patches))
}
if client.patches[0].identifier != "works-user-1" {
t.Fatalf("patch identifier=%q, want works-user-1", client.patches[0].identifier)
}
if client.patches[0].payload.CellPhone != "+821091917771" {
t.Fatalf("patch cellPhone=%q, want +821091917771", client.patches[0].payload.CellPhone)
}
}
type fakeWorksmobilePhoneAuditClient struct {
users []service.WorksmobileRemoteUser
patches []fakeWorksmobilePhonePatch
}
type fakeWorksmobilePhonePatch struct {
identifier string
payload service.WorksmobileUserPatchPayload
}
func (f *fakeWorksmobilePhoneAuditClient) ListUsers(ctx context.Context) ([]service.WorksmobileRemoteUser, error) {
return f.users, nil
}
func (f *fakeWorksmobilePhoneAuditClient) PatchUser(ctx context.Context, identifier string, payload service.WorksmobileUserPatchPayload) error {
f.patches = append(f.patches, fakeWorksmobilePhonePatch{identifier: identifier, payload: payload})
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
package main
import (
"baron-sso-backend/internal/service"
"testing"
)
func TestClassifyWorksmobileAlignFromWorksAllowsDomainOnlyEmailMismatch(t *testing.T) {
item := service.WorksmobileComparisonItem{
BaronEmail: "user@typo.example.com",
WorksmobileEmail: "user@example.com",
}
status, ok := classifyWorksmobileAlignFromWorks(item)
if !ok {
t.Fatalf("expected domain-only email mismatch to be alignable, status=%s", status)
}
if status != "updated" {
t.Fatalf("expected updated status, got %s", status)
}
}
func TestClassifyWorksmobileAlignFromWorksSkipsLocalPartChange(t *testing.T) {
item := service.WorksmobileComparisonItem{
BaronEmail: "old@example.com",
WorksmobileEmail: "new@example.com",
}
status, ok := classifyWorksmobileAlignFromWorks(item)
if ok {
t.Fatalf("expected local-part change to be skipped")
}
if status != "skipped_email_local_part_changed" {
t.Fatalf("expected skipped_email_local_part_changed status, got %s", status)
}
}