| // 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 |
| } |