forked from baron/baron-sso
Merge origin/dev into dev
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
151
backend/internal/handler/tenant_handler_get_test.go
Normal file
151
backend/internal/handler/tenant_handler_get_test.go
Normal 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)
|
||||
}
|
||||
276
backend/internal/handler/tenant_handler_relations_test.go
Normal file
276
backend/internal/handler/tenant_handler_relations_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user