첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
236
baron-sso/backend/cmd/adminctl/main.go
Normal file
236
baron-sso/backend/cmd/adminctl/main.go
Normal 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]")
|
||||
}
|
||||
140
baron-sso/backend/cmd/adminctl/main_test.go
Normal file
140
baron-sso/backend/cmd/adminctl/main_test.go
Normal 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
|
||||
}
|
||||
1709
baron-sso/backend/cmd/adminctl/worksmobile_sync.go
Normal file
1709
baron-sso/backend/cmd/adminctl/worksmobile_sync.go
Normal file
File diff suppressed because it is too large
Load Diff
38
baron-sso/backend/cmd/adminctl/worksmobile_sync_test.go
Normal file
38
baron-sso/backend/cmd/adminctl/worksmobile_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user