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

Generic secret retrieval method #36

Merged
merged 6 commits into from
Feb 22, 2025
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ MODULE.bazel.lock
.env
tools/credential-helper.exe
.bazelrc.user
.tweag-credential-helper.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ You can override the config file location using the `$CREDENTIAL_HELPER_CONFIG_F
- `.urls[].host`: Host of the url. Matches any host when empty and uses globbing otherwise (a `*` matches any characters).
- `.urls[].path`: Path of the url. Matches any path when empty and uses globbing otherwise (a `*` matches any characters).
- `.urls[].helper`: Helper to use for this url. Can be one of `s3`, `gcs`, `github`, `oci` or `null`.
- `.urls[].config`: Optional helper-specific configuration. Refer to the documentation of the chosen helper for more information.

### Example

Expand Down
5 changes: 5 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ const (
PlaceholderHomedir = "~"
)

// HelperConfigKey is the key used to store the helper configuration in the context (context.Context) as []byte.
// The encoding is expected to be json.
// The schema of the configuration is defined by the helper.
const HelperConfigKey = "helper-config"

// HelperFactory chooses a credential helper (like s3, gcs, github, ...) based on the raw uri.
type HelperFactory func(string) (Helper, error)

Expand Down
5 changes: 5 additions & 0 deletions authenticate/internal/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
filegroup(
name = "all_files",
srcs = glob(["*"]),
visibility = ["//:__subpackages__"],
)
15 changes: 15 additions & 0 deletions authenticate/internal/helperconfig/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "helperconfig",
srcs = ["helperconfig.go"],
importpath = "github.com/tweag/credential-helper/authenticate/internal/helperconfig",
visibility = ["//authenticate:__subpackages__"],
deps = ["//api"],
)

filegroup(
name = "all_files",
srcs = glob(["*"]),
visibility = ["//:__subpackages__"],
)
20 changes: 20 additions & 0 deletions authenticate/internal/helperconfig/helperconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package helperconfig

import (
"bytes"
"context"
"encoding/json"

"github.com/tweag/credential-helper/api"
)

func FromContext[T any](ctx context.Context, config T) (T, error) {
rawConfig, ok := ctx.Value(api.HelperConfigKey).([]byte)
if !ok {
return config, nil
}
decoder := json.NewDecoder(bytes.NewReader(rawConfig))
decoder.DisallowUnknownFields()
err := decoder.Decode(&config)
return config, err
}
15 changes: 15 additions & 0 deletions authenticate/internal/lookupchain/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "lookupchain",
srcs = ["lookupchain.go"],
importpath = "github.com/tweag/credential-helper/authenticate/internal/lookupchain",
visibility = ["//authenticate:__subpackages__"],
deps = ["@com_github_zalando_go_keyring//:go-keyring"],
)

filegroup(
name = "all_files",
srcs = glob(["*"]),
visibility = ["//:__subpackages__"],
)
186 changes: 186 additions & 0 deletions authenticate/internal/lookupchain/lookupchain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package lookupchain

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"strings"

keyring "github.com/zalando/go-keyring"
)

const (
SourceEnv = "env"
SourceKeyring = "keyring"
)

type LookupChain struct {
config Config
}

func New(config Config) *LookupChain {
return &LookupChain{config: config}
}

// Lookup looks up a binding in the chain.
// It returns the first value found, or an error.
func (c *LookupChain) Lookup(binding string) (string, error) {
if len(c.config) == 0 {
return "", fmt.Errorf("no sources configured to look up binding %q", binding)
}
for _, entry := range c.config {
source, err := c.sourceFor(entry)
if err != nil {
return "", fmt.Errorf("looking up binding %q: %w", binding, err)
}
result, err := source.Lookup(binding)
if err == nil {
return result, nil
}
if errors.Is(err, notFoundError) {
continue
}
return "", fmt.Errorf("looking up binding %q: %w", binding, err)
}
sourceNames := make([]string, len(c.config))
for i, entry := range c.config {
sourceNames[i] = entry.Source
}
return "", fmt.Errorf("no value found for binding %q after querying %v", binding, strings.Join(sourceNames, ", "))
}

func (c *LookupChain) sourceFor(entry ConfigEntry) (Source, error) {
decoder := json.NewDecoder(bytes.NewReader(entry.RawMessage))
decoder.DisallowUnknownFields()
var source Source

switch entry.Source {
case SourceEnv:
var env Env
if err := decoder.Decode(&env); err != nil {
return nil, fmt.Errorf("unmarshalling env source: %w", err)
}
source = &env
case SourceKeyring:
var keyring Keyring
if err := decoder.Decode(&keyring); err != nil {
return nil, fmt.Errorf("unmarshalling keyring source: %w", err)
}
source = &keyring
default:
return nil, fmt.Errorf("unknown source %q", entry.Source)
}

source.Canonicalize()
return source, nil
}

type Config []ConfigEntry

// ConfigEntry is a single entry in the lookup chain.
// This form is used when unmarshalling the config.
type ConfigEntry struct {
// Source is the name of the source used to look up the secret.
Source string `json:"source"`
json.RawMessage
}

type Source interface {
Lookup(binding string) (string, error)
Canonicalize()
}

type Env struct {
// Source is the name of the source used to look up the secret.
// It must be "env".
Source string `json:"source"`
// Name is the name of the environment variable to look up.
Name string `json:"name"`
// Binding binds the value of the environment variable to a well-known name in the helper.
// If not specified, the value is bound to the default secret of the helper.
Binding string `json:"binding,omitempty"`
}

func (e *Env) Lookup(binding string) (string, error) {
if e.Binding != binding {
return "", notFoundError
}
val, ok := os.LookupEnv(e.Name)
if !ok {
return "", notFoundError
}
return val, nil
}

func (e *Env) Canonicalize() {
e.Source = "env"
if e.Binding == "" {
e.Binding = "default"
}
}

type Keyring struct {
// Source is the name of the source used to look up the secret.
// It must be "keyring".
Source string `json:"source"`
// Key is the name of the key to look up in the keyring.
Key string `json:"key"`
// Binding binds the value of the keyring secret to a well-known name in the helper.
// If not specified, the value is bound to the default secret of the helper.
Binding string `json:"binding,omitempty"`
}

func (k *Keyring) Lookup(binding string) (string, error) {
if k.Binding != binding {
return "", notFoundError
}
val, err := keyring.Get("gh:github.com", "")
if errors.Is(err, keyring.ErrNotFound) {
return "", notFoundError
}
if err != nil {
return "", err
}
return val, nil
}

func (k *Keyring) Canonicalize() {
k.Source = "keyring"
if k.Binding == "" {
k.Binding = "default"
}
}

// Default constructs a partially marshalled Config from a slice of specific config entries.
func Default(in []Source) Config {
out := make(Config, len(in))
for i, entry := range in {
// TODO: fix
canonicalizeMethod := reflect.ValueOf(entry).MethodByName("Canonicalize")

if !canonicalizeMethod.IsValid() {
panic(fmt.Sprintf("constructing default config: invalid value at index %d is missing Canonicalize method", i))
}
canonicalizeMethod.Call(nil)

sourceField := reflect.ValueOf(entry).Elem().FieldByName("Source")
if !sourceField.IsValid() || sourceField.Type().Kind() != reflect.String {
panic(fmt.Sprintf("constructing default config: invalid value at index %d is missing Source field", i))
}

raw, err := json.Marshal(entry)
if err != nil {
panic(fmt.Sprintf("constructing default config: invalid value at index %d when marshaling inner config: %v", i, err))
}
out[i] = ConfigEntry{
Source: sourceField.String(),
RawMessage: raw,
}
}
return out
}

var notFoundError = errors.New("not found")
5 changes: 4 additions & 1 deletion bzl/private/source_files/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
release_files = [
"//:all_files",
"//agent:all_files",
"//agent/locate:all_files",
"//agent/internal:all_files",
"//agent/internal/lockfile:all_files",
"//agent/locate:all_files",
"//api:all_files",
"//authenticate/gcs:all_files",
"//authenticate/github:all_files",
"//authenticate/internal:all_files",
"//authenticate/internal/helperconfig:all_files",
"//authenticate/internal/lookupchain:all_files",
"//authenticate/null:all_files",
"//authenticate/oci:all_files",
"//authenticate/s3:all_files",
Expand Down
9 changes: 8 additions & 1 deletion cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ func foreground(ctx context.Context, helperFactory api.HelperFactory, cache api.
cfg, err := configReader.Read()
if err == nil {
helperFactory = func(uri string) (api.Helper, error) {
return cfg.FindHelper(uri)
helper, helperConfig, err := cfg.FindHelper(uri)
if err != nil {
return nil, err
}
if len(helperConfig) > 0 {
ctx = context.WithValue(ctx, api.HelperConfigKey, helperConfig)
}
return helper, nil
}
} else if err != config.ErrConfigNotFound {
logging.Fatalf("reading config: %v", err)
Expand Down
1 change: 0 additions & 1 deletion config/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ go_library(
deps = [
"//agent/locate",
"//api",
"//authenticate/null",
"//helperfactory/string",
],
)
Expand Down
42 changes: 25 additions & 17 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,57 @@ import (

"github.com/tweag/credential-helper/agent/locate"
"github.com/tweag/credential-helper/api"
authenticateNull "github.com/tweag/credential-helper/authenticate/null"
helperstringfactory "github.com/tweag/credential-helper/helperfactory/string"
)

var ErrConfigNotFound = errors.New("config file not found")

type URLConfig struct {
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
Helper string `json:"helper"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
Helper string `json:"helper"`
Config json.RawMessage `json:"config,omitempty"` // the schema of this field is defined by the helper
}

