open-vault/vault/logical_system_activity.go
hc-github-team-secure-vault-core 36365ed7f4
backport of commit 3a46ecc389e9096ccea6c6f847b68ada7f8068d7 (#21362)
Co-authored-by: Violet Hynes <violet.hynes@hashicorp.com>
2023-06-21 14:01:13 +00:00

443 lines
13 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package vault
import (
"context"
"fmt"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/hashicorp/go-secure-stdlib/parseutil"
"github.com/hashicorp/vault/helper/timeutil"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// activityQueryPath is available in every namespace
func (b *SystemBackend) activityQueryPath() *framework.Path {
return &framework.Path{
Pattern: "internal/counters/activity$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "internal-client-activity",
OperationVerb: "report",
OperationSuffix: "counts",
},
Fields: map[string]*framework.FieldSchema{
"current_billing_period": {
Type: framework.TypeBool,
Description: "Query utilization for configured billing period",
},
"start_time": {
Type: framework.TypeTime,
Description: "Start of query interval",
},
"end_time": {
Type: framework.TypeTime,
Description: "End of query interval",
},
"limit_namespaces": {
Type: framework.TypeInt,
Default: 0,
Description: "Limit query output by namespaces",
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["activity-query"][0]),
HelpDescription: strings.TrimSpace(sysHelp["activity-query"][1]),
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleClientMetricQuery,
Summary: "Report the client count metrics, for this namespace and all child namespaces.",
},
},
}
}
// monthlyActivityCountPath is available in every namespace
func (b *SystemBackend) monthlyActivityCountPath() *framework.Path {
return &framework.Path{
Pattern: "internal/counters/activity/monthly$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "internal-client-activity",
OperationVerb: "report",
OperationSuffix: "counts-this-month",
},
HelpSynopsis: strings.TrimSpace(sysHelp["activity-monthly"][0]),
HelpDescription: strings.TrimSpace(sysHelp["activity-monthly"][1]),
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleMonthlyActivityCount,
Summary: "Report the number of clients for this month, for this namespace and all child namespaces.",
},
},
}
}
func (b *SystemBackend) activityPaths() []*framework.Path {
return []*framework.Path{
b.monthlyActivityCountPath(),
b.activityQueryPath(),
}
}
// rootActivityPaths are available only in the root namespace
func (b *SystemBackend) rootActivityPaths() []*framework.Path {
paths := []*framework.Path{
b.activityQueryPath(),
b.monthlyActivityCountPath(),
{
Pattern: "internal/counters/config$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "internal-client-activity",
},
Fields: map[string]*framework.FieldSchema{
"default_report_months": {
Type: framework.TypeInt,
Default: 12,
Description: "Number of months to report if no start date specified.",
},
"retention_months": {
Type: framework.TypeInt,
Default: 24,
Description: "Number of months of client data to retain. Setting to 0 will clear all existing data.",
},
"enabled": {
Type: framework.TypeString,
Default: "default",
Description: "Enable or disable collection of client count: enable, disable, or default.",
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["activity-config"][0]),
HelpDescription: strings.TrimSpace(sysHelp["activity-config"][1]),
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleActivityConfigRead,
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "read",
OperationSuffix: "configuration",
},
Summary: "Read the client count tracking configuration.",
},
logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleActivityConfigUpdate,
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "configure",
},
Summary: "Enable or disable collection of client count, set retention period, or set default reporting period.",
},
},
},
{
Pattern: "internal/counters/activity/export$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "internal-client-activity",
OperationVerb: "export",
},
Fields: map[string]*framework.FieldSchema{
"start_time": {
Type: framework.TypeTime,
Description: "Start of query interval",
},
"end_time": {
Type: framework.TypeTime,
Description: "End of query interval",
},
"format": {
Type: framework.TypeString,
Description: "Format of the file. Either a CSV or a JSON file with an object per line.",
Default: "json",
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["activity-export"][0]),
HelpDescription: strings.TrimSpace(sysHelp["activity-export"][1]),
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleClientExport,
Summary: "Report the client count metrics, for this namespace and all child namespaces.",
},
},
},
}
if writePath := b.activityWritePath(); writePath != nil {
paths = append(paths, writePath)
}
return paths
}
func parseStartEndTimes(a *ActivityLog, d *framework.FieldData) (time.Time, time.Time, error) {
startTime := d.Get("start_time").(time.Time)
endTime := d.Get("end_time").(time.Time)
// If a specific endTime is used, then respect that
// otherwise we want to give the latest N months, so go back to the start
// of the previous month
//
// Also convert any user inputs to UTC to avoid
// problems later.
if endTime.IsZero() {
endTime = timeutil.EndOfMonth(timeutil.StartOfPreviousMonth(time.Now().UTC()))
} else {
endTime = endTime.UTC()
}
if startTime.IsZero() {
startTime = a.DefaultStartTime(endTime)
} else {
startTime = startTime.UTC()
}
if startTime.After(endTime) {
return time.Time{}, time.Time{}, fmt.Errorf("start_time is later than end_time")
}
return startTime, endTime, nil
}
// This endpoint is not used by the UI. The UI's "export" feature is entirely client-side.
func (b *SystemBackend) handleClientExport(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.Core.activityLogLock.RLock()
a := b.Core.activityLog
b.Core.activityLogLock.RUnlock()
if a == nil {
return logical.ErrorResponse("no activity log present"), nil
}
startTime, endTime, err := parseStartEndTimes(a, d)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
// This is to avoid the default 90s context timeout.
timeout := 10 * time.Minute
if durationRaw := os.Getenv("VAULT_ACTIVITY_EXPORT_DURATION"); durationRaw != "" {
d, err := parseutil.ParseDurationSecond(durationRaw)
if err == nil {
timeout = d
}
}
runCtx, cancelFunc := context.WithTimeout(b.Core.activeContext, timeout)
defer cancelFunc()
err = a.writeExport(runCtx, req.ResponseWriter, d.Get("format").(string), startTime, endTime)
if err != nil {
return nil, err
}
return nil, nil
}
func (b *SystemBackend) handleClientMetricQuery(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
var startTime, endTime time.Time
b.Core.activityLogLock.RLock()
a := b.Core.activityLog
b.Core.activityLogLock.RUnlock()
if a == nil {
return logical.ErrorResponse("no activity log present"), nil
}
if d.Get("current_billing_period").(bool) {
startTime = b.Core.BillingStart()
endTime = time.Now().UTC()
} else {
var err error
startTime, endTime, err = parseStartEndTimes(a, d)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
}
var limitNamespaces int
if limitNamespacesRaw, ok := d.GetOk("limit_namespaces"); ok {
limitNamespaces = limitNamespacesRaw.(int)
}
results, err := a.handleQuery(ctx, startTime, endTime, limitNamespaces)
if err != nil {
return nil, err
}
if results == nil {
resp204, err := logical.RespondWithStatusCode(nil, req, http.StatusNoContent)
return resp204, err
}
return &logical.Response{
Data: results,
}, nil
}
func (b *SystemBackend) handleMonthlyActivityCount(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.Core.activityLogLock.RLock()
a := b.Core.activityLog
b.Core.activityLogLock.RUnlock()
if a == nil {
return logical.ErrorResponse("no activity log present"), nil
}
results, err := a.partialMonthClientCount(ctx)
if err != nil {
return nil, err
}
if results == nil {
return logical.RespondWithStatusCode(nil, req, http.StatusNoContent)
}
return &logical.Response{
Data: results,
}, nil
}
func (b *SystemBackend) handleActivityConfigRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.Core.activityLogLock.RLock()
a := b.Core.activityLog
b.Core.activityLogLock.RUnlock()
if a == nil {
return logical.ErrorResponse("no activity log present"), nil
}
config, err := a.loadConfigOrDefault(ctx)
if err != nil {
return nil, err
}
qa, err := a.queriesAvailable(ctx)
if err != nil {
return nil, err
}
if config.Enabled == "default" {
config.Enabled = activityLogEnabledDefaultValue
}
return &logical.Response{
Data: map[string]interface{}{
"default_report_months": config.DefaultReportMonths,
"retention_months": config.RetentionMonths,
"enabled": config.Enabled,
"queries_available": qa,
"reporting_enabled": b.Core.CensusLicensingEnabled(),
"billing_start_timestamp": b.Core.BillingStart(),
"minimum_retention_months": a.configOverrides.MinimumRetentionMonths,
},
}, nil
}
func (b *SystemBackend) handleActivityConfigUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
b.Core.activityLogLock.RLock()
a := b.Core.activityLog
b.Core.activityLogLock.RUnlock()
if a == nil {
return logical.ErrorResponse("no activity log present"), nil
}
warnings := make([]string, 0)
config, err := a.loadConfigOrDefault(ctx)
if err != nil {
return nil, err
}
{
// Parse the default report months
if defaultReportMonthsRaw, ok := d.GetOk("default_report_months"); ok {
config.DefaultReportMonths = defaultReportMonthsRaw.(int)
}
if config.DefaultReportMonths <= 0 {
return logical.ErrorResponse("default_report_months must be greater than 0"), logical.ErrInvalidRequest
}
}
{
// Parse the retention months
if retentionMonthsRaw, ok := d.GetOk("retention_months"); ok {
config.RetentionMonths = retentionMonthsRaw.(int)
}
if config.RetentionMonths < 0 {
return logical.ErrorResponse("retention_months must be greater than or equal to 0"), logical.ErrInvalidRequest
}
if config.RetentionMonths > 36 {
config.RetentionMonths = 36
warnings = append(warnings, "retention_months cannot be greater than 36; capped to 36.")
}
}
{
// Parse the enabled setting
if enabledRaw, ok := d.GetOk("enabled"); ok {
enabledStr := enabledRaw.(string)
// If we switch from enabled to disabled, then we return a warning to the client.
// We have to keep the default state of activity log enabled in mind
if config.Enabled == "enable" && enabledStr == "disable" ||
!activityLogEnabledDefault && config.Enabled == "enable" && enabledStr == "default" ||
activityLogEnabledDefault && config.Enabled == "default" && enabledStr == "disable" {
// if census is enabled, the activity log cannot be disabled
if a.core.CensusLicensingEnabled() {
return logical.ErrorResponse("cannot disable the activity log while Reporting is enabled"), logical.ErrInvalidRequest
}
warnings = append(warnings, "the current monthly segment will be deleted because the activity log was disabled")
}
switch enabledStr {
case "default", "enable", "disable":
config.Enabled = enabledStr
default:
return logical.ErrorResponse("enabled must be one of \"default\", \"enable\", \"disable\""), logical.ErrInvalidRequest
}
}
}
a.core.activityLogLock.RLock()
minimumRetentionMonths := a.configOverrides.MinimumRetentionMonths
a.core.activityLogLock.RUnlock()
enabled := config.Enabled == "enable"
if !enabled && config.Enabled == "default" {
enabled = activityLogEnabledDefault
}
if enabled && config.RetentionMonths == 0 {
return logical.ErrorResponse("retention_months cannot be 0 while enabled"), logical.ErrInvalidRequest
}
if a.core.CensusLicensingEnabled() && config.RetentionMonths < minimumRetentionMonths {
return logical.ErrorResponse("retention_months must be at least %d while Reporting is enabled", minimumRetentionMonths), logical.ErrInvalidRequest
}
// Store the config
entry, err := logical.StorageEntryJSON(path.Join(activitySubPath, activityConfigKey), config)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
// Set the new config on the activity log
a.SetConfig(ctx, config)
if len(warnings) > 0 {
return &logical.Response{
Warnings: warnings,
}, nil
}
return nil, nil
}