1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/main'

This commit is contained in:
Lectom C Han
2026-02-03 16:50:11 +09:00
24 changed files with 1073 additions and 156 deletions

View File

@@ -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 != "" {

View File

@@ -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"})

View File

@@ -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)
}