type Config struct {
URLs []URLConfig `json:"urls,omitempty"`
}

func (c Config) FindHelper(uri string) (api.Helper, error) {
func (c Config) FindHelper(uri string) (api.Helper, []byte, error) {
requested, err := url.Parse(uri)
if err != nil {
return nil, err
return nil, nil, err
}
if len(c.URLs) == 0 {
return nil, errors.New("invalid configuration file: no helpers configured")
return nil, nil, errors.New("invalid configuration file: no helpers configured")
}
for _, url := range c.URLs {
for _, urlConfig := range c.URLs {
if len(urlConfig.Helper) == 0 {
return nil, nil, errors.New("invalid configuration file: helper field is required")
}

// if a scheme is specified, it must match
if len(url.Scheme) > 0 && url.Scheme != requested.Scheme {
if len(urlConfig.Scheme) > 0 && urlConfig.Scheme != requested.Scheme {
continue
}
// if a host is specified, it must glob match
if len(url.Host) > 0 && !globMatch(url.Host, requested.Host) {
if len(urlConfig.Host) > 0 && !globMatch(urlConfig.Host, requested.Host) {
continue
}
// if a path is specified, it must glob match
if len(url.Path) > 0 && !globMatch(url.Path, requested.Path) {
if len(urlConfig.Path) > 0 && !globMatch(urlConfig.Path, requested.Path) {
continue
}
helper := helperstringfactory.HelperFromString(url.Helper)
helper := helperstringfactory.HelperFromString(urlConfig.Helper)
if helper != nil {
return helper, nil
return helper, urlConfig.Config, nil
}
return nil, fmt.Errorf("unknown helper: %s", url.Helper)
return nil, nil, fmt.Errorf("unknown helper: %s", urlConfig.Helper)
}
return &authenticateNull.Null{}, nil
// this is equivalent to null.Null{}
// but avoids the import of the null package
return helperstringfactory.HelperFromString("null"), nil, nil
}

type ConfigReader interface {
Expand All @@ -75,7 +81,9 @@ func (r OSReader) Read() (Config, error) {
defer file.Close()

var config Config
err = json.NewDecoder(file).Decode(&config)
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
err = decoder.Decode(&config)
if err != nil {
return Config{}, err
}
Expand Down