From 343292ac120d18cea4fecf05930020f8592db8f8 Mon Sep 17 00:00:00 2001 From: opspawn Date: Fri, 20 Feb 2026 21:10:10 +0000 Subject: [PATCH 1/3] feat: add read-only mode for GitOps deployments (#1344) Add read-only mode to both the Go backend and Next.js frontend, allowing kagent to be deployed in GitOps environments where resources are managed declaratively. Backend: - Add ReadOnlyAuthorizer that allows VerbGet but rejects create/update/delete - Wire via KAGENT_READ_ONLY=true environment variable - Add comprehensive tests for the new authorizer Frontend: - Add ReadOnlyProvider context with useReadOnly() hook - Hide Create dropdown in Header navigation - Hide Edit/Delete overlays on agent cards and model rows - Hide chat session delete option - Hide Add/Remove buttons on MCP servers page - Add route guards redirecting /agents/new and /models/new to / - Skip onboarding wizard in read-only mode - Show 'Resources are managed via GitOps' in empty states - Wire via NEXT_PUBLIC_READ_ONLY=true environment variable Closes #1344 Signed-off-by: opspawn --- go/cmd/controller/main.go | 11 +- go/internal/httpserver/auth/authz.go | 15 +++ go/internal/httpserver/auth/authz_test.go | 60 +++++++++++ ui/src/app/agents/new/page.tsx | 8 +- ui/src/app/layout.tsx | 35 +++--- ui/src/app/models/new/page.tsx | 9 +- ui/src/app/models/page.tsx | 70 ++++++------ ui/src/app/servers/page.tsx | 73 +++++++------ ui/src/components/AgentCard.tsx | 34 +++--- ui/src/components/AgentList.tsx | 22 ++-- ui/src/components/AppInitializer.tsx | 7 +- ui/src/components/Header.tsx | 124 +++++++++++----------- ui/src/components/ReadOnlyProvider.tsx | 19 ++++ ui/src/components/sidebars/ChatItem.tsx | 48 +++++---- 14 files changed, 348 insertions(+), 187 deletions(-) create mode 100644 go/internal/httpserver/auth/authz_test.go create mode 100644 ui/src/components/ReadOnlyProvider.tsx diff --git a/go/cmd/controller/main.go b/go/cmd/controller/main.go index 2049e6962..614ce18c2 100644 --- a/go/cmd/controller/main.go +++ b/go/cmd/controller/main.go @@ -17,8 +17,12 @@ limitations under the License. package main import ( + "os" + "strings" + "github.com/kagent-dev/kagent/go/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/pkg/app" + pkgauth "github.com/kagent-dev/kagent/go/pkg/auth" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -27,7 +31,12 @@ import ( //nolint:gocyclo func main() { - authorizer := &auth.NoopAuthorizer{} + var authorizer pkgauth.Authorizer + if strings.EqualFold(os.Getenv("KAGENT_READ_ONLY"), "true") { + authorizer = &auth.ReadOnlyAuthorizer{} + } else { + authorizer = &auth.NoopAuthorizer{} + } authenticator := &auth.UnsecureAuthenticator{} app.Start(func(bootstrap app.BootstrapConfig) (*app.ExtensionConfig, error) { return &app.ExtensionConfig{ diff --git a/go/internal/httpserver/auth/authz.go b/go/internal/httpserver/auth/authz.go index e23c696a8..8fd30354c 100644 --- a/go/internal/httpserver/auth/authz.go +++ b/go/internal/httpserver/auth/authz.go @@ -2,6 +2,7 @@ package auth import ( "context" + "fmt" "github.com/kagent-dev/kagent/go/pkg/auth" ) @@ -13,3 +14,17 @@ func (a *NoopAuthorizer) Check(ctx context.Context, principal auth.Principal, ve } var _ auth.Authorizer = (*NoopAuthorizer)(nil) + +// ReadOnlyAuthorizer allows only read (get) operations and rejects all +// mutating requests. This is useful for GitOps deployments where resources +// are managed declaratively and the UI should be view-only. +type ReadOnlyAuthorizer struct{} + +func (a *ReadOnlyAuthorizer) Check(ctx context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { + if verb == auth.VerbGet { + return nil + } + return fmt.Errorf("forbidden: read-only mode is enabled, %s operations on %s are not allowed", verb, resource.Type) +} + +var _ auth.Authorizer = (*ReadOnlyAuthorizer)(nil) diff --git a/go/internal/httpserver/auth/authz_test.go b/go/internal/httpserver/auth/authz_test.go new file mode 100644 index 000000000..986052f4a --- /dev/null +++ b/go/internal/httpserver/auth/authz_test.go @@ -0,0 +1,60 @@ +package auth_test + +import ( + "context" + "testing" + + authimpl "github.com/kagent-dev/kagent/go/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +func TestNoopAuthorizer(t *testing.T) { + authorizer := &authimpl.NoopAuthorizer{} + principal := auth.Principal{User: auth.User{ID: "test-user"}} + resource := auth.Resource{Name: "test-agent", Type: "agents"} + + for _, verb := range []auth.Verb{auth.VerbGet, auth.VerbCreate, auth.VerbUpdate, auth.VerbDelete} { + if err := authorizer.Check(context.Background(), principal, verb, resource); err != nil { + t.Errorf("NoopAuthorizer should allow %s but got error: %v", verb, err) + } + } +} + +func TestReadOnlyAuthorizer_AllowsGet(t *testing.T) { + authorizer := &authimpl.ReadOnlyAuthorizer{} + principal := auth.Principal{User: auth.User{ID: "test-user"}} + resource := auth.Resource{Name: "test-agent", Type: "agents"} + + if err := authorizer.Check(context.Background(), principal, auth.VerbGet, resource); err != nil { + t.Errorf("ReadOnlyAuthorizer should allow get but got error: %v", err) + } +} + +func TestReadOnlyAuthorizer_RejectsMutations(t *testing.T) { + authorizer := &authimpl.ReadOnlyAuthorizer{} + principal := auth.Principal{User: auth.User{ID: "test-user"}} + resource := auth.Resource{Name: "test-agent", Type: "agents"} + + for _, verb := range []auth.Verb{auth.VerbCreate, auth.VerbUpdate, auth.VerbDelete} { + err := authorizer.Check(context.Background(), principal, verb, resource) + if err == nil { + t.Errorf("ReadOnlyAuthorizer should reject %s but allowed it", verb) + } + } +} + +func TestReadOnlyAuthorizer_ErrorMessage(t *testing.T) { + authorizer := &authimpl.ReadOnlyAuthorizer{} + principal := auth.Principal{User: auth.User{ID: "test-user"}} + resource := auth.Resource{Name: "my-agent", Type: "agents"} + + err := authorizer.Check(context.Background(), principal, auth.VerbCreate, resource) + if err == nil { + t.Fatal("expected error for create verb") + } + + expected := "forbidden: read-only mode is enabled, create operations on agents are not allowed" + if err.Error() != expected { + t.Errorf("expected error message %q, got %q", expected, err.Error()) + } +} diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index 4d15e6195..fc80afeba 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -9,8 +9,9 @@ import { ModelConfig, AgentType } from "@/types"; import { SystemPromptSection } from "@/components/create/SystemPromptSection"; import { ModelSelectionSection } from "@/components/create/ModelSelectionSection"; import { ToolsSection } from "@/components/create/ToolsSection"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams, redirect } from "next/navigation"; import { useAgents } from "@/components/AgentsProvider"; +import { useReadOnly } from "@/components/ReadOnlyProvider"; import { LoadingState } from "@/components/LoadingState"; import { ErrorState } from "@/components/ErrorState"; import KagentLogo from "@/components/kagent-logo"; @@ -738,12 +739,17 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo // Main component that wraps the content in a Suspense boundary export default function AgentPage() { + const readOnly = useReadOnly(); // Determine if in edit mode const searchParams = useSearchParams(); const isEditMode = searchParams.get("edit") === "true"; const agentName = searchParams.get("name"); const agentNamespace = searchParams.get("namespace"); + if (readOnly) { + redirect("/"); + } + // Create a key based on the edit mode and agent ID const formKey = isEditMode ? `edit-${agentName}-${agentNamespace}` : 'create'; diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 43f9c75e0..be4336a4f 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Footer } from "@/components/Footer"; import { ThemeProvider } from "@/components/ThemeProvider"; import { Toaster } from "@/components/ui/sonner"; import { AppInitializer } from "@/components/AppInitializer"; +import { ReadOnlyProvider } from "@/components/ReadOnlyProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -20,21 +21,23 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - - - - -
-
{children}
-