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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user