forked from baron/baron-sso
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -91,6 +91,7 @@ type AuthHandler struct {
|
||||
OathkeeperRepo domain.OathkeeperLogRepository
|
||||
Hydra *service.HydraAdminService
|
||||
TenantService service.TenantService
|
||||
KetoService service.KetoService
|
||||
UserRepo repository.UserRepository
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
||||
return false, int(interval.Seconds())
|
||||
}
|
||||
|
||||
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
|
||||
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *AuthHandler {
|
||||
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||
|
||||
@@ -177,6 +178,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
||||
OathkeeperRepo: oathkeeperRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
TenantService: tenantService,
|
||||
KetoService: ketoService,
|
||||
UserRepo: userRepo,
|
||||
}
|
||||
}
|
||||
@@ -405,16 +407,26 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||
}
|
||||
|
||||
// [New] Auto-Assign Tenant by Domain
|
||||
companyCode := req.CompanyCode
|
||||
if companyCode == "" {
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) == 2 {
|
||||
domainName := parts[1]
|
||||
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
||||
if err == nil && tenant != nil {
|
||||
// [Strict] Enforce Tenant Auto-Assignment by Domain ONLY
|
||||
// Manual companyCode from request is ignored to prevent unauthorized tenant joining
|
||||
companyCode := ""
|
||||
var tenantID *string
|
||||
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) == 2 {
|
||||
domainName := parts[1]
|
||||
tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug)
|
||||
companyCode = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
} else {
|
||||
slog.Warn("[Signup] Attempted to join non-active tenant", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status)
|
||||
// Policy: If tenant exists but not active, reject signup or allow as general?
|
||||
// For now, let's allow as general but log it.
|
||||
// Or return error if we want strict domain locking.
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Your organization's tenant is currently not active."})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,21 +481,27 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
Phone: normalizedPhone,
|
||||
AffiliationType: req.AffiliationType,
|
||||
CompanyCode: companyCode,
|
||||
TenantID: tenantID,
|
||||
Department: req.Department,
|
||||
Role: "user",
|
||||
Status: "active",
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
// Link TenantID if possible
|
||||
if companyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), 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("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.UserRepo.Create(c.Context(), localUser); err != nil {
|
||||
slog.Error("[Signup] Failed to sync user to local DB", "email", req.Email, "error", err)
|
||||
// We don't fail the whole signup if local sync fails
|
||||
// [Keto] Sync user-tenant relationship
|
||||
if h.KetoService != nil && tenantID != nil {
|
||||
go func() {
|
||||
err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID)
|
||||
if err != nil {
|
||||
slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -2555,69 +2573,18 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string {
|
||||
return phone
|
||||
}
|
||||
|
||||
// GetMe - Returns current user's profile with 010 phone format
|
||||
// GetMe - Returns current user's profile with enriched data from local DB
|
||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
if looksLikeJWT(token) && h.DescopeClient != nil {
|
||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||
if err == nil && authorized {
|
||||
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
|
||||
}
|
||||
|
||||
identityID, resolveErr := h.resolveKratosIdentityID(
|
||||
c.Context(),
|
||||
userResponse.Email,
|
||||
normalizePhoneForLoginID(userResponse.Phone),
|
||||
)
|
||||
if resolveErr != nil || identityID == "" {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
|
||||
}
|
||||
|
||||
dept, _ := userResponse.CustomAttributes["department"].(string)
|
||||
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
||||
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
||||
|
||||
resp := domain.UserProfileResponse{
|
||||
ID: identityID,
|
||||
Email: userResponse.Email,
|
||||
Name: userResponse.Name,
|
||||
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
||||
Department: dept,
|
||||
AffiliationType: affType,
|
||||
CompanyCode: compCode,
|
||||
Metadata: userResponse.CustomAttributes,
|
||||
}
|
||||
|
||||
if compCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode); err == nil && tenant != nil {
|
||||
resp.Tenant = tenant
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(resp)
|
||||
}
|
||||
}
|
||||
|
||||
profile, err := h.getKratosProfile(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
}
|
||||
return c.JSON(profile)
|
||||
}
|
||||
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"})
|
||||
}
|
||||
profile, err := h.getKratosProfileWithCookie(cookie)
|
||||
profile, err := h.resolveCurrentProfile(c)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(profile)
|
||||
}
|
||||
// GetEnrichedProfile - Exported wrapper for resolveCurrentProfile used by middlewares
|
||||
func (h *AuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
return h.resolveCurrentProfile(c)
|
||||
}
|
||||
|
||||
func looksLikeJWT(token string) bool {
|
||||
return strings.Count(token, ".") == 2
|
||||
@@ -3539,52 +3506,101 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
// [Development Mode Fallback]
|
||||
if os.Getenv("APP_ENV") != "production" {
|
||||
// 우선순위: 1. 헤더, 2. 쿠키, 3. 기본값(user)
|
||||
testRole := c.Get("X-Test-Role")
|
||||
if testRole == "" {
|
||||
testRole = c.Cookies("X-Mock-Role")
|
||||
}
|
||||
|
||||
if testRole == "" {
|
||||
testRole = domain.RoleUser // 기본값을 user로 변경하여 차단 확인
|
||||
}
|
||||
|
||||
slog.Info("Using MOCK profile", "role", testRole, "source", "dev_fallback")
|
||||
return &domain.UserProfileResponse{
|
||||
ID: "dev-admin-uuid",
|
||||
Email: "dev-admin@baron.local",
|
||||
Name: "Dev Admin (" + testRole + ")",
|
||||
Role: testRole,
|
||||
CompanyCode: "hanmac",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var profile *domain.UserProfileResponse
|
||||
var err error
|
||||
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
if looksLikeJWT(token) && h.DescopeClient != nil {
|
||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||
if err == nil && authorized {
|
||||
userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if err == nil {
|
||||
identityID, resolveErr := h.resolveKratosIdentityID(
|
||||
c.Context(),
|
||||
userResponse.Email,
|
||||
normalizePhoneForLoginID(userResponse.Phone),
|
||||
)
|
||||
if resolveErr == nil && identityID != "" {
|
||||
dept, _ := userResponse.CustomAttributes["department"].(string)
|
||||
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
||||
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
||||
profile = &domain.UserProfileResponse{
|
||||
ID: identityID,
|
||||
Email: userResponse.Email,
|
||||
Name: userResponse.Name,
|
||||
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
||||
Department: dept,
|
||||
AffiliationType: affType,
|
||||
CompanyCode: compCode,
|
||||
Metadata: userResponse.CustomAttributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
identityID, resolveErr := h.resolveKratosIdentityID(
|
||||
c.Context(),
|
||||
userResponse.Email,
|
||||
normalizePhoneForLoginID(userResponse.Phone),
|
||||
)
|
||||
if resolveErr != nil || identityID == "" {
|
||||
return nil, fmt.Errorf("failed to resolve kratos identity for profile")
|
||||
}
|
||||
dept, _ := userResponse.CustomAttributes["department"].(string)
|
||||
affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
|
||||
compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
|
||||
return &domain.UserProfileResponse{
|
||||
ID: identityID,
|
||||
Email: userResponse.Email,
|
||||
Name: userResponse.Name,
|
||||
Phone: h.formatPhoneForDisplay(userResponse.Phone),
|
||||
Department: dept,
|
||||
AffiliationType: affType,
|
||||
CompanyCode: compCode,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
profile, err := h.getKratosProfile(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if profile == nil {
|
||||
profile, err = h.getKratosProfile(token)
|
||||
}
|
||||
} else {
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie != "" {
|
||||
profile, err = h.getKratosProfileWithCookie(cookie)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return nil, fmt.Errorf("missing authorization token")
|
||||
if err != nil || profile == nil {
|
||||
return nil, errors.New("invalid session")
|
||||
}
|
||||
return h.getKratosProfileWithCookie(cookie)
|
||||
|
||||
// [New] Enrich with Local DB (Roles, TenantID, etc.)
|
||||
if h.UserRepo != nil {
|
||||
localUser, err := h.UserRepo.FindByID(c.Context(), profile.ID)
|
||||
if err == nil && localUser != nil {
|
||||
profile.Role = localUser.Role
|
||||
profile.TenantID = localUser.TenantID
|
||||
profile.RelyingPartyID = localUser.RelyingPartyID
|
||||
if profile.Tenant == nil && localUser.Tenant != nil {
|
||||
profile.Tenant = localUser.Tenant
|
||||
}
|
||||
} else {
|
||||
// 로컬 DB에 없으면 기본 권한 부여
|
||||
profile.Role = domain.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 DB에 Tenant 정보가 없더라도 companyCode(slug)가 있으면 조회 시도
|
||||
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
||||
profile.Tenant = tenant
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
|
||||
@@ -39,6 +39,47 @@ type tenantListResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Domain string `json:"domain"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if req.Name == "" || req.Domain == "" || req.AdminEmail == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name, domain, and adminEmail are required"})
|
||||
}
|
||||
|
||||
tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
|
||||
"message": "Registration request received and is pending approval.",
|
||||
"tenant": mapTenantSummary(*tenant),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"})
|
||||
}
|
||||
|
||||
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
|
||||
}
|
||||
|
||||
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
|
||||
@@ -17,14 +17,16 @@ 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, userRepo repository.UserRepository) *UserHandler {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +59,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
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"))
|
||||
@@ -74,17 +79,28 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
if search == "" {
|
||||
filtered = identities
|
||||
} else {
|
||||
searchLower := strings.ToLower(search)
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) {
|
||||
filtered = append(filtered, identity)
|
||||
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))
|
||||
@@ -123,6 +139,15 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -245,6 +270,23 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// [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()})
|
||||
@@ -278,6 +320,15 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
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"`
|
||||
@@ -292,6 +343,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
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{}{}
|
||||
@@ -395,10 +453,33 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user