diff --git a/internal/cmd/branch/connections/client.go b/internal/cmd/branch/connections/client.go index a687f31d..b15c8727 100644 --- a/internal/cmd/branch/connections/client.go +++ b/internal/cmd/branch/connections/client.go @@ -100,6 +100,51 @@ func (e *notFoundExplainer) resolve(ctx context.Context, err error) error { } } +const killPermissionDenied = "you don't have permission to kill processes on this branch" + +// killDenial returns the message to show when the caller can't kill connections +// on the branch. It returns an empty string when they can, and also when the +// branch or permissions can't be read, since api-bb enforces the permission on +// every kill regardless. +func killDenial(ctx context.Context, ch *cmdutil.Helper, client *live.Client, database, branch string, engine ps.DatabaseEngine) string { + production, err := branchIsProduction(ctx, ch, database, branch, engine) + if err != nil { + return "" + } + permissions, err := client.Permissions(ctx) + if err != nil { + return "" + } + action := "manage_live_connections" + if production { + action = "manage_production_live_connections" + } + if permissions[action] { + return "" + } + return killPermissionDenied +} + +func branchIsProduction(ctx context.Context, ch *cmdutil.Helper, database, branch string, engine ps.DatabaseEngine) (bool, error) { + client, err := ch.Client() + if err != nil { + return false, err + } + org := ch.Config.Organization + if engine == ps.DatabaseEngineMySQL { + b, err := client.DatabaseBranches.Get(ctx, &ps.GetDatabaseBranchRequest{Organization: org, Database: database, Branch: branch}) + if err != nil { + return false, err + } + return b.Production, nil + } + b, err := client.PostgresBranches.Get(ctx, &ps.GetPostgresBranchRequest{Organization: org, Database: database, Branch: branch}) + if err != nil { + return false, err + } + return b.Production, nil +} + func newConnectionsClient(ch *cmdutil.Helper, database, branch string, target ConnectionTarget) (*live.Client, error) { return live.NewClient(live.ClientConfig{ BaseURL: ch.Config.BaseURL, diff --git a/internal/cmd/branch/connections/top.go b/internal/cmd/branch/connections/top.go index 379347ee..a32a7363 100644 --- a/internal/cmd/branch/connections/top.go +++ b/internal/cmd/branch/connections/top.go @@ -198,7 +198,11 @@ func runTopInteractive(ctx context.Context, ch *cmdutil.Helper, request topReque Keyspace: source.Target.Keyspace, Shard: source.Target.Shard, } - return runInteractive(ctx, source.Client, flags.duration, flags.interval, control, target, request.Filter.chip(), source.View) + var denial string + if client, err := newConnectionsClient(ch, request.Database, request.Branch, ConnectionTarget{}); err == nil { + denial = killDenial(ctx, ch, client, request.Database, request.Branch, request.Engine) + } + return runInteractive(ctx, source.Client, flags.duration, flags.interval, control, target, request.Filter.chip(), source.View, denial) } func runTopHeadless(ctx context.Context, cmd *cobra.Command, ch *cmdutil.Helper, request topRequest, source topSource, flags topFlags) error { @@ -312,7 +316,7 @@ var runTeaProgram = func(model tea.Model, options ...tea.ProgramOption) error { return err } -func runInteractive(ctx context.Context, client tui.ConnectionsClient, duration, interval time.Duration, control *tui.CaptureControl, target tui.Target, filterChip string, view tui.ConnectionViewProfile) (err error) { +func runInteractive(ctx context.Context, client tui.ConnectionsClient, duration, interval time.Duration, control *tui.CaptureControl, target tui.Target, filterChip string, view tui.ConnectionViewProfile, killDenial string) (err error) { runCtx, cancel := context.WithCancel(ctx) defer cancel() if control != nil { @@ -328,6 +332,9 @@ func runInteractive(ctx context.Context, client tui.ConnectionsClient, duration, if control != nil { model = model.WithCaptureControl(control) } + if killDenial != "" { + model = model.WithReadOnlyActions(killDenial) + } return runTeaProgram(model, tea.WithAltScreen(), tea.WithContext(runCtx)) } diff --git a/internal/cmd/branch/connections/top_test.go b/internal/cmd/branch/connections/top_test.go index bb4b5fa4..15d7cd41 100644 --- a/internal/cmd/branch/connections/top_test.go +++ b/internal/cmd/branch/connections/top_test.go @@ -758,6 +758,10 @@ func setRunTeaProgram(t *testing.T, run func(tea.Model, ...tea.ProgramOption) er func liveConnectionsServer(t *testing.T, body string) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/permissions") { + writeKillPermissions(w) + return + } if r.URL.Path != "/v1/organizations/acme/databases/pgload/branches/main/connections" { t.Fatalf("path = %q", r.URL.Path) } @@ -769,11 +773,21 @@ func liveConnectionsServer(t *testing.T, body string) *httptest.Server { func liveConnectionsServerForTop(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() - server := httptest.NewServer(handler) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/permissions") { + writeKillPermissions(w) + return + } + handler(w, r) + })) t.Cleanup(server.Close) return server } +func writeKillPermissions(w http.ResponseWriter) { + _, _ = io.WriteString(w, `{"permissions":{"manage_live_connections":true,"manage_production_live_connections":true}}`) +} + func topCmdForServer(baseURL string, out *bytes.Buffer) *cobra.Command { return topCmdForServerAndEngine(baseURL, ps.DatabaseEnginePostgres, out) } @@ -797,6 +811,16 @@ func topDatabaseClient(engine ps.DatabaseEngine) func() (*ps.Client, error) { return &ps.Database{Name: req.Database, Kind: engine}, nil }, }, + DatabaseBranches: &mock.DatabaseBranchesService{ + GetFn: func(_ context.Context, req *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + return &ps.DatabaseBranch{Name: req.Branch}, nil + }, + }, + PostgresBranches: &mock.PostgresBranchesService{ + GetFn: func(_ context.Context, req *ps.GetPostgresBranchRequest) (*ps.PostgresBranch, error) { + return &ps.PostgresBranch{Name: req.Branch}, nil + }, + }, }, nil } } diff --git a/internal/connections/client.go b/internal/connections/client.go index 21d34256..7c5f2e36 100644 --- a/internal/connections/client.go +++ b/internal/connections/client.go @@ -592,6 +592,47 @@ func (e *UnknownInstanceError) Error() string { return fmt.Sprintf("unknown instance %q (valid instances: %s)", e.Instance, strings.Join(e.Valid, ", ")) } +// Permissions returns the caller's actions on the database, combining the +// database and organization permission maps the way api-bb authorizes them. +func (s *Client) Permissions(ctx context.Context) (map[string]bool, error) { + if s.cfg.RequestTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.cfg.RequestTimeout) + defer cancel() + } + base := strings.TrimRight(s.cfg.BaseURL, "/") + "/v1/organizations/" + url.PathEscape(s.cfg.Organization) + merged := map[string]bool{} + for _, u := range []string{base + "/databases/" + url.PathEscape(s.cfg.Database) + "/permissions", base + "/permissions"} { + permissions, err := s.fetchPermissions(ctx, u) + if err != nil { + return nil, err + } + for action, allowed := range permissions { + merged[action] = merged[action] || allowed + } + } + return merged, nil +} + +func (s *Client) fetchPermissions(ctx context.Context, u string) (map[string]bool, error) { + resp, err := s.do(ctx, http.MethodGet, u) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode > 299 { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("permissions: HTTP %d", resp.StatusCode) + } + var body struct { + Permissions map[string]bool `json:"permissions"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("permissions: invalid response: %w", err) + } + return body.Permissions, nil +} + func (s *Client) connectionsURL(suffix string) string { raw := fmt.Sprintf( "%s/v1/organizations/%s/databases/%s/branches/%s/connections",