open-vault/command/operator_usage.go
VAL 0ef529b710
Global flag that outputs minimum policy HCL required for an operation (#14899)
* WIP: output policy

* Outputs example policy HCL for given request

* Simplify conditional

* Add PATCH capability

* Use OpenAPI spec and regex patterns to determine if path is sudo

* Add test for isSudoPath

* Add changelog

* Fix broken CLI tests

* Add output-policy to client cloning code

* Smaller fixes from PR comments

* Clone client instead of saving and restoring custom values

* Fix test

* Address comments

* Don't unset output-policy flag on KV requests otherwise the preflight request will fail and not populate LastOutputPolicyError

* Print errors saved in buffer from preflight KV requests

* Unescape characters in request URL

* Rename methods and properties to improve readability

* Put KV-specificness at front of KV-specific error

* Simplify logic by doing more direct returns of strings and errors

* Use precompiled regexes and move OpenAPI call to tests

* Remove commented out code

* Remove legacy MFA paths

* Remove unnecessary use of client

* Move sudo paths map to plugin helper

* Remove unused error return

* Add explanatory comment

* Remove need to pass in address

* Make {name} regex less greedy

* Use method and path instead of info from retryablerequest

* Add test for IsSudoPaths, use more idiomatic naming

* Use precompiled regexes and move OpenAPI call to tests (#15170)

* Use precompiled regexes and move OpenAPI call to tests

* Remove commented out code

* Remove legacy MFA paths

* Remove unnecessary use of client

* Move sudo paths map to plugin helper

* Remove unused error return

* Add explanatory comment

* Remove need to pass in address

* Make {name} regex less greedy

* Use method and path instead of info from retryablerequest

* Add test for IsSudoPaths, use more idiomatic naming

* Make stderr writing more obvious, fix nil pointer deref
2022-04-27 16:35:18 -07:00

325 lines
7.7 KiB
Go

package command
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/hashicorp/vault/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/ryanuber/columnize"
)
var (
_ cli.Command = (*OperatorUsageCommand)(nil)
_ cli.CommandAutocomplete = (*OperatorUsageCommand)(nil)
)
type OperatorUsageCommand struct {
*BaseCommand
flagStartTime time.Time
flagEndTime time.Time
}
func (c *OperatorUsageCommand) Synopsis() string {
return "Lists historical client counts"
}
func (c *OperatorUsageCommand) Help() string {
helpText := `
Usage: vault operator usage
List the client counts for the default reporting period.
$ vault operator usage
List the client counts for a specific time period.
$ vault operator usage -start-time=2020-10 -end-time=2020-11
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *OperatorUsageCommand) Flags() *FlagSets {
set := c.flagSet(FlagSetHTTP | FlagSetOutputFormat)
f := set.NewFlagSet("Command Options")
f.TimeVar(&TimeVar{
Name: "start-time",
Usage: "Start of report period. Defaults to 'default_reporting_period' before end time.",
Target: &c.flagStartTime,
Completion: complete.PredictNothing,
Default: time.Time{},
Formats: TimeVar_TimeOrDay | TimeVar_Month,
})
f.TimeVar(&TimeVar{
Name: "end-time",
Usage: "End of report period. Defaults to end of last month.",
Target: &c.flagEndTime,
Completion: complete.PredictNothing,
Default: time.Time{},
Formats: TimeVar_TimeOrDay | TimeVar_Month,
})
return set
}
func (c *OperatorUsageCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictAnything
}
func (c *OperatorUsageCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *OperatorUsageCommand) Run(args []string) int {
f := c.Flags()
if err := f.Parse(args); err != nil {
c.UI.Error(err.Error())
return 1
}
data := make(map[string][]string)
if !c.flagStartTime.IsZero() {
data["start_time"] = []string{c.flagStartTime.Format(time.RFC3339)}
}
if !c.flagEndTime.IsZero() {
data["end_time"] = []string{c.flagEndTime.Format(time.RFC3339)}
}
client, err := c.Client()
if err != nil {
c.UI.Error(err.Error())
return 2
}
resp, err := client.Logical().ReadWithData("sys/internal/counters/activity", data)
if err != nil {
c.UI.Error(fmt.Sprintf("Error retrieving client counts: %v", err))
return 2
}
if resp == nil || resp.Data == nil {
if c.noReportAvailable(client) {
c.UI.Warn("Vault does not have any usage data available. A report will be available\n" +
"after the first calendar month in which monitoring is enabled.")
} else {
c.UI.Warn("No data is available for the given time range.")
}
// No further output
return 0
}
switch Format(c.UI) {
case "table":
default:
// Handle JSON, YAML, etc.
return OutputData(c.UI, resp)
}
// Show this before the headers
c.outputTimestamps(resp.Data)
out := []string{
"Namespace path | Distinct entities | Non-Entity tokens | Active clients",
}
out = append(out, c.namespacesOutput(resp.Data)...)
out = append(out, c.totalOutput(resp.Data)...)
colConfig := columnize.DefaultConfig()
colConfig.Empty = " " // Do not show n/a on intentional blank lines
colConfig.Glue = " "
c.UI.Output(tableOutput(out, colConfig))
return 0
}
// noReportAvailable checks whether we can definitively say that no
// queries can be answered; if there's an error, just fall back to
// reporting that the response is empty.
func (c *OperatorUsageCommand) noReportAvailable(client *api.Client) bool {
if c.flagOutputCurlString || c.flagOutputPolicy {
// Don't mess up the original query string
return false
}
resp, err := client.Logical().Read("sys/internal/counters/config")
if err != nil || resp == nil || resp.Data == nil {
c.UI.Warn("bad response from config")
return false
}
qaRaw, ok := resp.Data["queries_available"]
if !ok {
c.UI.Warn("no queries_available key")
return false
}
qa, ok := qaRaw.(bool)
if !ok {
c.UI.Warn("wrong type")
return false
}
return !qa
}
func (c *OperatorUsageCommand) outputTimestamps(data map[string]interface{}) {
c.UI.Output(fmt.Sprintf("Period start: %v\nPeriod end: %v\n",
data["start_time"].(string),
data["end_time"].(string)))
}
type UsageCommandNamespace struct {
formattedLine string
sortOrder string
// Sort order:
// -- root first
// -- namespaces in lexicographic order
// -- deleted namespace "xxxxx" last
}
type UsageResponse struct {
namespacePath string
entityCount int64
// As per 1.9, the tokenCount field will contain the distinct non-entity
// token clients instead of each individual token.
tokenCount int64
clientCount int64
}
func jsonNumberOK(m map[string]interface{}, key string) (int64, bool) {
val, ok := m[key].(json.Number)
if !ok {
return 0, false
}
intVal, err := val.Int64()
if err != nil {
return 0, false
}
return intVal, true
}
// TODO: provide a function in the API module for doing this conversion?
func (c *OperatorUsageCommand) parseNamespaceCount(rawVal interface{}) (UsageResponse, error) {
var ret UsageResponse
val, ok := rawVal.(map[string]interface{})
if !ok {
return ret, errors.New("value is not a map")
}
ret.namespacePath, ok = val["namespace_path"].(string)
if !ok {
return ret, errors.New("bad namespace path")
}
counts, ok := val["counts"].(map[string]interface{})
if !ok {
return ret, errors.New("missing counts")
}
ret.entityCount, ok = jsonNumberOK(counts, "distinct_entities")
if !ok {
return ret, errors.New("missing distinct_entities")
}
ret.tokenCount, ok = jsonNumberOK(counts, "non_entity_tokens")
if !ok {
return ret, errors.New("missing non_entity_tokens")
}
ret.clientCount, ok = jsonNumberOK(counts, "clients")
if !ok {
return ret, errors.New("missing clients")
}
return ret, nil
}
func (c *OperatorUsageCommand) namespacesOutput(data map[string]interface{}) []string {
byNs, ok := data["by_namespace"].([]interface{})
if !ok {
c.UI.Error("missing namespace breakdown in response")
return nil
}
nsOut := make([]UsageCommandNamespace, 0, len(byNs))
for _, rawVal := range byNs {
val, err := c.parseNamespaceCount(rawVal)
if err != nil {
c.UI.Error(fmt.Sprintf("malformed namespace in response: %v", err))
continue
}
sortOrder := "1" + val.namespacePath
if val.namespacePath == "" {
val.namespacePath = "[root]"
sortOrder = "0"
} else if strings.HasPrefix(val.namespacePath, "deleted namespace") {
sortOrder = "2" + val.namespacePath
}
formattedLine := fmt.Sprintf("%s | %d | %d | %d",
val.namespacePath, val.entityCount, val.tokenCount, val.clientCount)
nsOut = append(nsOut, UsageCommandNamespace{
formattedLine: formattedLine,
sortOrder: sortOrder,
})
}
sort.Slice(nsOut, func(i, j int) bool {
return nsOut[i].sortOrder < nsOut[j].sortOrder
})
out := make([]string, len(nsOut))
for i := range nsOut {
out[i] = nsOut[i].formattedLine
}
return out
}
func (c *OperatorUsageCommand) totalOutput(data map[string]interface{}) []string {
// blank line separating it from namespaces
out := []string{" | | | "}
total, ok := data["total"].(map[string]interface{})
if !ok {
c.UI.Error("missing total in response")
return out
}
entityCount, ok := jsonNumberOK(total, "distinct_entities")
if !ok {
c.UI.Error("missing distinct_entities in total")
return out
}
tokenCount, ok := jsonNumberOK(total, "non_entity_tokens")
if !ok {
c.UI.Error("missing non_entity_tokens in total")
return out
}
clientCount, ok := jsonNumberOK(total, "clients")
if !ok {
c.UI.Error("missing clients in total")
return out
}
out = append(out, fmt.Sprintf("Total | %d | %d | %d",
entityCount, tokenCount, clientCount))
return out
}