forked from baron/baron-sso
588 lines
18 KiB
Go
588 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/repository"
|
|
"baron-sso-backend/internal/service"
|
|
"baron-sso-backend/internal/utils"
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type UserHandler struct {
|
|
KratosAdmin *service.KratosAdminService
|
|
OryProvider *service.OryProvider
|
|
TenantService service.TenantService
|
|
KetoService service.KetoService
|
|
UserRepo repository.UserRepository
|
|
}
|
|
|
|
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler {
|
|
return &UserHandler{
|
|
KratosAdmin: kratosAdmin,
|
|
OryProvider: oryProvider,
|
|
TenantService: tenantService,
|
|
KetoService: ketoService,
|
|
UserRepo: userRepo,
|
|
}
|
|
}
|
|
|
|
type userSummary struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
|
Department string `json:"department"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
InitialPassword string `json:"initialPassword,omitempty"`
|
|
}
|
|
|
|
type userListResponse struct {
|
|
Items []userSummary `json:"items"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
|
}
|
|
|
|
// [New] Get requester profile from middleware
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
identities, err := h.KratosAdmin.ListIdentities(c.Context())
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
filtered := make([]service.KratosIdentity, 0, len(identities))
|
|
searchLower := strings.ToLower(search)
|
|
|
|
for _, identity := range identities {
|
|
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
|
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
|
|
// 1. Tenant Admin filtering
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
continue // Skip users from other tenants
|
|
}
|
|
}
|
|
|
|
// 2. Search filtering
|
|
if search != "" {
|
|
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
filtered = append(filtered, identity)
|
|
}
|
|
|
|
total := int64(len(filtered))
|
|
if offset > len(filtered) {
|
|
offset = len(filtered)
|
|
}
|
|
end := offset + limit
|
|
if end > len(filtered) {
|
|
end = len(filtered)
|
|
}
|
|
|
|
items := make([]userSummary, 0, end-offset)
|
|
for _, identity := range filtered[offset:end] {
|
|
summary := h.mapIdentitySummary(c.Context(), identity)
|
|
items = append(items, summary)
|
|
}
|
|
|
|
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
|
}
|
|
|
|
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if identity == nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
|
|
// [New] Check access scope
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: access to user in another tenant denied"})
|
|
}
|
|
}
|
|
|
|
return c.JSON(h.mapIdentitySummary(c.Context(), *identity))
|
|
}
|
|
|
|
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|
if h.OryProvider == nil || h.KratosAdmin == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
CompanyCode string `json:"companyCode"`
|
|
Department string `json:"department"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
email := strings.TrimSpace(req.Email)
|
|
if email == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
|
|
}
|
|
if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email format"})
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
|
}
|
|
|
|
password := strings.TrimSpace(req.Password)
|
|
policy, err := h.OryProvider.GetPasswordPolicy()
|
|
if err != nil || policy == nil {
|
|
policy = &domain.PasswordPolicy{
|
|
MinLength: 12,
|
|
Lowercase: true,
|
|
Uppercase: false,
|
|
Number: true,
|
|
NonAlphanumeric: true,
|
|
MinCharacterTypes: 0,
|
|
}
|
|
}
|
|
|
|
generatedPassword := ""
|
|
if password == "" {
|
|
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
|
if genErr != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"})
|
|
}
|
|
password = generated
|
|
generatedPassword = generated
|
|
} else {
|
|
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
role := strings.TrimSpace(req.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
attributes := map[string]interface{}{
|
|
"department": req.Department,
|
|
"affiliationType": "internal",
|
|
"companyCode": req.CompanyCode,
|
|
"grade": role,
|
|
}
|
|
|
|
// Merge custom metadata into attributes
|
|
for k, v := range req.Metadata {
|
|
// Don't overwrite core fields
|
|
if _, exists := attributes[k]; !exists {
|
|
attributes[k] = v
|
|
}
|
|
}
|
|
|
|
brokerUser := &domain.BrokerUser{
|
|
Email: email,
|
|
Name: name,
|
|
PhoneNumber: normalizePhoneNumber(req.Phone),
|
|
Attributes: attributes,
|
|
}
|
|
|
|
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "already exists") {
|
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
|
}
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// [New] Local DB Sync
|
|
localUser := &domain.User{
|
|
ID: identityID,
|
|
Email: email,
|
|
Name: name,
|
|
Phone: normalizePhoneNumber(req.Phone),
|
|
AffiliationType: "internal",
|
|
CompanyCode: req.CompanyCode,
|
|
Department: req.Department,
|
|
Role: role,
|
|
Status: "active",
|
|
Metadata: req.Metadata,
|
|
}
|
|
|
|
if req.CompanyCode != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil {
|
|
localUser.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
|
|
if h.UserRepo != nil {
|
|
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
|
slog.Error("[UserHandler] Failed to sync user to local DB", "email", email, "error", err)
|
|
}
|
|
}
|
|
|
|
// [Keto] Sync relations
|
|
if h.KetoService != nil {
|
|
go func() {
|
|
ctx := context.Background()
|
|
// 1. Tenant Membership
|
|
if localUser.TenantID != nil {
|
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID)
|
|
}
|
|
// 2. Role Specifics
|
|
if role == domain.RoleSuperAdmin {
|
|
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID)
|
|
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
|
|
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID)
|
|
}
|
|
}()
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if identity == nil {
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
|
}
|
|
|
|
response := h.mapIdentitySummary(c.Context(), *identity)
|
|
if generatedPassword != "" {
|
|
response.InitialPassword = generatedPassword
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(response)
|
|
}
|
|
|
|
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
|
}
|
|
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if identity == nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
|
|
// [New] Check access scope
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot update user in another tenant"})
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
Password *string `json:"password"`
|
|
Name *string `json:"name"`
|
|
Phone *string `json:"phone"`
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
CompanyCode *string `json:"companyCode"`
|
|
Department *string `json:"department"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
|
|
// [New] Tenant Admin restriction: Cannot change companyCode
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: tenant admins cannot change user's tenant"})
|
|
}
|
|
}
|
|
|
|
traits := identity.Traits
|
|
if traits == nil {
|
|
traits = map[string]interface{}{}
|
|
}
|
|
if req.Name != nil {
|
|
traits["name"] = strings.TrimSpace(*req.Name)
|
|
}
|
|
if req.Phone != nil {
|
|
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
|
}
|
|
if req.CompanyCode != nil {
|
|
traits["companyCode"] = strings.TrimSpace(*req.CompanyCode)
|
|
}
|
|
if req.Department != nil {
|
|
traits["department"] = strings.TrimSpace(*req.Department)
|
|
}
|
|
if req.Role != nil {
|
|
role := strings.TrimSpace(*req.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
traits["grade"] = role
|
|
}
|
|
|
|
// [Refined] Metadata synchronization: replace non-core traits with new Metadata
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true,
|
|
}
|
|
|
|
// 1. Remove existing non-core traits to handle deletions
|
|
for k := range traits {
|
|
if !coreTraits[k] {
|
|
delete(traits, k)
|
|
}
|
|
}
|
|
|
|
// 2. Add new metadata fields
|
|
for k, v := range req.Metadata {
|
|
if !coreTraits[k] {
|
|
traits[k] = v
|
|
}
|
|
}
|
|
|
|
state := normalizeKratosState(req.Status)
|
|
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// [New] Local DB Sync
|
|
if h.UserRepo != nil {
|
|
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
|
if req.Name != nil {
|
|
localUser.Name = *req.Name
|
|
}
|
|
if req.Phone != nil {
|
|
localUser.Phone = normalizePhoneNumber(*req.Phone)
|
|
}
|
|
if req.CompanyCode != nil {
|
|
localUser.CompanyCode = *req.CompanyCode
|
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
|
localUser.TenantID = &tenant.ID
|
|
}
|
|
}
|
|
if req.Department != nil {
|
|
localUser.Department = *req.Department
|
|
}
|
|
if req.Role != nil {
|
|
localUser.Role = *req.Role
|
|
}
|
|
if req.Status != nil {
|
|
localUser.Status = *req.Status
|
|
}
|
|
if req.Metadata != nil {
|
|
localUser.Metadata = req.Metadata
|
|
}
|
|
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
|
|
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", userID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.Password != nil && *req.Password != "" {
|
|
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
return c.JSON(h.mapIdentitySummary(c.Context(), *updated))
|
|
}
|
|
|
|
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|
if h.KratosAdmin == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
|
}
|
|
|
|
userID := strings.TrimSpace(c.Params("id"))
|
|
if userID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
|
}
|
|
|
|
// [New] Check access scope before deletion
|
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
|
if err == nil && identity != nil {
|
|
compCode := extractTraitString(identity.Traits, "companyCode")
|
|
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: cannot delete user in another tenant"})
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// [Keto] Cleanup relations (Best effort)
|
|
if h.KetoService != nil {
|
|
go func() {
|
|
ctx := context.Background()
|
|
// Note: Proper cleanup requires searching all relations,
|
|
// here we just cleanup known common ones or rely on subject cleanup if Keto supported it.
|
|
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", userID)
|
|
// For tenants, we'd need to know which tenant they were in.
|
|
}()
|
|
}
|
|
|
|
return c.SendStatus(fiber.StatusNoContent)
|
|
}
|
|
|
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
|
traits := identity.Traits
|
|
role := extractTraitString(traits, "grade")
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
|
|
compCode := extractTraitString(traits, "companyCode")
|
|
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
|
|
summary := userSummary{
|
|
ID: identity.ID,
|
|
Email: extractTraitString(traits, "email"),
|
|
Name: extractTraitString(traits, "name"),
|
|
Phone: extractTraitString(traits, "phone_number"),
|
|
Role: role,
|
|
Status: normalizeStatus(identity.State),
|
|
CompanyCode: compCode,
|
|
Department: extractTraitString(traits, "department"),
|
|
Metadata: make(domain.JSONMap),
|
|
CreatedAt: formatTime(identity.CreatedAt),
|
|
UpdatedAt: formatTime(identity.UpdatedAt),
|
|
}
|
|
|
|
// Filter out core traits and put everything else in Metadata
|
|
coreTraits := map[string]bool{
|
|
"email": true, "name": true, "phone_number": true,
|
|
"grade": true, "companyCode": true, "department": true,
|
|
"affiliationType": true,
|
|
}
|
|
for k, v := range traits {
|
|
if !coreTraits[k] {
|
|
summary.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
if compCode != "" && h.TenantService != nil {
|
|
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
|
summary.Tenant = tenant
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
func extractTraitString(traits map[string]interface{}, key string) string {
|
|
if traits == nil {
|
|
return ""
|
|
}
|
|
if raw, ok := traits[key]; ok {
|
|
if value, ok := raw.(string); ok {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.Format(time.RFC3339)
|
|
}
|
|
|
|
func normalizeStatus(state string) string {
|
|
state = strings.ToLower(strings.TrimSpace(state))
|
|
if state == "inactive" || state == "blocked" || state == "active" {
|
|
return state
|
|
}
|
|
if state == "" {
|
|
return "active"
|
|
}
|
|
return state
|
|
}
|
|
|
|
func normalizeKratosState(status *string) string {
|
|
if status == nil {
|
|
return ""
|
|
}
|
|
value := strings.ToLower(strings.TrimSpace(*status))
|
|
if value == "blocked" {
|
|
return "inactive"
|
|
}
|
|
if value == "active" || value == "inactive" {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizePhoneNumber(phone string) string {
|
|
normalized := strings.ReplaceAll(phone, "-", "")
|
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
|
if normalized == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(normalized, "010") {
|
|
return "+82" + normalized[1:]
|
|
}
|
|
if strings.HasPrefix(normalized, "82") {
|
|
return "+" + normalized
|
|
}
|
|
return normalized
|
|
}
|