~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
361 lines · 9.4 KB Raw History
package credentials

import (
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/component"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcplibs/impl/kcpstore"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
)

var ErrNoSource = errors.New("credentials: a resolver needs an OpenBaoServer source")

type Options struct {
	Logins *openbao.Logins

	Store *kcpstore.Store

	Workspace string

	Client string

	Server string

	Vars map[string]string
}

type Resolver struct {
	logins *openbao.Logins

	workspace string

	server string

	vars map[string]string
}

var _ component.CredentialResolver = (*Resolver)(nil)

var _ component.CredentialWriter = (*Resolver)(nil)

var _ component.CredentialDeleter = (*Resolver)(nil)

func New(opts Options) (*Resolver, error) {
	logins := opts.Logins
	if logins == nil && opts.Store != nil {
		logins = openbao.NewLogins(&KCPStoreSource{Store: opts.Store, Client: opts.Client})
	}
	if logins == nil {
		return nil, ErrNoSource
	}
	opts.Workspace = cmp.Or(opts.Workspace, "root")
	vars := map[string]string{"workspace": opts.Workspace}
	maps.Copy(vars, opts.Vars)
	return &Resolver{logins: logins, workspace: opts.Workspace, server: opts.Server, vars: vars}, nil
}

func (r *Resolver) With(vars map[string]string) *Resolver {
	if r == nil {
		return nil
	}
	out := *r
	merged := make(map[string]string, len(r.vars)+len(vars))
	maps.Copy(merged, r.vars)
	maps.Copy(merged, vars)
	out.vars = merged
	return &out
}

func NewKCPResolver(store *kcpstore.Store, workspace, client, server string) (*Resolver, error) {
	return New(Options{Store: store, Workspace: workspace, Client: client, Server: server})
}

func (r *Resolver) Resolve(ctx context.Context, ref component.CredentialRef) (component.Credential, error) {
	return r.ResolveVars(ctx, ref, nil)
}

func (r *Resolver) ResolveVars(ctx context.Context, ref component.CredentialRef, vars map[string]string) (component.Credential, error) {
	ref = r.expand(ref, vars)
	if path, ok := fileRef(ref); ok {
		data, err := readFile(path)
		if err != nil {
			return component.Credential{}, fmt.Errorf("credentials: reading %s: %w", path, err)
		}
		return component.Credential{Ref: ref, Data: filterKeys(data, ref.Keys)}, nil
	}
	t, err := r.target(ref)
	if err != nil {
		return component.Credential{}, err
	}
	data, err := r.readKV(ctx, &t, ref.Version)
	if err != nil {
		return component.Credential{}, fmt.Errorf("credentials: reading %s/data/%s: %w", t.mount, t.path, err)
	}
	if len(data) == 0 {
		return component.Credential{}, fmt.Errorf("credentials: %s/data/%s: %w", t.mount, t.path, openbao.ErrNotFound)
	}
	return component.Credential{Ref: ref, Data: filterKeys(data, ref.Keys)}, nil
}

func (r *Resolver) WriteCredential(ctx context.Context, ref component.CredentialRef, data map[string]any) error {
	ref = r.expand(ref, nil)
	if path, ok := fileRef(ref); ok {
		if err := writeFile(path, data); err != nil {
			return fmt.Errorf("credentials: writing %s: %w", path, err)
		}
		return nil
	}
	if data == nil {
		return errors.New("credentials: a credential write needs data")
	}
	t, err := r.target(ref)
	if err != nil {
		return err
	}
	client, err := r.client(ctx, &t)
	if err != nil {
		return err
	}
	if err := client.PutKV(ctx, t.mount+"/data/"+t.path, data); err != nil {
		return fmt.Errorf("credentials: writing %s/data/%s: %w", t.mount, t.path, err)
	}
	return nil
}

func (r *Resolver) DeleteCredential(ctx context.Context, ref component.CredentialRef) error {
	ref = r.expand(ref, nil)
	if path, ok := fileRef(ref); ok {
		if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("credentials: deleting %s: %w", path, err)
		}
		return nil
	}
	t, err := r.target(ref)
	if err != nil {
		return err
	}
	client, err := r.client(ctx, &t)
	if err != nil {
		return err
	}
	if err := client.Delete(ctx, t.mount+"/metadata/"+t.path); err != nil {
		return fmt.Errorf("credentials: deleting %s/metadata/%s: %w", t.mount, t.path, err)
	}
	return nil
}

type target struct {
	workspace string

	name string

	mount string

	path string
}

func (r *Resolver) target(ref component.CredentialRef) (target, error) {
	path := strings.Trim(strings.TrimSpace(ref.Path), "/")
	if path == "" {
		return target{}, errors.New("credentials: the credential ref has no path")
	}
	server := cmp.Or(strings.TrimSpace(ref.Server), r.server)
	if server == "" {
		return target{}, openbao.ErrNoServer
	}
	workspace, name := openbao.ParseServer(server, r.workspace)
	mount := strings.Trim(strings.TrimSpace(ref.Mount), "/")
	if mount == "file" || mount == "file:" {
		mount = ""
	}
	return target{workspace: workspace, name: name, mount: mount, path: path}, nil
}

