From cea0c01a810895ebeb26433cdc6f7b9ea73e026f Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Mon, 31 Aug 2026 11:24:46 +0530 Subject: [PATCH] sudo flag to disable cloud editing --- cli/cmd/sudo/project/edit.go | 75 ++++++++++++++++++++++++++++++----- runtime/feature_flags.go | 16 ++++++++ runtime/feature_flags_test.go | 37 +++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/cli/cmd/sudo/project/edit.go b/cli/cmd/sudo/project/edit.go index 88d9c986cf63..665e1715880b 100644 --- a/cli/cmd/sudo/project/edit.go +++ b/cli/cmd/sudo/project/edit.go @@ -5,6 +5,7 @@ import ( "github.com/rilldata/rill/cli/pkg/cmdutil" adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + "github.com/rilldata/rill/runtime" "github.com/spf13/cobra" ) @@ -12,6 +13,7 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { var prodSlots, devSlots int var prodVersion string var overrideDiskGB int64 + var cloudEditingDisabled bool editCmd := &cobra.Command{ Use: "edit ", @@ -26,18 +28,18 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { SuperuserForceAccess: true, } - isEditRequested := false + isProjectEditRequested := false if cmd.Flags().Changed("prod-slots") { if prodSlots <= 0 { return fmt.Errorf("--prod-slots must be greater than zero") } prodSlotsInt64 := int64(prodSlots) req.ProdSlots = &prodSlotsInt64 - isEditRequested = true + isProjectEditRequested = true } if cmd.Flags().Changed("prod-version") { req.ProdVersion = &prodVersion - isEditRequested = true + isProjectEditRequested = true } if cmd.Flags().Changed("dev-slots") { if devSlots <= 0 { @@ -45,7 +47,7 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { } devSlotsInt64 := int64(devSlots) req.DevSlots = &devSlotsInt64 - isEditRequested = true + isProjectEditRequested = true } if cmd.Flags().Changed("override-disk-gb") { if overrideDiskGB < 0 { @@ -53,10 +55,11 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { } v := overrideDiskGB req.OverrideDiskGb = &v - isEditRequested = true + isProjectEditRequested = true } - if !isEditRequested { + isCloudEditingEditRequested := cmd.Flags().Changed("cloud-editing-disabled") + if !isProjectEditRequested && !isCloudEditingEditRequested { ch.Printf("No edit requested\n") return nil } @@ -66,13 +69,43 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { return err } - updatedProj, err := client.UpdateProject(ctx, req) - if err != nil { - return err + var updatedProject *adminv1.Project + if isProjectEditRequested { + res, err := client.UpdateProject(ctx, req) + if err != nil { + return err + } + updatedProject = res.Project + } + + if isCloudEditingEditRequested { + res, err := client.GetProject(ctx, &adminv1.GetProjectRequest{ + Org: args[0], + Project: args[1], + SuperuserForceAccess: true, + }) + if err != nil { + return err + } + + annotations, changed := setCloudEditingDisabledAnnotation(res.Project.Annotations, cloudEditingDisabled) + if changed { + updatedAnnotations, err := client.SudoUpdateAnnotations(ctx, &adminv1.SudoUpdateAnnotationsRequest{ + Org: args[0], + Project: args[1], + Annotations: annotations, + }) + if err != nil { + return err + } + updatedProject = updatedAnnotations.Project + } else { + updatedProject = res.Project + } } ch.PrintfSuccess("Updated project\n") - ch.PrintProjects([]*adminv1.Project{updatedProj.Project}) + ch.PrintProjects([]*adminv1.Project{updatedProject}) return nil }, @@ -82,5 +115,27 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command { editCmd.Flags().IntVar(&devSlots, "dev-slots", 0, "Slots to allocate for dev deployments") editCmd.Flags().StringVar(&prodVersion, "prod-version", "", "Rill version for production deployment") editCmd.Flags().Int64Var(&overrideDiskGB, "override-disk-gb", 0, "Override disk size in GB for prod and dev deployments (0 clears the override)") + editCmd.Flags().BoolVar(&cloudEditingDisabled, "cloud-editing-disabled", false, "Hide cloud editing in the UI even when enabled in rill.yaml") return editCmd } + +func setCloudEditingDisabledAnnotation(annotations map[string]string, disabled bool) (map[string]string, bool) { + res := make(map[string]string, len(annotations)+1) + for k, v := range annotations { + res[k] = v + } + + if disabled { + if res[runtime.CloudEditingDisabledAnnotation] == "true" { + return res, false + } + res[runtime.CloudEditingDisabledAnnotation] = "true" + return res, true + } + + if _, ok := res[runtime.CloudEditingDisabledAnnotation]; !ok { + return res, false + } + delete(res, runtime.CloudEditingDisabledAnnotation) + return res, true +} diff --git a/runtime/feature_flags.go b/runtime/feature_flags.go index b153d1fc07db..78da31d9da6e 100644 --- a/runtime/feature_flags.go +++ b/runtime/feature_flags.go @@ -3,6 +3,7 @@ package runtime import ( "context" "fmt" + "strconv" "strings" "github.com/iancoleman/strcase" @@ -11,6 +12,10 @@ import ( "golang.org/x/exp/maps" ) +// CloudEditingDisabledAnnotation is an admin-managed project annotation that +// disables the cloud editing UI regardless of the cloud_editing feature flag. +const CloudEditingDisabledAnnotation = "cloud_editing_disabled" + // FeatureFlags finds and resolves the feature flags for the given instance ID and claims. // It's designed for use in the backend. Use runtime.ResolveFeatureFlags for resolving flags that will be exposed to the UI. func (r *Runtime) FeatureFlags(ctx context.Context, instanceID string, claims *SecurityClaims) (map[string]bool, error) { @@ -116,6 +121,17 @@ func ResolveFeatureFlags(inst *drivers.Instance, userAttributes map[string]any, featureFlags[k] = bv } + // Admin-managed disables take precedence over project-configured feature + // flags. This is intentionally a UI-only control; backend permissions for + // editable deployments are enforced independently. + cloudEditingKey := "cloud_editing" + if camelCase { + cloudEditingKey = "cloudEditing" + } + if disabled, _ := strconv.ParseBool(inst.Annotations[CloudEditingDisabledAnnotation]); disabled { + featureFlags[cloudEditingKey] = false + } + // Apply feature flag dependencies: // If chat is disabled, dashboard_chat should also be disabled chatKey := "chat" diff --git a/runtime/feature_flags_test.go b/runtime/feature_flags_test.go index 33d2f38ff074..998f218dc3cd 100644 --- a/runtime/feature_flags_test.go +++ b/runtime/feature_flags_test.go @@ -149,3 +149,40 @@ func Test_ResolveFeatureFlags(t *testing.T) { }) } } + +func Test_ResolveFeatureFlags_CloudEditingDisabledAnnotation(t *testing.T) { + tests := []struct { + name string + annotationValue string + expected bool + }{ + {name: "annotation absent", expected: true}, + {name: "annotation false", annotationValue: "false", expected: true}, + {name: "annotation true", annotationValue: "true", expected: false}, + {name: "annotation true case insensitive", annotationValue: "TRUE", expected: false}, + {name: "annotation invalid", annotationValue: "invalid", expected: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + annotations := map[string]string{} + if test.annotationValue != "" { + annotations[CloudEditingDisabledAnnotation] = test.annotationValue + } + + for _, camelCase := range []bool{false, true} { + featureFlags, err := ResolveFeatureFlags(&drivers.Instance{ + FeatureFlags: map[string]string{"cloud_editing": "true"}, + Annotations: annotations, + }, nil, camelCase) + require.NoError(t, err) + + key := "cloud_editing" + if camelCase { + key = "cloudEditing" + } + require.Equal(t, test.expected, featureFlags[key]) + } + }) + } +}