1
0
forked from baron/baron-sso

Merge origin/dev into dev

This commit is contained in:
2026-06-15 20:05:47 +09:00
67 changed files with 6933 additions and 3919 deletions

View File

@@ -4928,6 +4928,125 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
}
}
if h.KetoService != nil {
subject := "User:" + profile.ID
var sp domain.SystemPermissions
if profile.Role == "super_admin" {
sp = domain.SystemPermissions{
Overview: true,
Tenants: true,
OrgChart: true,
Worksmobile: true,
OrySSOT: true,
DataIntegrity: true,
Users: true,
PermissionsDirect: true,
AuthGuard: true,
ApiKeys: true,
AuditLogs: true,
ManageOverview: true,
ManageTenants: true,
ManageOrgChart: true,
ManageWorksmobile: true,
ManageOrySSOT: true,
ManageDataIntegrity: true,
ManageUsers: true,
ManagePermissionsDirect: true,
ManageAuthGuard: true,
ManageApiKeys: true,
ManageAuditLogs: true,
}
} else {
// Query Keto in parallel for maximum performance
type checkResult struct {
menu string
allowed bool
}
menus := map[string]string{
"overview": "access_overview",
"manage_overview": "manage_overview",
"tenants": "access_tenants",
"manage_tenants": "manage_tenants",
"org_chart": "access_org_chart",
"manage_org_chart": "manage_org_chart",
"worksmobile": "access_worksmobile",
"manage_worksmobile": "manage_worksmobile",
"ory_ssot": "access_ory_ssot",
"manage_ory_ssot": "manage_ory_ssot",
"data_integrity": "access_data_integrity",
"manage_data_integrity": "manage_data_integrity",
"users": "access_users",
"manage_users": "manage_users",
"permissions_direct": "access_permissions_direct",
"manage_permissions_direct": "manage_permissions_direct",
"auth_guard": "access_auth_guard",
"manage_auth_guard": "manage_auth_guard",
"api_keys": "access_api_keys",
"manage_api_keys": "manage_api_keys",
"audit_logs": "access_audit_logs",
"manage_audit_logs": "manage_audit_logs",
}
ch := make(chan checkResult, len(menus))
for m, rel := range menus {
go func(menuName, relation string) {
allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation)
ch <- checkResult{menu: menuName, allowed: allowed}
}(m, rel)
}
for range menus {
res := <-ch
switch res.menu {
case "overview":
sp.Overview = res.allowed
case "manage_overview":
sp.ManageOverview = res.allowed
case "tenants":
sp.Tenants = res.allowed
case "manage_tenants":
sp.ManageTenants = res.allowed
case "org_chart":
sp.OrgChart = res.allowed
case "manage_org_chart":
sp.ManageOrgChart = res.allowed
case "worksmobile":
sp.Worksmobile = res.allowed
case "manage_worksmobile":
sp.ManageWorksmobile = res.allowed
case "ory_ssot":
sp.OrySSOT = res.allowed
case "manage_ory_ssot":
sp.ManageOrySSOT = res.allowed
case "data_integrity":
sp.DataIntegrity = res.allowed
case "manage_data_integrity":
sp.ManageDataIntegrity = res.allowed
case "users":
sp.Users = res.allowed
case "manage_users":
sp.ManageUsers = res.allowed
case "permissions_direct":
sp.PermissionsDirect = res.allowed
case "manage_permissions_direct":
sp.ManagePermissionsDirect = res.allowed
case "auth_guard":
sp.AuthGuard = res.allowed
case "manage_auth_guard":
sp.ManageAuthGuard = res.allowed
case "api_keys":
sp.ApiKeys = res.allowed
case "manage_api_keys":
sp.ManageApiKeys = res.allowed
case "audit_logs":
sp.AuditLogs = res.allowed
case "manage_audit_logs":
sp.ManageAuditLogs = res.allowed
}
}
}
profile.SystemPermissions = &sp
}
return profile
}
@@ -8426,7 +8545,7 @@ func buildHydraAuthorizationURL(clientID string, scopes []string, redirectURIs [
seen := map[string]struct{}{}
for _, scope := range append([]string{"openid"}, scopes...) {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -464,7 +464,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
appendIfPresent := func(scope string) {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
return
}
if _, ok := seen[scope]; ok {
@@ -485,7 +485,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
for _, scope := range combined {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -153,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
[]string{"openid", "offline", "profile", "offline_access"},
)
assert.Equal(t, []string{"openid", "tenant", "profile", "email"}, merged)
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
}
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
@@ -166,10 +167,11 @@ func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T)
parsed, err := url.Parse(urlString)
assert.NoError(t, err)
scopes := parsed.Query().Get("scope")
scopeItems := strings.Fields(scopes)
assert.Equal(t, "openid profile email", scopes)
assert.NotContains(t, scopes, "offline")
assert.NotContains(t, scopes, "offline_access")
assert.Equal(t, "openid profile offline_access email", scopes)
assert.NotContains(t, scopeItems, "offline")
assert.Contains(t, scopeItems, "offline_access")
}
func TestGetConsentRequest_DeniesTenantAccess(t *testing.T) {

View File

@@ -3848,7 +3848,7 @@ func normalizeClientScopes(scopes []string) []string {
seen := make(map[string]struct{}, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" || isRefreshTokenScopeAlias(scope) {
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
continue
}
if _, ok := seen[scope]; ok {
@@ -3860,9 +3860,9 @@ func normalizeClientScopes(scopes []string) []string {
return normalized
}
func isRefreshTokenScopeAlias(scope string) bool {
func isLegacyRefreshTokenScopeAlias(scope string) bool {
switch strings.ToLower(strings.TrimSpace(scope)) {
case "offline", "offline_access":
case "offline":
return true
default:
return false

View File

@@ -2229,9 +2229,9 @@ func TestCreateClient_StripsOfflineScopesAndKeepsRefreshTokenGrant(t *testing.T)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "openid profile email", captured.Scope)
assert.Equal(t, "openid profile offline_access email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}
@@ -2296,9 +2296,9 @@ func TestUpdateClient_StripsStoredOfflineScopesAndKeepsRefreshTokenGrant(t *test
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "openid profile email", captured.Scope)
assert.Equal(t, "openid profile offline_access email", captured.Scope)
assert.NotContains(t, strings.Fields(captured.Scope), "offline")
assert.NotContains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, strings.Fields(captured.Scope), "offline_access")
assert.Contains(t, captured.GrantTypes, "refresh_token")
}

View File

@@ -84,20 +84,36 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
h.Worksmobile = syncer
}
type tenantPermissions struct {
View bool `json:"view"`
Manage bool `json:"manage"`
ManageAdmins bool `json:"manage_admins"`
ViewProfile bool `json:"view_profile"`
ManageProfile bool `json:"manage_profile"`
ViewPermissions bool `json:"view_permissions"`
ManagePermissions bool `json:"manage_permissions"`
ViewOrganization bool `json:"view_organization"`
ManageOrganization bool `json:"manage_organization"`
ViewSchema bool `json:"view_schema"`
ManageSchema bool `json:"manage_schema"`
}
type tenantSummary struct {
ID string `json:"id"`
Type string `json:"type"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
MemberCount int64 `json:"memberCount"`
TotalMemberCount int64 `json:"totalMemberCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Type string `json:"type"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
MemberCount int64 `json:"memberCount"`
TotalMemberCount int64 `json:"totalMemberCount"`
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type tenantListResponse struct {
@@ -1709,6 +1725,83 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
summary.MemberCount = memberCounts[tenant.ID]
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
// Populate Keto-based permissions for the current user
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if ok && profile != nil {
role := domain.NormalizeRole(profile.Role)
if role == domain.RoleSuperAdmin {
summary.UserPermissions = &tenantPermissions{
View: true,
Manage: true,
ManageAdmins: true,
ViewProfile: true,
ManageProfile: true,
ViewPermissions: true,
ManagePermissions: true,
ViewOrganization: true,
ManageOrganization: true,
ViewSchema: true,
ManageSchema: true,
}
} else {
// Query Keto in parallel for maximum performance
subject := "User:" + profile.ID
type checkResult struct {
relation string
allowed bool
err error
}
ch := make(chan checkResult, 11)
relations := []string{
"view", "manage", "manage_admins",
"view_profile", "manage_profile",
"view_permissions", "manage_permissions",
"view_organization", "manage_organization",
"view_schema", "manage_schema",
}
for _, rel := range relations {
go func(r string) {
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
ch <- checkResult{relation: r, allowed: allowed, err: err}
}(rel)
}
perms := &tenantPermissions{}
for range relations {
res := <-ch
if res.err != nil {
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
continue
}
switch res.relation {
case "view":
perms.View = res.allowed
case "manage":
perms.Manage = res.allowed
case "manage_admins":
perms.ManageAdmins = res.allowed
case "view_profile":
perms.ViewProfile = res.allowed
case "manage_profile":
perms.ManageProfile = res.allowed
case "view_permissions":
perms.ViewPermissions = res.allowed
case "manage_permissions":
perms.ManagePermissions = res.allowed
case "view_organization":
perms.ViewOrganization = res.allowed
case "manage_organization":
perms.ManageOrganization = res.allowed
case "view_schema":
perms.ViewSchema = res.allowed
case "manage_schema":
perms.ManageSchema = res.allowed
}
}
summary.UserPermissions = perms
}
}
return c.JSON(summary)
}
@@ -3652,3 +3745,419 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
"sharedWith": link.Name,
})
}
type tenantRelationRequest struct {
UserID string `json:"userId"`
Relation string `json:"relation"`
}
func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
type userRelationInfo struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
}
userMap := make(map[string][]string)
for _, rel := range relations {
if !allowedRelations[rel.Relation] {
continue
}
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
userMap[userID] = append(userMap[userID], rel.Relation)
}
items := []userRelationInfo{}
for userID, rels := range userMap {
name := "Unknown"
email := "Unknown"
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
items = append(items, userRelationInfo{
UserID: userID,
Name: name,
Email: email,
Relations: rels,
})
}
return c.JSON(fiber.Map{
"items": items,
})
}
func (h *TenantHandler) AddRelation(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
allowedRelations := map[string]bool{
"profile_viewers": true,
"profile_managers": true,
"permissions_viewers": true,
"permissions_managers": true,
"organization_viewers": true,
"organization_managers": true,
"schema_viewers": true,
"schema_managers": true,
}
if !allowedRelations[req.Relation] {
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
}
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionCreate,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionDelete,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
"overview_managers": true,
"tenants_managers": true,
"org_chart_managers": true,
"worksmobile_managers": true,
"ory_ssot_managers": true,
"data_integrity_managers": true,
"users_managers": true,
"permissions_direct_managers": true,
"auth_guard_managers": true,
"api_keys_managers": true,
"audit_logs_managers": true,
}
type userRelationInfo struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
}
userMap := make(map[string][]string)
for _, rel := range relations {
if !allowedRelations[rel.Relation] {
continue
}
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
userMap[userID] = append(userMap[userID], rel.Relation)
}
items := []userRelationInfo{}
for userID, rels := range userMap {
name := "Unknown"
email := "Unknown"
if h.KratosAdmin != nil {
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
}
}
if name == "Unknown" && email == "Unknown" && h.UserRepo != nil {
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
items = append(items, userRelationInfo{
UserID: userID,
Name: name,
Email: email,
Relations: rels,
})
}
return c.JSON(fiber.Map{
"items": items,
})
}
func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
allowedRelations := map[string]bool{
"overview_viewers": true,
"tenants_viewers": true,
"org_chart_viewers": true,
"worksmobile_viewers": true,
"ory_ssot_viewers": true,
"data_integrity_viewers": true,
"users_viewers": true,
"permissions_direct_viewers": true,
"auth_guard_viewers": true,
"api_keys_viewers": true,
"audit_logs_viewers": true,
"overview_managers": true,
"tenants_managers": true,
"org_chart_managers": true,
"worksmobile_managers": true,
"ory_ssot_managers": true,
"data_integrity_managers": true,
"users_managers": true,
"permissions_direct_managers": true,
"auth_guard_managers": true,
"api_keys_managers": true,
"audit_logs_managers": true,
}
if !allowedRelations[req.Relation] {
return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.")
}
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.CreateRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionCreate,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error {
var req tenantRelationRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.UserID == "" || req.Relation == "" {
return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required")
}
var directWriteErr error
if h.Keto != nil {
directWriteErr = h.Keto.DeleteRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID)
}
if h.KetoOutbox != nil {
status := domain.KetoOutboxStatusPending
var processedAt *time.Time
if directWriteErr == nil && h.Keto != nil {
status = domain.KetoOutboxStatusProcessed
now := time.Now()
processedAt = &now
}
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "system",
Relation: req.Relation,
Subject: "User:" + req.UserID,
Action: domain.KetoOutboxActionDelete,
Status: status,
ProcessedAt: processedAt,
})
}
if directWriteErr != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 실패: "+directWriteErr.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@@ -0,0 +1,151 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
t.Fatalf("failed to migrate tenant domains: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000010",
Name: "Super Admin Test Tenant",
Slug: "super-admin-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
}
// We'll simulate middleware setting "user_profile" for a Super Admin
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
profile := &domain.UserProfileResponse{
ID: "user-super-admin-id",
Role: domain.RoleSuperAdmin,
}
c.Locals("user_profile", profile)
return h.GetTenant(c)
})
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got tenantSummary
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
assert.Equal(t, "Super Admin Test Tenant", got.Name)
assert.NotNil(t, got.UserPermissions)
assert.True(t, got.UserPermissions.View)
assert.True(t, got.UserPermissions.Manage)
assert.True(t, got.UserPermissions.ManageAdmins)
}
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
t.Fatalf("failed to migrate tenant domains: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenant := domain.Tenant{
ID: "00000000-0000-0000-0000-000000000020",
Name: "Normal User Test Tenant",
Slug: "normal-user-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
app := fiber.New()
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
}
// Mock Keto response: allowed view/manage but not manage_admins
subject := "User:user-normal-id"
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe()
// We'll simulate middleware setting "user_profile" for a regular admin/user
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
profile := &domain.UserProfileResponse{
ID: "user-normal-id",
Role: domain.RoleUser,
}
c.Locals("user_profile", profile)
return h.GetTenant(c)
})
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got tenantSummary
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
assert.Equal(t, "Normal User Test Tenant", got.Name)
assert.NotNil(t, got.UserPermissions)
assert.True(t, got.UserPermissions.View)
assert.True(t, got.UserPermissions.Manage)
assert.False(t, got.UserPermissions.ManageAdmins)
mockKeto.AssertExpectations(t)
}