func (r *Resolver) client(ctx context.Context, t *target) (*openbao.Client, error) {
	client, srv, err := r.logins.ClientForMount(ctx, t.workspace, t.name, t.mount)
	if err != nil {
		return nil, err
	}
	t.mount = cmp.Or(t.mount, strings.Trim(srv.KVMount, "/"))
	if t.mount == "" {
		return nil, errors.New("credentials: the credential ref has no mount and the OpenBaoServer names no KV mount")
	}
	return client, nil
}

func (r *Resolver) readKV(ctx context.Context, t *target, version int) (map[string]any, error) {
	client, err := r.client(ctx, t)
	if err != nil {
		return nil, err
	}
	kvPath := t.mount + "/data/" + t.path
	if version > 0 {
		kvPath = fmt.Sprintf("%s?version=%d", kvPath, version)
	}
	return client.GetKV(ctx, kvPath)
}

func (r *Resolver) expand(ref component.CredentialRef, vars map[string]string) component.CredentialRef {
	merged := make(map[string]string, len(r.vars)+len(vars))
	maps.Copy(merged, r.vars)
	maps.Copy(merged, vars)
	ref.Server = expandTemplate(ref.Server, merged)
	ref.Mount = expandTemplate(ref.Mount, merged)
	ref.Path = expandTemplate(ref.Path, merged)
	return ref
}

func expandTemplate(value string, vars map[string]string) string {
	if value == "" || len(vars) == 0 {
		return value
	}
	for key, replacement := range vars {
		value = strings.ReplaceAll(value, "{"+key+"}", replacement)
	}
	return value
}

func fileRef(ref component.CredentialRef) (string, bool) {
	server := strings.TrimSpace(ref.Server)
	path := strings.TrimSpace(ref.Path)
	mount := strings.TrimSpace(ref.Mount)
	switch {
	case strings.HasPrefix(server, "file:"):
		base := strings.TrimPrefix(server, "file:")
		if strings.HasPrefix(path, "/") {
			return path, true
		}
		return filepath.Join(base, path), true
	case strings.HasPrefix(path, "file:"):
		return strings.TrimPrefix(path, "file:"), true
	case mount == "file" || mount == "file:":
		return path, true
	}
	return "", false
}

func readFile(path string) (map[string]any, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	out := map[string]any{}
	if err := json.Unmarshal(data, &out); err == nil && out != nil {
		return out, nil
	}
	return map[string]any{"value": string(data)}, nil
}

func writeFile(path string, data map[string]any) error {
	if data == nil {
		data = map[string]any{}
	}
	raw, err := json.Marshal(data)
	if err != nil {
		return err
	}
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".credential-*")
	if err != nil {
		return err
	}
	name := tmp.Name()
	defer os.Remove(name)
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if _, err := tmp.Write(append(raw, '\n')); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(name, path)
}

func filterKeys(data map[string]any, keys []string) map[string]any {
	if len(keys) == 0 {
		return data
	}
	out := make(map[string]any, len(keys))
	for _, key := range keys {
		if value, ok := data[key]; ok {
			out[key] = value
		}
	}
	return out
}

func ParseRef(value string) (component.CredentialRef, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return component.CredentialRef{}, errors.New("credentials: the ref is empty")
	}
	if path, ok := strings.CutPrefix(value, "file:"); ok {
		if path == "" {
			return component.CredentialRef{}, errors.New("credentials: the file ref has no path")
		}
		return component.CredentialRef{Mount: "file", Path: path}, nil
	}
	rest := value
	switch {
	case strings.HasPrefix(rest, "credential:"):
		rest = strings.TrimPrefix(rest, "credential:")
	case strings.HasPrefix(rest, "kv:"):
		rest = strings.TrimPrefix(rest, "kv:")
	}
	rest, version := cutVersion(rest)
	body, key, hasKey := strings.Cut(rest, "#")
	mount, path, ok := strings.Cut(body, "/")
	if !ok || mount == "" || path == "" {
		return component.CredentialRef{}, fmt.Errorf("credentials: %q is not mount/path", value)
	}
	ref := component.CredentialRef{Mount: mount, Path: path, Version: version}
	if hasKey {
		if key == "" {
			return component.CredentialRef{}, fmt.Errorf("credentials: %q names an empty key", value)
		}
		ref.Keys = []string{key}
	}
	if err := ref.Check(); err != nil {
		return component.CredentialRef{}, err
	}
	return ref, nil
}

func cutVersion(value string) (string, int) {
	path, query, ok := strings.Cut(value, "?")
	if !ok {
		return value, 0
	}
	for pair := range strings.SplitSeq(query, "&") {
		key, raw, ok := strings.Cut(pair, "=")
		if !ok || key != "version" {
			continue
		}
		version, err := strconv.Atoi(raw)
		if err != nil || version < 0 {
			return value, 0
		}
		return path, version
	}
	return value, 0
}