Skip to content

Commit 30d297e

Browse files
Add rill sudo embed open command (#9230)
* Add `rill sudo embed open` command and `superuser_force_access` to embed APIs * Test case
1 parent 48fcd20 commit 30d297e

13 files changed

Lines changed: 5074 additions & 4771 deletions

File tree

admin/server/deployment.go

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,30 @@ func (s *Server) GetDeployment(ctx context.Context, req *adminv1.GetDeploymentRe
171171
}
172172

173173
claims := auth.GetClaims(ctx)
174+
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
174175
permissions := claims.ProjectPermissions(ctx, proj.OrganizationID, proj.ID)
175176

176-
if depl.Environment == "dev" {
177-
if !permissions.ReadDev {
178-
return nil, status.Error(codes.PermissionDenied, "does not have permission to read dev deployment")
177+
if !forceAccess {
178+
if depl.Environment == "dev" {
179+
if !permissions.ReadDev {
180+
return nil, status.Error(codes.PermissionDenied, "does not have permission to read dev deployment")
181+
}
182+
} else {
183+
if !permissions.ReadProd {
184+
return nil, status.Error(codes.PermissionDenied, "does not have permission to read prod deployment")
185+
}
179186
}
180-
} else {
181-
if !permissions.ReadProd {
182-
return nil, status.Error(codes.PermissionDenied, "does not have permission to read prod deployment")
187+
188+
if req.For != nil || req.ExternalUserId != "" {
189+
if depl.Environment == "dev" {
190+
if !permissions.ManageDev {
191+
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage dev deployment")
192+
}
193+
} else {
194+
if !permissions.ManageProd {
195+
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage prod deployment")
196+
}
197+
}
183198
}
184199
}
185200

@@ -204,14 +219,6 @@ func (s *Server) GetDeployment(ctx context.Context, req *adminv1.GetDeploymentRe
204219
// Some users use service accounts for generating JWTs for end users without passing req.For, and we don't want to accidentally pass a shared subject ID across those users (which could leak e.g. AI chats).
205220
}
206221
} else if req.For != nil {
207-
if depl.Environment == "prod" && !permissions.ManageProd {
208-
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage prod deployment")
209-
}
210-
211-
if depl.Environment == "dev" && !permissions.ManageDev {
212-
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage dev deployment")
213-
}
214-
215222
switch forVal := req.For.(type) {
216223
case *adminv1.GetDeploymentRequest_UserId:
217224
if req.ExternalUserId != "" {
@@ -583,9 +590,10 @@ func (s *Server) GetDeploymentCredentials(ctx context.Context, req *adminv1.GetD
583590
}
584591

585592
claims := auth.GetClaims(ctx)
593+
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
586594
permissions := claims.ProjectPermissions(ctx, proj.OrganizationID, proj.ID)
587595

588-
if !permissions.ManageProd {
596+
if !forceAccess && !permissions.ManageProd {
589597
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage deployment")
590598
}
591599

@@ -709,9 +717,10 @@ func (s *Server) GetIFrame(ctx context.Context, req *adminv1.GetIFrameRequest) (
709717
}
710718

711719
claims := auth.GetClaims(ctx)
720+
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
712721
permissions := claims.ProjectPermissions(ctx, proj.OrganizationID, proj.ID)
713722

714-
if !permissions.ManageProd {
723+
if !forceAccess && !permissions.ManageProd {
715724
return nil, status.Error(codes.PermissionDenied, "does not have permission to manage deployment")
716725
}
717726

cli/cmd/sudo/embed/embed.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package embed
2+
3+
import (
4+
"github.com/rilldata/rill/cli/pkg/cmdutil"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func EmbedCmd(ch *cmdutil.Helper) *cobra.Command {
9+
embedCmd := &cobra.Command{
10+
Use: "embed",
11+
Short: "Manage embeds",
12+
}
13+
14+
embedCmd.AddCommand(OpenCmd(ch))
15+
16+
return embedCmd
17+
}

cli/cmd/sudo/embed/embed_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package embed_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/rilldata/rill/admin/testadmin"
9+
"github.com/rilldata/rill/cli/testcli"
10+
"github.com/rilldata/rill/runtime/testruntime/testmode"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestEmbedOpen(t *testing.T) {
15+
testmode.Expensive(t)
16+
17+
adm := testadmin.NewWithOptionalRuntime(t, true)
18+
19+
// First user is automatically a superuser.
20+
_, u1Client := adm.NewUser(t)
21+
u1 := testcli.New(t, adm, u1Client.Token)
22+
23+
// Second user: regular, owns the org and project.
24+
_, u2Client := adm.NewUser(t)
25+
u2 := testcli.New(t, adm, u2Client.Token)
26+
27+
// Third user: no access
28+
_, u3Client := adm.NewUser(t)
29+
u3 := testcli.New(t, adm, u3Client.Token)
30+
31+
// u2 creates an org and deploys a project.
32+
res := u2.Run(t, "org", "create", "embed-test")
33+
require.Equal(t, 0, res.ExitCode, res.Output)
34+
35+
tempDir := t.TempDir()
36+
putFiles(t, tempDir, map[string]string{
37+
"rill.yaml": `olap_connector: duckdb`,
38+
})
39+
res = u2.Run(t, "project", "deploy", "--interactive=false", "--org=embed-test", "--project=embed-project", "--path="+tempDir)
40+
require.Equal(t, 0, res.ExitCode, res.Output)
41+
42+
// Reconcile the deployment so it has a runtime host and instance.
43+
adm.TriggerDeployment(t, "embed-test", "embed-project")
44+
45+
// Superuser can get an embed URL for the project.
46+
res = u1.Run(t, "sudo", "embed", "open", "embed-test", "embed-project", "--no-open", "--navigation")
47+
require.Equal(t, 0, res.ExitCode, res.Output)
48+
require.Contains(t, res.Output, "Open browser at:")
49+
50+
// Normal user cannot get an embed URL for the project.
51+
res = u3.Run(t, "sudo", "embed", "open", "embed-test", "embed-project", "--no-open", "--navigation")
52+
require.NotEqual(t, 0, res.ExitCode)
53+
require.Contains(t, res.Output, "does not have permission")
54+
}
55+
56+
func putFiles(t *testing.T, baseDir string, files map[string]string) {
57+
t.Helper()
58+
for path, content := range files {
59+
path = filepath.Join(baseDir, path)
60+
dir := filepath.Dir(path)
61+
require.NoError(t, os.MkdirAll(dir, 0755))
62+
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
63+
}
64+
}

cli/cmd/sudo/embed/open.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package embed
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/rilldata/rill/cli/pkg/browser"
8+
"github.com/rilldata/rill/cli/pkg/cmdutil"
9+
adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1"
10+
"github.com/spf13/cobra"
11+
"google.golang.org/protobuf/types/known/structpb"
12+
)
13+
14+
func OpenCmd(ch *cmdutil.Helper) *cobra.Command {
15+
var branch string
16+
var ttlSeconds uint32
17+
var userID string
18+
var userEmail string
19+
var userAttributes string
20+
var externalUserID string
21+
var resourceType string
22+
var resource string
23+
var theme string
24+
var themeMode string
25+
var navigation bool
26+
var query map[string]string
27+
var noOpen bool
28+
29+
openCmd := &cobra.Command{
30+
Use: "open <org> <project>",
31+
Args: cobra.ExactArgs(2),
32+
Short: "Open an embedded dashboard in the browser",
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
org := args[0]
35+
project := args[1]
36+
37+
req := &adminv1.GetIFrameRequest{
38+
Org: org,
39+
Project: project,
40+
Branch: branch,
41+
TtlSeconds: ttlSeconds,
42+
ExternalUserId: externalUserID,
43+
Type: resourceType,
44+
Resource: resource,
45+
Theme: theme,
46+
ThemeMode: themeMode,
47+
Navigation: navigation,
48+
Query: query,
49+
SuperuserForceAccess: true,
50+
}
51+
52+
// Set user identity: only one of user_id, user_email, or user_attributes can be specified.
53+
n := 0
54+
if userID != "" {
55+
n++
56+
req.For = &adminv1.GetIFrameRequest_UserId{UserId: userID}
57+
}
58+
if userEmail != "" {
59+
n++
60+
req.For = &adminv1.GetIFrameRequest_UserEmail{UserEmail: userEmail}
61+
}
62+
if userAttributes != "" {
63+
n++
64+
var attrs map[string]any
65+
if err := json.Unmarshal([]byte(userAttributes), &attrs); err != nil {
66+
return fmt.Errorf("invalid --user-attributes JSON: %w", err)
67+
}
68+
s, err := structpb.NewStruct(attrs)
69+
if err != nil {
70+
return fmt.Errorf("failed to parse --user-attributes: %w", err)
71+
}
72+
req.For = &adminv1.GetIFrameRequest_Attributes{Attributes: s}
73+
}
74+
if n > 1 {
75+
return fmt.Errorf("only one of --user-id, --user-email, or --user-attributes can be specified")
76+
}
77+
78+
client, err := ch.Client()
79+
if err != nil {
80+
return err
81+
}
82+
83+
res, err := client.GetIFrame(cmd.Context(), req)
84+
if err != nil {
85+
return err
86+
}
87+
88+
if noOpen || !ch.Interactive {
89+
ch.Printf("Open browser at: %s\n", res.IframeSrc)
90+
} else {
91+
ch.Printf("Opening browser at: %s\n", res.IframeSrc)
92+
_ = browser.Open(res.IframeSrc)
93+
}
94+
95+
return nil
96+
},
97+
}
98+
99+
openCmd.Flags().StringVar(&branch, "branch", "", "Branch to embed (defaults to the primary branch)")
100+
openCmd.Flags().Uint32Var(&ttlSeconds, "ttl-seconds", 0, "TTL for the access token in seconds")
101+
openCmd.Flags().StringVar(&userID, "user-id", "", "Rill user ID to assume")
102+
openCmd.Flags().StringVar(&userEmail, "user-email", "", "User email to assume")
103+
openCmd.Flags().StringVar(&userAttributes, "user-attributes", "", "User attributes as JSON (e.g. '{\"domain\":\"example.com\"}')")
104+
openCmd.Flags().StringVar(&externalUserID, "external-user-id", "", "External user ID for per-user state")
105+
openCmd.Flags().StringVar(&resourceType, "resource-type", "", "Type of resource to embed")
106+
openCmd.Flags().StringVar(&resource, "resource", "", "Name of the resource to embed")
107+
openCmd.Flags().StringVar(&theme, "theme", "", "Theme for the embedded resource")
108+
openCmd.Flags().StringVar(&themeMode, "theme-mode", "", "Theme mode")
109+
openCmd.Flags().BoolVar(&navigation, "navigation", false, "Enable navigation between resources")
110+
openCmd.Flags().StringToStringVar(&query, "query", nil, "Additional query parameters (key=value)")
111+
openCmd.Flags().BoolVar(&noOpen, "no-open", false, "Print the URL without opening the browser")
112+
113+
return openCmd
114+
}

cli/cmd/sudo/sudo.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sudo
33
import (
44
"github.com/rilldata/rill/cli/cmd/sudo/annotations"
55
"github.com/rilldata/rill/cli/cmd/sudo/billing"
6+
"github.com/rilldata/rill/cli/cmd/sudo/embed"
67
"github.com/rilldata/rill/cli/cmd/sudo/org"
78
"github.com/rilldata/rill/cli/cmd/sudo/project"
89
"github.com/rilldata/rill/cli/cmd/sudo/quota"
@@ -24,6 +25,7 @@ func SudoCmd(ch *cmdutil.Helper) *cobra.Command {
2425
GroupID: internalGroupID,
2526
}
2627
sudoCmd.AddCommand(lookupCmd(ch))
28+
sudoCmd.AddCommand(embed.EmbedCmd(ch))
2729
sudoCmd.AddCommand(org.OrgCmd(ch))
2830
sudoCmd.AddCommand(project.ProjectCmd(ch))
2931
sudoCmd.AddCommand(user.UserCmd(ch))

proto/gen/rill/admin/v1/admin.swagger.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ paths:
140140
description: |-
141141
Optional ID for an external end user of the deployment. If set, the access token enables per-user state, such as AI chat history.
142142
Cannot be combined with `user_id`. If `user_email` matches a Rill Cloud user, their attributes are used, but this ID takes precedence for per-user state.
143+
superuserForceAccess:
144+
type: boolean
145+
description: If true, superusers can access the deployment even without org/project membership.
143146
/v1/deployments/{deploymentId}/provision:
144147
post:
145148
summary: |-
@@ -1488,6 +1491,9 @@ paths:
14881491
description: |-
14891492
Optional ID for an external end user of the deployment. If set, the access token enables per-user state, such as AI chat history.
14901493
Cannot be combined with `user_id`. If `user_email` matches a Rill Cloud user, their attributes are used, but this ID takes precedence for per-user state.
1494+
superuserForceAccess:
1495+
type: boolean
1496+
description: If true, superusers can access the deployment even without org/project membership.
14911497
/v1/orgs/{org}/projects/{project}/deployments:
14921498
get:
14931499
summary: ListDeployments lists deployments for a project.
@@ -1662,6 +1668,9 @@ paths:
16621668
additionalProperties:
16631669
type: string
16641670
description: 'DEPRECATED: Additional parameters to set outright in the generated URL query.'
1671+
superuserForceAccess:
1672+
type: boolean
1673+
description: If true, superusers can access the project even without org/project membership.
16651674
description: GetIFrameRequest is the request payload for AdminService.GetIFrame.
16661675
/v1/orgs/{org}/projects/{project}/invites:
16671676
get:

0 commit comments

Comments
 (0)