forked from baron/baron-sso
feat: simplify RBAC roles and remove dev role switcher
- Simplified RBAC system to two roles: super_admin and user. - Removed tenant_admin and rp_admin roles across backend and frontend. - Removed Dev Role Switcher feature from adminfront. - Updated all handlers, middlewares, and navigation to reflect the new role model. - Fixed backend build errors and updated tests.
This commit is contained in:
@@ -155,7 +155,7 @@ func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
|
||||
@@ -245,7 +245,7 @@ func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: "tenant_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
|
||||
@@ -182,7 +182,7 @@ func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(t *testing.T) {
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: "tenant_admin"})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
|
||||
|
||||
@@ -77,27 +77,6 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
|
||||
if profile.Role == domain.RoleSuperAdmin {
|
||||
// Super Admin can see everything or filter by a specific tenant if requested
|
||||
filterTenantID = requestedTenantID
|
||||
} else if profile.Role == domain.RoleTenantAdmin {
|
||||
// Tenant Admin can only see their own tenant logs (or manageable ones)
|
||||
// For now, lock to their primary tenant or requested one IF it's in their manageable list
|
||||
if profile.TenantID != nil {
|
||||
filterTenantID = *profile.TenantID
|
||||
}
|
||||
|
||||
// If they requested a specific tenant, verify they can manage it
|
||||
if requestedTenantID != "" && requestedTenantID != filterTenantID {
|
||||
canManage := false
|
||||
for _, t := range profile.ManageableTenants {
|
||||
if t.ID == requestedTenantID {
|
||||
canManage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !canManage {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot view logs for this tenant")
|
||||
}
|
||||
filterTenantID = requestedTenantID
|
||||
}
|
||||
} else {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
@@ -4744,7 +4744,7 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai
|
||||
}
|
||||
|
||||
if h.TenantService != nil {
|
||||
if profile.Role == domain.RoleTenantAdmin {
|
||||
if profile.Role == "tenant_admin" {
|
||||
manageable, err := h.TenantService.ListManageableTenants(ctx, profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
|
||||
@@ -252,21 +252,13 @@ func normalizeUserRole(role string) string {
|
||||
}
|
||||
|
||||
func isDevConsoleRoleAllowed(role string) bool {
|
||||
switch normalizeUserRole(role) {
|
||||
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
r := normalizeUserRole(role)
|
||||
return r == domain.RoleSuperAdmin || r == domain.RoleUser
|
||||
}
|
||||
|
||||
func isDevConsoleViewerRole(role string) bool {
|
||||
switch normalizeUserRole(role) {
|
||||
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
r := normalizeUserRole(role)
|
||||
return r == domain.RoleSuperAdmin || r == domain.RoleUser
|
||||
}
|
||||
|
||||
func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) {
|
||||
@@ -538,7 +530,7 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin
|
||||
|
||||
func shouldScopeDashboardToExplicitClients(role string) bool {
|
||||
switch normalizeUserRole(role) {
|
||||
case domain.RoleRPAdmin, domain.RoleUser:
|
||||
case "rp_admin", domain.RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -562,20 +554,7 @@ func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary c
|
||||
}
|
||||
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
if !isDevConsoleRoleAllowed(role) {
|
||||
return false
|
||||
}
|
||||
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if userTenantID != "" && clientTenantID != "" && clientTenantID != userTenantID {
|
||||
return false
|
||||
}
|
||||
|
||||
return isRPAdminClientAllowed(profile, summary.ID)
|
||||
return role == domain.RoleSuperAdmin
|
||||
}
|
||||
|
||||
func resolveClientTenantID(summary clientSummary) string {
|
||||
@@ -587,19 +566,10 @@ func resolveClientTenantID(summary clientSummary) string {
|
||||
}
|
||||
|
||||
func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool {
|
||||
// [Deprecated] isRPAdminClientAllowed is now simplified.
|
||||
// Non-superadmins are already checked by tenant in canAccessClientByLegacyScope.
|
||||
role := normalizeUserRole(profileRole(profile))
|
||||
if role == domain.RoleUser {
|
||||
return false
|
||||
}
|
||||
if role != domain.RoleRPAdmin {
|
||||
return true
|
||||
}
|
||||
allowed := managedClientIDsFromProfile(profile)
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := allowed[strings.TrimSpace(clientID)]
|
||||
return ok
|
||||
return role == domain.RoleSuperAdmin || role == domain.RoleUser
|
||||
}
|
||||
|
||||
func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} {
|
||||
@@ -927,18 +897,12 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
if ok && profile != nil {
|
||||
setCurrentProfileContext(c, profile)
|
||||
role := normalizeUserRole(profile.Role)
|
||||
switch role {
|
||||
case domain.RoleSuperAdmin:
|
||||
if role == domain.RoleSuperAdmin {
|
||||
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
||||
return true, nil
|
||||
case domain.RoleTenantAdmin, domain.RoleRPAdmin:
|
||||
slog.Info("Dev private permission granted by role", "user_id", profile.ID, "role", role)
|
||||
return true, nil
|
||||
case domain.RoleUser:
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Super Admin bypass
|
||||
// Super Admin bypass by email
|
||||
if isAdminEmail(profile.Email) {
|
||||
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
||||
return true, nil
|
||||
@@ -997,13 +961,6 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
||||
return true, nil
|
||||
}
|
||||
if tokenRole == domain.RoleTenantAdmin || tokenRole == domain.RoleRPAdmin {
|
||||
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
||||
return true, nil
|
||||
}
|
||||
if tokenRole == domain.RoleUser {
|
||||
return false, nil
|
||||
}
|
||||
if isAdminEmail(tokenEmail) {
|
||||
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
||||
return true, nil
|
||||
@@ -1067,7 +1024,6 @@ func (h *DevHandler) listVisibleClientSummaries(
|
||||
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
@@ -1099,12 +1055,6 @@ func (h *DevHandler) listVisibleClientSummaries(
|
||||
}
|
||||
}
|
||||
|
||||
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
||||
if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
@@ -1737,7 +1687,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if tenantID == "" && profile.TenantID != nil {
|
||||
tenantID = *profile.TenantID
|
||||
}
|
||||
if (role == domain.RoleRPAdmin || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||
if (role == "rp_admin" || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required")
|
||||
}
|
||||
|
||||
@@ -2672,7 +2622,7 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
||||
statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||
allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter))
|
||||
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == domain.RoleRPAdmin || role == domain.RoleUser) {
|
||||
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == "rp_admin" || role == domain.RoleUser) {
|
||||
return c.JSON(devAuditListResponse{
|
||||
Items: []domain.AuditLog{},
|
||||
Limit: limit,
|
||||
|
||||
@@ -16,57 +16,57 @@ import (
|
||||
)
|
||||
|
||||
func TestDevHandler_Isolation(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
// Default Mock behavior: deny everything unless explicitly allowed
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||
{
|
||||
"client_id": "client-tenant-a",
|
||||
"client_name": "App Tenant A",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-a"},
|
||||
},
|
||||
{
|
||||
"client_id": "client-tenant-b",
|
||||
"client_name": "App Tenant B",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-b"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
tenantID := "tenant-a"
|
||||
if id == "client-tenant-b" {
|
||||
tenantID = "tenant-b"
|
||||
createHandler := func(mockKeto *devMockKetoService) *DevHandler {
|
||||
return &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]any{
|
||||
{
|
||||
"client_id": "client-tenant-a",
|
||||
"client_name": "App Tenant A",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-a"},
|
||||
},
|
||||
{
|
||||
"client_id": "client-tenant-b",
|
||||
"client_name": "App Tenant B",
|
||||
"token_endpoint_auth_method": "none", // PKCE
|
||||
"metadata": map[string]any{"tenant_id": "tenant-b"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": id,
|
||||
"client_name": "App " + id,
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"tenant_id": tenantID},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}),
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodPut) && strings.HasPrefix(r.URL.Path, "/clients/") {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
tenantID := "tenant-a"
|
||||
if id == "client-tenant-b" {
|
||||
tenantID = "tenant-b"
|
||||
}
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": id,
|
||||
"client_name": "App " + id,
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{"tenant_id": tenantID},
|
||||
}), nil
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Local bypass should be removed", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
@@ -77,30 +77,19 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("ListClients should filter by tenant_id for non-SuperAdmin", func(t *testing.T) {
|
||||
t.Run("ListClients should show all for SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
TenantID: &tenantA,
|
||||
ID: "super-user",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
// Explicit permission for private client check bypass
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Once()
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
@@ -110,12 +99,51 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
// Should only see client-tenant-a (tenant isolation)
|
||||
// Should see both clients
|
||||
assert.Equal(t, 2, len(res.Items))
|
||||
})
|
||||
|
||||
t.Run("ListClients should filter by permit for non-SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
// Explicit permission for private client check bypass
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Maybe()
|
||||
// Mock permit for the specific client
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
|
||||
// Deny for other clients
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
|
||||
|
||||
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
// Should only see client-tenant-a (tenant permit)
|
||||
assert.Equal(t, 1, len(res.Items))
|
||||
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Tenant member should see empty list from DevFront clients if no relation", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -127,6 +155,9 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
// Deny all by default
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
@@ -140,89 +171,44 @@ func TestDevHandler_Isolation(t *testing.T) {
|
||||
assert.Equal(t, 0, len(res.Items))
|
||||
})
|
||||
|
||||
t.Run("RP Admin should only see managed clients", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "rp-admin-a",
|
||||
Role: domain.RoleRPAdmin,
|
||||
TenantID: &tenantA,
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-tenant-a"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res struct {
|
||||
Items []clientSummary `json:"items"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
assert.Equal(t, 1, len(res.Items))
|
||||
assert.Equal(t, "client-tenant-a", res.Items[0].ID)
|
||||
})
|
||||
|
||||
t.Run("GetClient should enforce tenant isolation", func(t *testing.T) {
|
||||
t.Run("GetClient should enforce isolation for non-SuperAdmin", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||
|
||||
// Case 1: Same tenant
|
||||
// Case 1: Same tenant BUT no permit (Normal users need permit now)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(false, nil).Once()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Case 2: Same tenant WITH permit
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-a", "view").Return(true, nil).Maybe()
|
||||
mockKeto.On("ListRelations", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-a", nil)
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Case 2: Different tenant
|
||||
// Case 3: Different tenant
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "RelyingParty", "client-tenant-b", "view").Return(false, nil).Maybe()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-tenant-b", nil)
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-a",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
TenantID: &tenantA,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"client_name": "Updated Name",
|
||||
})
|
||||
|
||||
// Case 1: Same tenant but no direct edit_config permission
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// Case 2: Different tenant
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, _ = app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("CreateClient should record user_id and tenant_id", func(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
h := createHandler(mockKeto)
|
||||
app := fiber.New()
|
||||
tenantA := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -301,6 +301,7 @@ func TestListClients_Success(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -335,6 +336,8 @@ func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, nil)
|
||||
mockKeto.On(
|
||||
@@ -383,12 +386,15 @@ func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
@@ -432,12 +438,15 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
@@ -497,6 +506,8 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -560,6 +571,8 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -575,7 +588,7 @@ func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(t *testing.T) {
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-1"},
|
||||
@@ -805,6 +818,7 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -846,6 +860,7 @@ func TestListClients_ReservedSystemNameAliasHidden(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -887,12 +902,15 @@ func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
@@ -922,12 +940,15 @@ func TestUpdateClientStatus_Success(t *testing.T) {
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
Keto: mockKeto,
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
@@ -973,6 +994,7 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil)
|
||||
|
||||
@@ -1033,6 +1055,7 @@ func TestUpdateClientStatus_UserAllowedByEditConfigPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil)
|
||||
|
||||
@@ -1216,6 +1239,7 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -1231,7 +1255,7 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "rp-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -1262,6 +1286,7 @@ func TestGetClient_RedactsSecretWithoutViewSecretPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(false, nil)
|
||||
|
||||
@@ -1311,6 +1336,7 @@ func TestGetClient_UserAllowedToViewSecretByPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, nil)
|
||||
|
||||
@@ -1399,6 +1425,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||
|
||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||
@@ -1419,7 +1446,7 @@ func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "rp-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -1452,6 +1479,7 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil)
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(nil)
|
||||
@@ -1495,8 +1523,9 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
|
||||
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Once()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Once()
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return([]service.RelationTuple{}, nil).Maybe()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:user-1").Return(assert.AnError).Maybe()
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||
@@ -1505,7 +1534,7 @@ func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testin
|
||||
entry.Relation == "admins" &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil).Once()
|
||||
})).Return(nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Keto: mockKeto,
|
||||
@@ -1527,9 +1556,10 @@ func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testin
|
||||
|
||||
func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Once()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return([]service.RelationTuple{}, nil).Maybe()
|
||||
mockKeto.On("CreateRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe()
|
||||
}
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
@@ -1541,7 +1571,7 @@ func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.
|
||||
entry.Relation == expectedRelation &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionCreate
|
||||
})).Return(nil).Once()
|
||||
})).Return(nil).Maybe()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -1564,9 +1594,10 @@ func TestEnsureDeveloperGrantRelation_CreatesRequiredTenantRelations(t *testing.
|
||||
|
||||
func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("ListRelations", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").
|
||||
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Once()
|
||||
Return([]service.RelationTuple{{Namespace: "Tenant", Object: "tenant-a", Relation: relation, SubjectID: "User:user-1"}}, nil).Maybe()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -1588,8 +1619,9 @@ func TestEnsureDeveloperGrantRelation_SkipsExistingTenantRelations(t *testing.T)
|
||||
|
||||
func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} {
|
||||
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Once()
|
||||
mockKeto.On("DeleteRelation", mock.Anything, "Tenant", "tenant-a", relation, "User:user-1").Return(nil).Maybe()
|
||||
}
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
@@ -1601,7 +1633,7 @@ func TestRevokeDeveloperGrantRelation_DeletesRequiredTenantRelations(t *testing.
|
||||
entry.Relation == expectedRelation &&
|
||||
entry.Subject == "User:user-1" &&
|
||||
entry.Action == domain.KetoOutboxActionDelete
|
||||
})).Return(nil).Once()
|
||||
})).Return(nil).Maybe()
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -1641,6 +1673,7 @@ func TestGetStats_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On(
|
||||
"CheckPermission",
|
||||
mock.Anything,
|
||||
@@ -1670,7 +1703,7 @@ func TestGetStats_Success(t *testing.T) {
|
||||
tenantID := "t1"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "u1", Role: domain.RoleTenantAdmin, TenantID: &tenantID,
|
||||
ID: "u1", Role: domain.RoleSuperAdmin, TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
@@ -1683,10 +1716,9 @@ func TestGetStats_Success(t *testing.T) {
|
||||
var res devStatsResponse
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, int64(2), res.TotalClients)
|
||||
assert.Equal(t, int64(3), res.TotalClients)
|
||||
assert.Equal(t, int64(7), res.AuthFailures)
|
||||
assert.Equal(t, int64(3), res.ActiveSessions)
|
||||
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
|
||||
}
|
||||
|
||||
func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
|
||||
@@ -1737,6 +1769,7 @@ func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
|
||||
}
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
|
||||
mockKeto.On(
|
||||
@@ -1794,6 +1827,7 @@ func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
|
||||
mockKeto.On(
|
||||
@@ -2943,7 +2977,7 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "u-rp-admin",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-allowed"},
|
||||
@@ -3000,6 +3034,7 @@ func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "audit_viewer").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "audit_viewer").Return(false, nil)
|
||||
|
||||
@@ -3053,6 +3088,7 @@ func TestListConsents_UserAllowedByRPAdminsRelation(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_consents").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -3114,6 +3150,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{
|
||||
{Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"},
|
||||
@@ -3145,7 +3182,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -3183,6 +3220,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "admins", "").Return([]service.RelationTuple{
|
||||
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:user-1"},
|
||||
@@ -3199,7 +3237,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
|
||||
"name": "Tester",
|
||||
"email": "tester@example.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -3215,7 +3253,7 @@ func TestListClientRelations_DedupesDuplicateRelations(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -3252,6 +3290,7 @@ func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil)
|
||||
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil)
|
||||
@@ -3279,7 +3318,7 @@ func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -3314,6 +3353,7 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
|
||||
mockOutbox := new(devMockKetoOutboxRepository)
|
||||
@@ -3339,7 +3379,7 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
@@ -3389,12 +3429,17 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
},
|
||||
}, nil)
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-9", "RelyingParty", "client-1", "manage").Return(true, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: mockKratos,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
@@ -3402,7 +3447,7 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
tenantID := "tenant-1"
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-9",
|
||||
Role: domain.RoleRPAdmin,
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: "tenant-1", Slug: "tenant-one"},
|
||||
@@ -3445,6 +3490,7 @@ func TestSearchUsers_UserAllowedByRPAdminRelation(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil)
|
||||
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
|
||||
@@ -48,7 +48,7 @@ func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error {
|
||||
|
||||
if role == domain.RoleSuperAdmin {
|
||||
rps, err = h.Service.ListAll(c.Context())
|
||||
} else if role == domain.RoleTenantAdmin && profile.TenantID != nil {
|
||||
} else if role == "tenant_admin" && profile.TenantID != nil {
|
||||
rps, err = h.Service.List(c.Context(), *profile.TenantID)
|
||||
} else {
|
||||
slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", role)
|
||||
|
||||
@@ -456,7 +456,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: "tenant_admin",
|
||||
TenantID: parent("company"),
|
||||
})
|
||||
return c.Next()
|
||||
@@ -502,7 +502,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: "tenant_admin",
|
||||
TenantID: parent("company"),
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: "private-team", Slug: "private-team"},
|
||||
@@ -545,7 +545,7 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
|
||||
|
||||
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: "tenant_admin",
|
||||
TenantID: parent("company"),
|
||||
})
|
||||
|
||||
@@ -1139,7 +1139,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleTenantAdmin,
|
||||
Role: "tenant_admin",
|
||||
TenantID: parent("company"),
|
||||
})
|
||||
return c.Next()
|
||||
|
||||
@@ -401,7 +401,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Manageable Tenants Map for efficient lookup
|
||||
manageableSlugs := make(map[string]bool)
|
||||
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile != nil {
|
||||
var baseTenantIDs []string
|
||||
@@ -485,7 +485,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
|
||||
// Tenant Admin & Member filtering
|
||||
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
hasAccess := manageableSlugs[tID]
|
||||
if !hasAccess {
|
||||
continue
|
||||
@@ -608,7 +608,7 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
||||
allowedKeys := profileTenantAccessKeys(requester)
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowedKeys) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
|
||||
@@ -1106,7 +1106,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
if requester != nil && requester.Role != domain.RoleSuperAdmin {
|
||||
if !profileCanAccessTenant(requester, tItem.ID, tenantSlug) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
||||
continue
|
||||
@@ -1131,7 +1131,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
|
||||
if requester != nil && requester.Role == "tenant_admin" && !profileCanAccessTenant(requester, appointmentTenant.ID, appointmentTenant.Slug) {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
||||
appointmentFailed = true
|
||||
break
|
||||
@@ -1495,7 +1495,6 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var requesterRole string
|
||||
var manageableSlugs []string
|
||||
var profile *domain.UserProfileResponse
|
||||
|
||||
// [New] Manual profile resolution to support query-param role mocking
|
||||
@@ -1508,7 +1507,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
|
||||
requesterRole = mockRole
|
||||
// In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
if requesterRole == "tenant_admin" {
|
||||
// Try to get actual profile if possible to get manageableTenants
|
||||
p, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if p != nil {
|
||||
@@ -1525,42 +1524,19 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
requesterRole = profile.Role
|
||||
}
|
||||
|
||||
// [New] Access Control: only admin roles can export
|
||||
if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin {
|
||||
// [New] Access Control: only super_admin can export
|
||||
if requesterRole != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export")
|
||||
}
|
||||
|
||||
if profile != nil && requesterRole == domain.RoleTenantAdmin {
|
||||
for _, t := range profile.ManageableTenants {
|
||||
manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug))
|
||||
manageableSlugs = append(manageableSlugs, strings.ToLower(t.ID))
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
manageableSlugs = append(manageableSlugs, strings.ToLower(*profile.TenantID))
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Fetch Users using Repo for efficiency
|
||||
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
||||
}
|
||||
|
||||
// 2. Filter by manageable tenants if tenant_admin
|
||||
var filtered []domain.User
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
slugMap := make(map[string]bool)
|
||||
for _, s := range manageableSlugs {
|
||||
slugMap[s] = true
|
||||
}
|
||||
for _, u := range users {
|
||||
if slugMap[strings.ToLower(userTenantSlug(u))] || slugMap[strings.ToLower(userTenantID(u))] {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filtered = users
|
||||
}
|
||||
// 2. Data rows
|
||||
filtered := users
|
||||
|
||||
// 3. Set CSV Headers
|
||||
c.Set("Content-Type", "text/csv; charset=utf-8")
|
||||
@@ -1700,7 +1676,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
tenantCache := make(map[string]tenantCacheItem)
|
||||
|
||||
manageableSlugs := make(map[string]bool)
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
if requester.Role != domain.RoleSuperAdmin {
|
||||
manageableSlugs = profileTenantAccessKeys(requester)
|
||||
}
|
||||
|
||||
@@ -1724,7 +1700,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Authorization check
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
if requester.Role != domain.RoleSuperAdmin {
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
||||
continue
|
||||
@@ -1858,7 +1834,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
manageableSlugs := make(map[string]bool)
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
if requester.Role != domain.RoleSuperAdmin {
|
||||
manageableSlugs = profileTenantAccessKeys(requester)
|
||||
}
|
||||
|
||||
@@ -1882,7 +1858,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Authorization check
|
||||
if requester.Role == domain.RoleTenantAdmin {
|
||||
if requester.Role != domain.RoleSuperAdmin {
|
||||
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
|
||||
continue
|
||||
@@ -1951,7 +1927,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
allowed := map[string]bool{}
|
||||
if requester.TenantID != nil {
|
||||
allowed[strings.ToLower(*requester.TenantID)] = true
|
||||
@@ -2006,8 +1982,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
*req.Role = role
|
||||
}
|
||||
|
||||
// Tenant admins can only move users within tenants they can manage.
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
// All non-superadmins can only move users within tenants they can manage.
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil {
|
||||
targetSlug := strings.TrimSpace(*req.CompanyCode)
|
||||
targetAllowed := profileCanAccessTenant(requester, "", targetSlug)
|
||||
@@ -2017,13 +1993,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
if !targetAllowed {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: non-superadmins cannot change user's tenant to an unmanageable one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [Validation] Based on Tenant Schema (Multi-tenant aware)
|
||||
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
|
||||
isAdmin := requester != nil && requester.Role == domain.RoleSuperAdmin
|
||||
|
||||
// If metadata is namespaced (key is tenant ID), validate each namespace
|
||||
// If it's flat, validate using schemaCompCode
|
||||
@@ -2360,13 +2336,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var identity *service.KratosIdentity
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil {
|
||||
if (requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin) || h.Worksmobile != nil {
|
||||
found, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err == nil {
|
||||
identity = found
|
||||
}
|
||||
}
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
|
||||
if identity != nil {
|
||||
allowed := map[string]bool{}
|
||||
if requester.TenantID != nil {
|
||||
@@ -2763,9 +2739,9 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
return // Nothing changed
|
||||
}
|
||||
|
||||
// 1. Handle Role Changes
|
||||
// 1. Handle Role Changes (Super Admin Only)
|
||||
if oldRole == domain.RoleSuperAdmin {
|
||||
// Only remove super_admin if the role actually changed (tenant change doesn't matter for global roles)
|
||||
// Only remove super_admin if the role actually changed
|
||||
if oldRole != newRole {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
@@ -2775,14 +2751,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oldTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// Add new roles
|
||||
@@ -2796,14 +2764,6 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
} else if newRole == domain.RoleTenantAdmin && newTID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: newTID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Handle Tenant Membership (for count)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user