Add sentryctl dashboards permissions list|grant|revoke

PUT/DELETE /dashboards/{id}/permissions/{userId} (per-resource dashboard
grants, built earlier this phase) had no caller but Go tests and curl --
named as a real, disclosed gap in docs/phase-4-runbook.md. Adds a CLI
surface: sentryctl dashboards permissions list/grant/revoke, following
the existing dashboards subcommand pattern.

grant/revoke needed a new httpclient.go helper (httpMutateNoBody) since
both endpoints respond 204 No Content -- the existing helpers all expect
a JSON body to pretty-print. grant validates the role client-side
(viewer/editor only, mirroring api/dashboards.validGrantRole) before
making a request, since Admin/Owner already have tenant-wide dashboard
access and a resource-level grant can never raise someone past Editor.

Verified with real httptest.Server round trips (method, path, request
body, and error-body parsing on a 501 from a deployment with no
enterprise permission service wired in) -- the same pattern every other
sentryctl subcommand's tests already use, no fake/mock client needed
since sentryctl itself is just an HTTP client with no store of its own.
This commit is contained in:
2026-08-14 23:09:33 -07:00
parent abeee0076b
commit 3cf1320881
7 changed files with 282 additions and 9 deletions
+57 -2
View File
@@ -3,12 +3,13 @@ package main
import (
"fmt"
"io"
"net/http"
"os"
)
func cmdDashboards(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards: expected a subcommand (list, get, apply)")
fmt.Fprintln(stderr, "sentryctl dashboards: expected a subcommand (list, get, apply, permissions)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
@@ -32,8 +33,62 @@ func cmdDashboards(args []string, stdout, stderr io.Writer) int {
// /dashboards/{id}/export produces and the web UI's Export JSON
// button downloads -- one JSON contract, three call sites.
return httpPostFileJSON(apiURL, "/dashboards/import", token, rest[0], stdout, stderr)
case "permissions":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions: expected a subcommand (list, grant, revoke)")
return 1
}
return cmdDashboardsPermissions(rest, apiURL, token, stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0])
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply, permissions)\n", args[0])
return 1
}
}
// cmdDashboardsPermissions is api/dashboards.PermissionStore's CLI
// surface -- PUT/DELETE /dashboards/{id}/permissions/{userId} existed
// with no caller but Go tests and curl until now (see
// /docs/phase-4-runbook.md's "Known gaps"). Kept as dashboards'
// own sub-subcommand rather than a flat sentryctl command (like
// "sentryctl dashboard-permissions grant ...") since a grant only ever
// makes sense in the context of one specific dashboard -- args[0]
// selects list/grant/revoke.
func cmdDashboardsPermissions(args []string, apiURL, token string, stdout, stderr io.Writer) int {
sub, rest := args[0], args[1:]
switch sub {
case "list":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions list: missing dashboard id")
return 1
}
return httpGetJSON(apiURL, "/dashboards/"+rest[0]+"/permissions", token, stdout, stderr)
case "grant":
if len(rest) < 3 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions grant: usage: grant <dashboard-id> <user-id> <viewer|editor>")
return 1
}
dashboardID, userID, role := rest[0], rest[1], rest[2]
// Mirrors api/dashboards.validGrantRole -- Admin/Owner already
// have tenant-wide dashboard access, so a resource-level grant
// only ever raises someone as high as Editor; the server
// rejects anything else too, this just fails faster/locally.
if role != "viewer" && role != "editor" {
fmt.Fprintf(stderr, "sentryctl dashboards permissions grant: role must be \"viewer\" or \"editor\", got %q\n", role)
return 1
}
body := fmt.Sprintf(`{"role":%q}`, role)
path := "/dashboards/" + dashboardID + "/permissions/" + userID
return httpMutateNoBody(http.MethodPut, apiURL, path, token, body, "granted", stdout, stderr)
case "revoke":
if len(rest) < 2 {
fmt.Fprintln(stderr, "sentryctl dashboards permissions revoke: usage: revoke <dashboard-id> <user-id>")
return 1
}
dashboardID, userID := rest[0], rest[1]
path := "/dashboards/" + dashboardID + "/permissions/" + userID
return httpMutateNoBody(http.MethodDelete, apiURL, path, token, "", "revoked", stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl dashboards permissions: unknown subcommand %q (want list, grant, revoke)\n", sub)
return 1
}
}