diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fabc6639..080fe23f 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -662,12 +662,12 @@ func main() { // Relying Party Management (Tenant Context) admin.Post("/tenants/:tenantId/relying-parties", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"), relyingPartyHandler.Create) admin.Get("/tenants/:tenantId/relying-parties", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"), relyingPartyHandler.List) admin.Get("/relying-parties/:id", @@ -677,7 +677,7 @@ func main() { admin.Put("/relying-parties/:id", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "edit_config"), relyingPartyHandler.Update) admin.Delete("/relying-parties/:id", diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 93dbbea3..b3f248e7 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -254,6 +254,88 @@ func managedClientIDsFromProfile(profile *domain.UserProfileResponse) map[string return ids } +func ketoSubjectFromProfile(profile *domain.UserProfileResponse) string { + if profile == nil { + return "" + } + id := strings.TrimSpace(profile.ID) + if id == "" { + return "" + } + return "User:" + id +} + +func (h *DevHandler) checkProfileKetoPermission(c *fiber.Ctx, profile *domain.UserProfileResponse, namespace, object, relation string) (bool, error) { + if profile == nil { + return false, nil + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true, nil + } + if h.Keto == nil { + return false, nil + } + subject := ketoSubjectFromProfile(profile) + if subject == "" { + return false, nil + } + return h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation) +} + +func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true + } + + clientTenantID := resolveClientTenantID(summary) + if clientTenantID != "" { + if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed { + return true + } + } + + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") + return err == nil && allowed +} + +func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { + if strings.TrimSpace(tenantID) == "" { + return false + } + allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions") + return err == nil && allowed +} + +func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, relation) + return err == nil && allowed +} + +func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + + 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) +} + func resolveClientTenantID(summary clientSummary) string { if summary.Metadata == nil { return "" @@ -364,7 +446,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { } // Check with Keto: System:global#manage_all - allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "global", "manage_all") + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+subject, "System", "global", "manage_all") if err != nil { // Fail closed for dev private endpoints: deny on permission backend error. slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err) @@ -443,7 +525,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { } // Check with Keto: System:global#manage_all - allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "global", "manage_all") + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+tokenSubject, "System", "global", "manage_all") if err != nil { // Fail closed for dev private endpoints: deny on permission backend error. slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err) @@ -589,9 +671,6 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { userTenantID := tenantIDFromProfile(profile) isSuperAdmin := role == domain.RoleSuperAdmin allowedClientIDs := managedClientIDsFromProfile(profile) - if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") - } isAppManager, err := h.checkAppManagerPermission(c) if err != nil { @@ -626,18 +705,24 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant if !isSuperAdmin { clientTenantID, _ := summary.Metadata["tenant_id"].(string) - if clientTenantID != userTenantID { + if clientTenantID != userTenantID && !h.canViewClientByPermit(c, profile, summary) { continue } } - // 3. [Role Scope] RP Admin can only access managed RP IDs - if role == domain.RoleRPAdmin { + // 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists + if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { if _, ok := allowedClientIDs[summary.ID]; !ok { - continue + if !h.canViewClientByPermit(c, profile, summary) { + continue + } } } + if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { + continue + } + items = append(items, summary) } @@ -677,16 +762,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] Check if user has access to this client - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -784,16 +860,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "change_status") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -847,6 +914,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } + if tenantID == "" && profile.TenantID != nil { + tenantID = *profile.TenantID + } + if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required") + } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { @@ -1035,16 +1108,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(currentSummary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, currentSummary.ID) { + if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1214,16 +1278,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "manage") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1273,7 +1328,15 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if !isRPAdminClientAllowed(profile, clientID) { + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + summary := h.mapClientSummary(*client) + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "view_consents") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1390,8 +1453,18 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if clientID != "" && !isRPAdminClientAllowed(profile, clientID) { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") + if clientID != "" { + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + summary := h.mapClientSummary(*client) + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "revoke_consents") { + return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") + } } // If subject is not a UUID, try to resolve it as an identifier (email/username) @@ -1454,15 +1527,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { } // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "rotate_secret") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1550,15 +1615,7 @@ func (h *DevHandler) RefreshHeadlessJWKSCache(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index b9a73fa5..f6804b8f 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -510,6 +510,52 @@ func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode) } +func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-b", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "rp-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -554,6 +600,59 @@ func TestRotateClientSecret_Success(t *testing.T) { assert.Equal(t, res.Client.ClientSecret, dbS) } +func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + body["client_secret"] = "generated-secret" + return httpJSONAny(r, http.StatusCreated, body), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil) + + secretRepo := &mockSecretRepo{secrets: make(map[string]string)} + redisRepo := &devMockRedisRepo{data: make(map[string]string)} + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: secretRepo, + Redis: redisRepo, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "rp-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "id": "client-1", + "name": "App One", + "type": "pkce", + "redirectUris": []string{"http://localhost/cb"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + func TestGetStats_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" {