Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions internal/cmd/branch/connections/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions internal/cmd/branch/connections/top.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
}

Expand Down
26 changes: 25 additions & 1 deletion internal/cmd/branch/connections/top_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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
}
}
Expand Down
41 changes: 41 additions & 0 deletions internal/connections/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading