Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow more complex key behavior #1263

Merged
merged 6 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,48 @@ Refinery supports the following key environment variables; please see the comman

Note: `REFINERY_HONEYCOMB_METRICS_API_KEY` takes precedence over `REFINERY_HONEYCOMB_API_KEY` for the `LegacyMetrics.APIKey` configuration.

## Managing Keys

Sending data to Honeycomb requires attaching an API key to telemetry. In order to make managing telemetry easier, Refinery support the `ReceiveKeys` and `SendKey` config options, along with `AcceptOnlyListedKeys` and `SendKeyMode`. In various combinations, they have a lot of expressive power. Please see the configuration documentation for details on how to set these parameters.

A quick start for specific scenarios is below:

### A small number of services
* Set keys in your applications the way you normally would, and leave Refinery set to the defaults.

### Large number of services, central key preferred
* Do not set keys in your applications
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `all`

### Applications must set a key, but control the actual key at Refinery
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `nonblank`

### Replace most keys but permit exceptions
* Set `ReceiveKeys` to the list of exceptions
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `unlisted`

### Some applications have custom keys, but others should use central key
* Set custom keys in your applications as needed, leave others blank
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `missingonly`

### Only applications knowing a specific secret should be able to send telemetry, but a central key is preferred
* Choose an internal secret key (any arbitrary string)
* Add that secret to `ReceiveKeys`
* Set `AcceptOnlyListedKeys` to `true`
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `listedonly`

### Replace specific keys used by certain applications with the central key
* Set `AcceptOnlyListedKeys` to `false`
* Set `ReceiveKeys` to the keys that should be replaced
* Set `SendKey` to a valid Honeycomb Key
* Set `SendKeyMode` to `listedonly`


## Dry Run Mode

When getting started with Refinery or when updating sampling rules, it may be helpful to verify that the rules are working as expected before you start dropping traffic. To do so, use Dry Run Mode in Refinery.
Expand Down
5 changes: 4 additions & 1 deletion app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,16 @@ func newStartedApp(
GetPeerBufferSizeVal: 10000,
GetListenAddrVal: "127.0.0.1:" + strconv.Itoa(basePort),
GetPeerListenAddrVal: "127.0.0.1:" + strconv.Itoa(basePort+1),
IsAPIKeyValidFunc: func(k string) bool { return k == legacyAPIKey || k == nonLegacyAPIKey },
GetHoneycombAPIVal: "http://api.honeycomb.io",
GetCollectionConfigVal: config.CollectionConfig{CacheCapacity: 10000},
AddHostMetadataToTrace: enableHostMetadata,
TraceIdFieldNames: []string{"trace.trace_id"},
ParentIdFieldNames: []string{"trace.parent_id"},
SampleCache: config.SampleCacheConfig{KeptSize: 10000, DroppedSize: 100000, SizeCheckInterval: config.Duration(10 * time.Second)},
GetAccessKeyConfigVal: config.AccessKeyConfig{
ReceiveKeys: []string{legacyAPIKey, nonLegacyAPIKey},
AcceptOnlyListedKeys: true,
},
}

var err error
Expand Down
4 changes: 2 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ type Config interface {
// Returns the entire GRPC config block
GetGRPCConfig() GRPCServerParameters

// IsAPIKeyValid checks if the given API key is valid according to the rules
IsAPIKeyValid(key string) bool
// GetAccessKeyConfig returns the access key configuration
GetAccessKeyConfig() AccessKeyConfig

// GetPeers returns a list of other servers participating in this proxy cluster
GetPeers() []string
Expand Down
80 changes: 67 additions & 13 deletions config/file_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"sync"
"time"

"github.com/honeycombio/refinery/generics"
"golang.org/x/exp/slices"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -86,8 +86,71 @@ type NetworkConfig struct {

type AccessKeyConfig struct {
ReceiveKeys []string `yaml:"ReceiveKeys" default:"[]"`
SendKey string `yaml:"SendKey"`
SendKeyMode string `yaml:"SendKeyMode" default:"none"`
AcceptOnlyListedKeys bool `yaml:"AcceptOnlyListedKeys"`
keymap generics.Set[string]
}

// truncate the key to 8 characters for logging
func (a *AccessKeyConfig) sanitize(key string) string {
return fmt.Sprintf("%.8s...", key)
}

// CheckAndMaybeReplaceKey checks the given API key against the configuration
// and possibly replaces it with the configured SendKey, if the settings so indicate.
// It returns the key to use, or an error if the key is invalid given the settings.
func (a *AccessKeyConfig) CheckAndMaybeReplaceKey(apiKey string) (string, error) {
// Apply AcceptOnlyListedKeys logic BEFORE we consider replacement
if a.AcceptOnlyListedKeys && !slices.Contains(a.ReceiveKeys, apiKey) {
err := fmt.Errorf("api key %s not found in list of authorized keys", a.sanitize(apiKey))
return "", err
}

if a.SendKey != "" {
overwriteWith := ""
switch a.SendKeyMode {
case "none":
// don't replace keys at all
// (SendKey is disabled)
case "all":
// overwrite all keys, even missing ones, with the configured one
overwriteWith = a.SendKey
case "nonblank":
// only replace nonblank keys with the configured one
if apiKey != "" {
overwriteWith = a.SendKey
}
case "listedonly":
// only replace keys that are listed in the `ReceiveKeys` list,
// otherwise use original key
overwriteWith = apiKey
if slices.Contains(a.ReceiveKeys, apiKey) {
overwriteWith = a.SendKey
}
case "missingonly":
// only inject keys into telemetry that doesn't have a key at all
// otherwise use original key
overwriteWith = apiKey
if apiKey == "" {
overwriteWith = a.SendKey
}
case "unlisted":
// only replace nonblank keys that are NOT listed in the `ReceiveKeys` list
// otherwise use original key
if apiKey != "" {
overwriteWith = apiKey
if !slices.Contains(a.ReceiveKeys, apiKey) {
overwriteWith = a.SendKey
}
}
}
apiKey = overwriteWith
}

if apiKey == "" {
return "", fmt.Errorf("blank API key is not permitted with this configuration")
}
return apiKey, nil
}

type DefaultTrue bool
Expand Down Expand Up @@ -532,20 +595,11 @@ func (f *fileConfig) GetGRPCConfig() GRPCServerParameters {
return f.mainConfig.GRPCServerParameters
}

func (f *fileConfig) IsAPIKeyValid(key string) bool {
func (f *fileConfig) GetAccessKeyConfig() AccessKeyConfig {
f.mux.RLock()
defer f.mux.RUnlock()

if !f.mainConfig.AccessKeys.AcceptOnlyListedKeys {
return true
}

// if we haven't built the keymap yet, do it now
if f.mainConfig.AccessKeys.keymap == nil {
f.mainConfig.AccessKeys.keymap = generics.NewSet(f.mainConfig.AccessKeys.ReceiveKeys...)
}

return f.mainConfig.AccessKeys.keymap.Contains(key)
return f.mainConfig.AccessKeys
}

func (f *fileConfig) GetPeerManagementType() string {
Expand Down
81 changes: 81 additions & 0 deletions config/file_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package config

import "testing"

func TestAccessKeyConfig_CheckAndMaybeReplaceKey(t *testing.T) {
type fields struct {
ReceiveKeys []string
SendKey string
SendKeyMode string
AcceptOnlyListedKeys bool
}

fNone := fields{}
fRcvAccept := fields{
ReceiveKeys: []string{"key1", "key2"},
AcceptOnlyListedKeys: true,
}
fSendAll := fields{
ReceiveKeys: []string{"key1", "key2"},
SendKey: "sendkey",
SendKeyMode: "all",
}
fListed := fields{
ReceiveKeys: []string{"key1", "key2"},
SendKey: "sendkey",
SendKeyMode: "listedonly",
}
fMissing := fields{
ReceiveKeys: []string{"key1", "key2"},
SendKey: "sendkey",
SendKeyMode: "missingonly",
}
fUnlisted := fields{
ReceiveKeys: []string{"key1", "key2"},
SendKey: "sendkey",
SendKeyMode: "unlisted",
}

tests := []struct {
name string
fields fields
apiKey string
want string
wantErr bool
}{
{"empty", fNone, "userkey", "userkey", false},
{"acceptonly known key", fRcvAccept, "key1", "key1", false},
{"acceptonly unknown key", fRcvAccept, "badkey", "", true},
{"acceptonly missing key", fRcvAccept, "", "", true},
{"send all known", fSendAll, "key1", "sendkey", false},
{"send all unknown", fSendAll, "userkey", "sendkey", false},
{"send all missing", fSendAll, "", "sendkey", false},
{"listed known", fListed, "key1", "sendkey", false},
{"listed unknown", fListed, "userkey", "userkey", false},
{"listed missing", fListed, "", "", true},
{"missing known", fMissing, "key1", "key1", false},
{"missing unknown", fMissing, "userkey", "userkey", false},
{"missing missing", fMissing, "", "sendkey", false},
{"unlisted known", fUnlisted, "key1", "key1", false},
{"unlisted unknown", fUnlisted, "userkey", "sendkey", false},
{"unlisted missing", fUnlisted, "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &AccessKeyConfig{
ReceiveKeys: tt.fields.ReceiveKeys,
SendKey: tt.fields.SendKey,
SendKeyMode: tt.fields.SendKeyMode,
AcceptOnlyListedKeys: tt.fields.AcceptOnlyListedKeys,
}
got, err := a.CheckAndMaybeReplaceKey(tt.apiKey)
if (err != nil) != tt.wantErr {
t.Errorf("AccessKeyConfig.CheckAndMaybeReplaceKey() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("AccessKeyConfig.CheckAndMaybeReplaceKey() = '%v', want '%v'", got, tt.want)
}
})
}
}
38 changes: 38 additions & 0 deletions config/metadata/configMeta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,44 @@ groups:

If `false`, then all traffic is accepted and `ReceiveKeys` is ignored.

This setting is applied *before* the `SendKey` and `SendKeyMode` settings.

- name: SendKey
type: string
pattern: apikey
valuetype: nondefault
default: ""
example: "SetThisToAHoneycombKey"
reload: true
validations:
- type: format
arg: apikeyOrBlank
summary: is an optional Honeycomb API key that Refinery can use to send data to Honeycomb, depending on configuration.
description: >
If `SendKey` is set to a valid Honeycomb key, then Refinery can use
the listed key to send data.
The exact behavior depends on the value of `SendKeyMode`.

- name: SendKeyMode
type: string
valuetype: choice
choices: ["none", "all", "nonblank", "listedonly", "unlisted", "missingonly"]
default: "none"
reload: true
summary: controls how SendKey is used to replace or augment API keys used in incoming telemetry.
description: >
Controls how SendKey is used to replace or supply API keys used in
incoming telemetry. If `AcceptOnlyListedKeys` is `true`, then
`SendKeys` will only be used for events with keys listed in
`ReceiveKeys`.

`none` uses the incoming key for all telemetry (default).
`all` overwrites all keys, even missing ones, with `SendKey`.
`nonblank` overwrites all supplied keys but will not inject `SendKey` if the incoming key is blank.
`listedonly` overwrites only the keys listed in `ReceiveKeys`.
`unlisted` uses the `SendKey` for all events *except* those with keys listed in `ReceiveKeys`, which use their original keys.
`missingonly` uses the SendKey only to inject keys into events with blank keys. All other events use their original keys.

- name: RefineryTelemetry
title: "Refinery Telemetry"
description: contains configuration information for the telemetry that Refinery uses to record its own operation.
Expand Down
11 changes: 3 additions & 8 deletions config/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
// initialization
type MockConfig struct {
Callbacks []ConfigReloadCallback
IsAPIKeyValidFunc func(string) bool
GetAccessKeyConfigVal AccessKeyConfig
GetCollectorTypeVal string
GetCollectionConfigVal CollectionConfig
GetHoneycombAPIVal string
Expand Down Expand Up @@ -102,16 +102,11 @@ func (m *MockConfig) GetHashes() (string, string) {
return m.CfgHash, m.RulesHash
}

func (m *MockConfig) IsAPIKeyValid(key string) bool {
func (m *MockConfig) GetAccessKeyConfig() AccessKeyConfig {
m.Mux.RLock()
defer m.Mux.RUnlock()

// if no function is set, assume the key is valid
if m.IsAPIKeyValidFunc == nil {
return true
}

return m.IsAPIKeyValidFunc(key)
return m.GetAccessKeyConfigVal
}

func (m *MockConfig) GetCollectorType() string {
Expand Down
7 changes: 7 additions & 0 deletions config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ func (m *Metadata) Validate(data map[string]any) []string {
}
}
for _, validation := range field.Validations {
nextValidation:
switch validation.Type {
case "choice":
if !(isString(v) && slices.Contains(field.Choices, v.(string))) {
Expand All @@ -279,6 +280,12 @@ func (m *Metadata) Validate(data map[string]any) []string {
var format string
mask := false
switch validation.Arg.(string) {
case "apikeyOrBlank":
// allow an empty string as well as a valid API key
if v.(string) == "" {
break nextValidation
}
fallthrough // fallthrough to the apikey case
case "apikey":
// valid API key formats are:
// 1. 32 hex characters ("classic" Honeycomb API key)
Expand Down
18 changes: 9 additions & 9 deletions route/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package route

import (
"context"
"errors"
"fmt"
"math/rand"
"net/http"
Expand Down Expand Up @@ -38,23 +37,24 @@ func (r *Router) queryTokenChecker(next http.Handler) http.Handler {
})
}

func (r *Router) apiKeyChecker(next http.Handler) http.Handler {
func (r *Router) apiKeyProcessor(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
apiKey := req.Header.Get(types.APIKeyHeader)
if apiKey == "" {
apiKey = req.Header.Get(types.APIKeyHeaderShort)
}
if apiKey == "" {
err := errors.New("no " + types.APIKeyHeader + " header found from within authing middleware")

keycfg := r.Config.GetAccessKeyConfig()

overwriteWith, err := keycfg.CheckAndMaybeReplaceKey(apiKey)
if err != nil {
r.handlerReturnWithError(w, ErrAuthNeeded, err)
return
}
if r.Config.IsAPIKeyValid(apiKey) {
next.ServeHTTP(w, req)
return
if overwriteWith != apiKey {
req.Header.Set(types.APIKeyHeader, overwriteWith)
}
err := fmt.Errorf("api key %s not found in list of authorized keys", apiKey)
r.handlerReturnWithError(w, ErrAuthNeeded, err)
next.ServeHTTP(w, req)
})
}

Expand Down
Loading
Loading