1
0
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:
2026-06-02 18:29:18 +09:00
parent 57f05e2694
commit 802bf3e91d
32 changed files with 487 additions and 938 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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