forked from baron/baron-sso
worksmobile 연동 & ory stack 26.2.0으로 업그레이드
This commit is contained in:
204
backend/internal/bootstrap/admin_account.go
Normal file
204
backend/internal/bootstrap/admin_account.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SuperAdminIdentityAdmin interface {
|
||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
}
|
||||
|
||||
type SuperAdminStore interface {
|
||||
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
CreateUser(ctx context.Context, user *domain.User) error
|
||||
UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error)
|
||||
EnqueueSuperAdminRelation(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type EnsureSuperAdminOptions struct {
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
Source string
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
type EnsureSuperAdminResult struct {
|
||||
Email string
|
||||
IdentityID string
|
||||
LocalUserID string
|
||||
IdentityCreated bool
|
||||
PasswordUpdated bool
|
||||
LocalUserCreated bool
|
||||
LocalUserUpdated bool
|
||||
KetoRelationQueued bool
|
||||
}
|
||||
|
||||
func EnsureSuperAdmin(ctx context.Context, identityAdmin SuperAdminIdentityAdmin, store SuperAdminStore, opts EnsureSuperAdminOptions) (EnsureSuperAdminResult, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(opts.Email))
|
||||
name := strings.TrimSpace(opts.Name)
|
||||
if name == "" {
|
||||
name = "System Admin"
|
||||
}
|
||||
source := strings.TrimSpace(opts.Source)
|
||||
if source == "" {
|
||||
source = "admin_cli"
|
||||
}
|
||||
result := EnsureSuperAdminResult{Email: email}
|
||||
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return result, fmt.Errorf("invalid admin email: %w", err)
|
||||
}
|
||||
if identityAdmin == nil {
|
||||
return result, errors.New("identity admin is required")
|
||||
}
|
||||
if store == nil {
|
||||
return result, errors.New("super admin store is required")
|
||||
}
|
||||
|
||||
identityID, err := identityAdmin.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("find admin identity: %w", err)
|
||||
}
|
||||
if identityID == "" {
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
return result, errors.New("admin password is required to create identity")
|
||||
}
|
||||
identityID, err = identityAdmin.CreateUser(ctx, buildSuperAdminBrokerUser(email, name), opts.Password)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create admin identity: %w", err)
|
||||
}
|
||||
result.IdentityCreated = true
|
||||
} else if opts.UpdatePassword {
|
||||
if strings.TrimSpace(opts.Password) == "" {
|
||||
return result, errors.New("admin password is required to update identity password")
|
||||
}
|
||||
if err := identityAdmin.UpdateIdentityPassword(ctx, identityID, opts.Password); err != nil {
|
||||
return result, fmt.Errorf("update admin identity password: %w", err)
|
||||
}
|
||||
result.PasswordUpdated = true
|
||||
}
|
||||
result.IdentityID = identityID
|
||||
|
||||
user, err := store.FindUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("find local admin user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
if identityID == "" {
|
||||
return result, errors.New("identity id is required to create local admin user")
|
||||
}
|
||||
user = &domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: domain.RoleSuperAdmin,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Metadata: domain.JSONMap{
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
if err := store.CreateUser(ctx, user); err != nil {
|
||||
return result, fmt.Errorf("create local admin user: %w", err)
|
||||
}
|
||||
result.LocalUserCreated = true
|
||||
} else if domain.NormalizeRole(user.Role) != domain.RoleSuperAdmin || user.Status != domain.UserStatusActive || (name != "" && user.Name != name) {
|
||||
user, err = store.UpdateUserSuperAdmin(ctx, user.ID, name)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("update local admin user: %w", err)
|
||||
}
|
||||
result.LocalUserUpdated = true
|
||||
}
|
||||
result.LocalUserID = user.ID
|
||||
|
||||
if err := store.EnqueueSuperAdminRelation(ctx, user.ID); err != nil {
|
||||
return result, fmt.Errorf("enqueue super admin keto relation: %w", err)
|
||||
}
|
||||
result.KetoRelationQueued = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildSuperAdminBrokerUser(email, name string) *domain.BrokerUser {
|
||||
return &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
PhoneNumber: "",
|
||||
Attributes: map[string]interface{}{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
"role": domain.RoleSuperAdmin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type gormSuperAdminStore struct {
|
||||
db *gorm.DB
|
||||
outbox repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func NewGormSuperAdminStore(db *gorm.DB, outbox repository.KetoOutboxRepository) SuperAdminStore {
|
||||
return &gormSuperAdminStore{db: db, outbox: outbox}
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var user domain.User
|
||||
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
|
||||
return s.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
||||
updates := map[string]interface{}{
|
||||
"role": domain.RoleSuperAdmin,
|
||||
"status": domain.UserStatusActive,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&domain.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *gormSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
|
||||
if s.outbox == nil {
|
||||
return nil
|
||||
}
|
||||
return s.outbox.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
159
backend/internal/bootstrap/admin_account_test.go
Normal file
159
backend/internal/bootstrap/admin_account_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureSuperAdminCreatesIdentityLocalUserAndKetoRelation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
identityAdmin := &fakeSuperAdminIdentityAdmin{createdID: "identity-1"}
|
||||
store := &fakeSuperAdminStore{}
|
||||
|
||||
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
|
||||
Email: "new-admin@example.com",
|
||||
Password: "Password!123",
|
||||
Name: "New Admin",
|
||||
Source: "test",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
|
||||
}
|
||||
if !result.IdentityCreated {
|
||||
t.Fatal("identity must be created")
|
||||
}
|
||||
if !result.LocalUserCreated {
|
||||
t.Fatal("local user must be created")
|
||||
}
|
||||
if result.IdentityID != "identity-1" {
|
||||
t.Fatalf("identity ID = %q, want identity-1", result.IdentityID)
|
||||
}
|
||||
if store.user == nil {
|
||||
t.Fatal("local user was not stored")
|
||||
}
|
||||
if store.user.Email != "new-admin@example.com" {
|
||||
t.Fatalf("local user email = %q", store.user.Email)
|
||||
}
|
||||
if store.user.Role != domain.RoleSuperAdmin {
|
||||
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
|
||||
}
|
||||
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:identity-1" {
|
||||
t.Fatalf("keto subjects = %#v, want User:identity-1", store.ketoSubjects)
|
||||
}
|
||||
if identityAdmin.createdUser == nil || identityAdmin.createdUser.Attributes["role"] != domain.RoleSuperAdmin {
|
||||
t.Fatalf("created identity attributes = %#v", identityAdmin.createdUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSuperAdminPromotesExistingLocalUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
identityAdmin := &fakeSuperAdminIdentityAdmin{existingID: "identity-1"}
|
||||
store := &fakeSuperAdminStore{
|
||||
user: &domain.User{
|
||||
ID: "local-user-1",
|
||||
Email: "existing@example.com",
|
||||
Name: "Existing",
|
||||
Role: domain.RoleUser,
|
||||
Status: domain.UserStatusInactive,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := EnsureSuperAdmin(ctx, identityAdmin, store, EnsureSuperAdminOptions{
|
||||
Email: "existing@example.com",
|
||||
Password: "Password!123",
|
||||
Name: "Existing Admin",
|
||||
Source: "test",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureSuperAdmin returned error: %v", err)
|
||||
}
|
||||
if result.IdentityCreated {
|
||||
t.Fatal("existing identity must not be recreated")
|
||||
}
|
||||
if !result.LocalUserUpdated {
|
||||
t.Fatal("local user must be promoted")
|
||||
}
|
||||
if store.user.Role != domain.RoleSuperAdmin {
|
||||
t.Fatalf("local user role = %q, want %q", store.user.Role, domain.RoleSuperAdmin)
|
||||
}
|
||||
if store.user.Status != domain.UserStatusActive {
|
||||
t.Fatalf("local user status = %q, want %q", store.user.Status, domain.UserStatusActive)
|
||||
}
|
||||
if len(store.ketoSubjects) != 1 || store.ketoSubjects[0] != "User:local-user-1" {
|
||||
t.Fatalf("keto subjects = %#v, want User:local-user-1", store.ketoSubjects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSuperAdminRequiresPasswordForNewIdentity(t *testing.T) {
|
||||
_, err := EnsureSuperAdmin(context.Background(), &fakeSuperAdminIdentityAdmin{}, &fakeSuperAdminStore{}, EnsureSuperAdminOptions{
|
||||
Email: "new-admin@example.com",
|
||||
Name: "New Admin",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSuperAdminIdentityAdmin struct {
|
||||
existingID string
|
||||
createdID string
|
||||
createdUser *domain.BrokerUser
|
||||
createdSecret string
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
return f.existingID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
if f.createdID == "" {
|
||||
return "", errors.New("created id is not configured")
|
||||
}
|
||||
f.createdUser = user
|
||||
f.createdSecret = password
|
||||
return f.createdID, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminIdentityAdmin) UpdateIdentityPassword(ctx context.Context, identityID string, newPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSuperAdminStore struct {
|
||||
user *domain.User
|
||||
ketoSubjects []string
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) CreateUser(ctx context.Context, user *domain.User) error {
|
||||
copied := *user
|
||||
f.user = &copied
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) UpdateUserSuperAdmin(ctx context.Context, userID string, name string) (*domain.User, error) {
|
||||
if f.user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
f.user.Role = domain.RoleSuperAdmin
|
||||
f.user.Status = domain.UserStatusActive
|
||||
if name != "" {
|
||||
f.user.Name = name
|
||||
}
|
||||
return f.user, nil
|
||||
}
|
||||
|
||||
func (f *fakeSuperAdminStore) EnqueueSuperAdminRelation(ctx context.Context, userID string) error {
|
||||
f.ketoSubjects = append(f.ketoSubjects, "User:"+userID)
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +45,8 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientSecret{},
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.WorksmobileOutbox{},
|
||||
&domain.WorksmobileResourceMapping{},
|
||||
&domain.SharedLink{},
|
||||
&domain.DeveloperRequest{},
|
||||
&domain.RPUserMetadata{},
|
||||
|
||||
@@ -288,6 +288,8 @@ func normalizeSeedTenantType(value string) string {
|
||||
return domain.TenantTypeCompany
|
||||
case domain.TenantTypeCompanyGroup:
|
||||
return domain.TenantTypeCompanyGroup
|
||||
case domain.TenantTypeOrganization:
|
||||
return domain.TenantTypeOrganization
|
||||
case domain.TenantTypeUserGroup:
|
||||
return domain.TenantTypeUserGroup
|
||||
default:
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
|
||||
func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
|
||||
configs, err := loadSeedTenantConfigs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
|
||||
@@ -17,12 +17,69 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
|
||||
name string
|
||||
slug string
|
||||
tenantType string
|
||||
parentSlug string
|
||||
domains []string
|
||||
}{
|
||||
{
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
},
|
||||
{
|
||||
name: "삼안",
|
||||
slug: "saman",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"samaneng.com"},
|
||||
},
|
||||
{
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"hanmaceng.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "총괄기획&기술개발센터",
|
||||
slug: "gpdtdc",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"baroncs.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "바론그룹",
|
||||
slug: "baron-group",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
parentSlug: "hanmac-family",
|
||||
},
|
||||
{
|
||||
name: "(주)장헌",
|
||||
slug: "jangheon",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"jangheon.com"},
|
||||
},
|
||||
{
|
||||
name: "장헌산업",
|
||||
slug: "jangheon-sanup",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"jangheon.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "한라산업개발",
|
||||
slug: "hanlla",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"hanllasanup.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "(주)피티씨",
|
||||
slug: "ptc",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "baron-group",
|
||||
domains: []string{"pre-cast.co.kr"},
|
||||
},
|
||||
{
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
@@ -45,15 +102,23 @@ func TestSeedTenantCSVDefinesOnlyRequiredRootTenants(t *testing.T) {
|
||||
if got.Type != want.tenantType {
|
||||
t.Fatalf("tenant[%d] type = %q, want %q", i, got.Type, want.tenantType)
|
||||
}
|
||||
if got.ParentSlug != "" {
|
||||
t.Fatalf("tenant[%d] parent slug = %q, want empty root tenant", i, got.ParentSlug)
|
||||
if got.ParentSlug != want.parentSlug {
|
||||
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, got.ParentSlug, want.parentSlug)
|
||||
}
|
||||
if len(got.Domains) != len(want.domains) {
|
||||
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
|
||||
}
|
||||
for j, wantDomain := range want.domains {
|
||||
if got.Domains[j] != wantDomain {
|
||||
t.Fatalf("tenant[%d] domain[%d] = %q, want %q", i, j, got.Domains[j], wantDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tenant := range configs {
|
||||
if tenant.Slug == "system" || tenant.Slug == "hanmac" || tenant.Slug == "saman" {
|
||||
t.Fatalf("tenant %q must be configured by import, not seed CSV", tenant.Slug)
|
||||
}
|
||||
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
|
||||
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
|
||||
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user