View File

@@ -0,0 +1,276 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/testsupport"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestTenantHandler_Relations(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate tenant domains or outbox: %v", err)
}
// Create a test tenant in DB with a valid UUID
tenantID := "00000000-0000-0000-0000-000000000030"
tenant := domain.Tenant{
ID: tenantID,
Name: "Relation Test Tenant",
Slug: "relation-test-tenant",
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("failed to create tenant: %v", err)
}
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
realOutbox := repository.NewKetoOutboxRepository(db)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
KetoOutbox: realOutbox,
}
userID := "user-relation-1"
t.Run("ListRelations - Returns correct relations aggregated by user", func(t *testing.T) {
app := fiber.New()
app.Get("/tenants/:id/relations", h.ListRelations)
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "", "").Return([]service.RelationTuple{
{
Namespace: "Tenant",
Object: tenantID,
Relation: "schema_managers",
SubjectID: "User:" + userID,
},
{
Namespace: "Tenant",
Object: tenantID,
Relation: "profile_viewers",
SubjectID: "User:" + userID,
},
{
Namespace: "Tenant",
Object: tenantID,
Relation: "unrelated_relation", // Should be filtered out
SubjectID: "User:" + userID,
},
}, nil).Once()
req := httptest.NewRequest("GET", "/tenants/"+tenantID+"/relations", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got struct {
Items []struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Relations []string `json:"relations"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Len(t, got.Items, 1)
assert.Equal(t, userID, got.Items[0].UserID)
assert.Contains(t, got.Items[0].Relations, "schema_managers")
assert.Contains(t, got.Items[0].Relations, "profile_viewers")
assert.NotContains(t, got.Items[0].Relations, "unrelated_relation")
mockKeto.AssertExpectations(t)
})
t.Run("AddRelation - Inserts into KetoOutbox DB table", func(t *testing.T) {
app := fiber.New()
app.Post("/tenants/:id/relations", h.AddRelation)
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "schema_managers",
})
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify row was written to the keto_outboxes DB table
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
mockKeto.AssertExpectations(t)
})
t.Run("RemoveRelation - Inserts delete action into KetoOutbox DB table", func(t *testing.T) {
app := fiber.New()
app.Delete("/tenants/:id/relations", h.RemoveRelation)
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "schema_managers",
})
req := httptest.NewRequest("DELETE", "/tenants/"+tenantID+"/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
// Verify delete action row was written to the keto_outboxes DB table
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionDelete).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
})
}
func TestTenantHandler_SystemRelations(t *testing.T) {
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
db := newTenantHandlerSeedDeleteDB(t)
if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil {
t.Fatalf("failed to migrate outbox: %v", err)
}
mockSvc := new(MockTenantService)
mockKeto := new(devMockKetoService)
realOutbox := repository.NewKetoOutboxRepository(db)
h := &TenantHandler{
DB: db,
Service: mockSvc,
Keto: mockKeto,
KetoOutbox: realOutbox,
}
userID := "user-system-1"
t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) {
app := fiber.New()
app.Get("/system/relations", h.ListSystemRelations)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{
{
Namespace: "System",
Object: "system",
Relation: "ory_ssot_viewers",
SubjectID: "User:" + userID,
},
{
Namespace: "System",
Object: "system",
Relation: "audit_logs_viewers",
SubjectID: "User:" + userID,
},
}, nil).Once()
req := httptest.NewRequest("GET", "/system/relations", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got struct {
Items []struct {
UserID string `json:"userId"`
Relations []string `json:"relations"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&got)
if err != nil {
t.Fatalf("failed to decode response: %v", err)
}
assert.Len(t, got.Items, 1)
assert.Equal(t, userID, got.Items[0].UserID)
assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers")
assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers")
mockKeto.AssertExpectations(t)
})
t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) {
app := fiber.New()
app.Post("/system/relations", h.AddSystemRelation)
mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
mockKeto.On("CreateRelation", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return(nil).Once()
body, _ := json.Marshal(map[string]string{
"userId": userID,
"relation": "ory_ssot_viewers",
})
req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
assert.Equal(t, http.StatusOK, resp.StatusCode)
var outboxEntries []domain.KetoOutbox
if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
t.Fatalf("failed to query outbox: %v", err)
}
assert.Len(t, outboxEntries, 1)
assert.Equal(t, "System", outboxEntries[0].Namespace)
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status)
mockKeto.AssertExpectations(t)
})
}