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: ftl secret --op can now write to 1password, fixes #1537 #1551

Merged
merged 10 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions buildengine/testdata/projects/another/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions buildengine/testdata/projects/other/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

230 changes: 102 additions & 128 deletions common/configuration/1password_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@ package configuration

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"

"github.com/alecthomas/types/optional"

"github.com/TBD54566975/ftl/internal/exec"
"github.com/TBD54566975/ftl/internal/log"
"github.com/TBD54566975/ftl/internal/slices"
)

// OnePasswordProvider is a configuration provider that reads passwords from
// 1Password vaults via the "op" command line tool.
type OnePasswordProvider struct {
OnePassword bool `name:"op" help:"Write 1Password secret references - does not write to 1Password." group:"Provider:" xor:"configwriter"`
gak marked this conversation as resolved.
Show resolved Hide resolved
Vault string `name:"op" help:"Store a secret in this 1Password vault." group:"Provider:" xor:"configwriter" placeholder:"VAULT"`
}

var _ MutableProvider[Secrets] = OnePasswordProvider{}
Expand All @@ -27,172 +25,148 @@ func (OnePasswordProvider) Role() Secrets { return
func (o OnePasswordProvider) Key() string { return "op" }
func (o OnePasswordProvider) Delete(ctx context.Context, ref Ref) error { return nil }

// Load returns either a single field if the op:// reference specifies a field, or all fields if not.
//
// A single value/password:
// op://Personal/With Spaces/username
// op --format json item get --vault Personal "With Spaces" --fields=username
// { id, value, ... }
// "value"
//
// All fields:
// op://Personal/With Spaces
// op --format json item get --vault Personal "With Spaces"
// { fields: [ { id, value, ... } ], ... }
// { id: value, ... }
// Load returns the secret stored in 1password, quoted as a JSON string.
gak marked this conversation as resolved.
Show resolved Hide resolved
func (o OnePasswordProvider) Load(ctx context.Context, ref Ref, key *url.URL) ([]byte, error) {
_, err := exec.LookPath("op")
if err != nil {
return nil, fmt.Errorf("1Password CLI tool \"op\" not found: %w", err)
if err := checkOpBinary(); err != nil {
return nil, err
}

decoded, err := base64.RawURLEncoding.DecodeString(key.Host)
vault := key.Host
full, err := getItem(ctx, vault, ref)
if err != nil {
return nil, fmt.Errorf("1Password secret reference must be a base64 encoded string: %w", err)
return nil, fmt.Errorf("get item failed: %w", err)
}

parsedRef, err := decodeSecretRef(string(decoded))
secret, ok := slices.Find(full.Fields, func(item entry) bool {
return item.ID == "password"
})
if !ok {
return nil, fmt.Errorf("password field not found in item %q", ref)
}

jsonSecret, err := json.Marshal(secret.Value)
if err != nil {
return nil, fmt.Errorf("1Password secret reference invalid: %w", err)
return nil, fmt.Errorf("json marshal failed: %w", err)
}

args := []string{"--format", "json", "item", "get", "--vault", parsedRef.Vault, parsedRef.Item}
v, fieldSpecified := parsedRef.Field.Get()
if fieldSpecified {
args = append(args, "--fields", v)
return jsonSecret, nil
}

// Store will save the given secret in 1Password via the `op` command.
//
// op does not support "create or update" as a single command. Neither does it support specifying an ID on create.
// Because of this, we need check if the item exists before creating it, and update it if it does.
func (o OnePasswordProvider) Store(ctx context.Context, ref Ref, value []byte) (*url.URL, error) {
if err := checkOpBinary(); err != nil {
return nil, err
}
output, err := exec.Capture(ctx, ".", "op", args...)

var secret string
err := json.Unmarshal(value, &secret)
if err != nil {
return nil, fmt.Errorf("run `op` with args %v: %w", args, err)
return nil, fmt.Errorf("json unmarshal failed: %w", err)
}

if fieldSpecified {
v, err := decodeSingle(output)
url := &url.URL{Scheme: "op", Host: o.Vault}

_, err = getItem(ctx, o.Vault, ref)
var notFound notFoundError
if errors.As(err, &notFound) {
gak marked this conversation as resolved.
Show resolved Hide resolved
err = createItem(ctx, o.Vault, ref, secret)
if err != nil {
return nil, err
return nil, fmt.Errorf("create item failed: %w", err)
}
return url, nil

return json.Marshal(v.Value)
} else if err != nil {
return nil, fmt.Errorf("get item failed: %w", err)
}

full, err := decodeFull(output)
err = editItem(ctx, o.Vault, ref, secret)
if err != nil {
return nil, err
return nil, fmt.Errorf("edit item failed: %w", err)
}

// Filter out anything without a value
filtered := slices.Filter(full, func(e entry) bool {
return e.Value != ""
})
// Map to id: value
var mapped = make(map[string]string, len(filtered))
for _, e := range filtered {
mapped[e.ID] = e.Value
}

return json.Marshal(mapped)
return url, nil
}

func (o OnePasswordProvider) Store(ctx context.Context, ref Ref, value []byte) (*url.URL, error) {
var opref string
if err := json.Unmarshal(value, &opref); err != nil {
return nil, fmt.Errorf("1Password value must be a JSON string containing a 1Password secret refererence: %w", err)
}
if !strings.HasPrefix(opref, "op://") {
return nil, fmt.Errorf("1Password secret reference must start with \"op://\"")
func (o OnePasswordProvider) Writer() bool { return o.Vault != "" }

func checkOpBinary() error {
_, err := exec.LookPath("op")
if err != nil {
return fmt.Errorf("1Password CLI tool \"op\" not found: %w", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to fix this instance, but convenience fyi for the future - you can denote a string with backticks to avoid having to escape all the quotes inside it:

`my "quoted" string`

}
encoded := base64.RawURLEncoding.EncodeToString([]byte(opref))
return &url.URL{Scheme: "op", Host: encoded}, nil
return nil
}

func (o OnePasswordProvider) Writer() bool { return o.OnePassword }

type entry struct {
ID string `json:"id"`
Value string `json:"value"`
type notFoundError struct {
vault string
ref Ref
}

type fullResponse struct {
Fields []entry `json:"fields"`
func (e notFoundError) Error() string {
return fmt.Sprintf("item %q not found in vault %q", e.ref, e.vault)
}

// Decode a full item response from op
func decodeFull(output []byte) ([]entry, error) {
var full fullResponse
if err := json.Unmarshal(output, &full); err != nil {
return nil, fmt.Errorf("error decoding op full response: %w", err)
}
return full.Fields, nil
// item is the JSON response from `op item get`.
type item struct {
Fields []entry `json:"fields"`
}

// Decode a single field from op
func decodeSingle(output []byte) (*entry, error) {
var single entry
if err := json.Unmarshal(output, &single); err != nil {
return nil, fmt.Errorf("error decoding op single response: %w", err)
}
return &single, nil
type entry struct {
ID string `json:"id"`
Value string `json:"value"`
}

// Custom parser for 1Password secret references because the format is not a standard URL, and we also need to
// allow users to omit the field name so that we can support secrets with multiple fields.
//
// Does not support "section-name".
//
// op://<vault-name>/<item-name>[/<field-name>]
//
// Secret references are case-insensitive and support the following characters:
//
// alphanumeric characters (a-z, A-Z, 0-9), -, _, . and the whitespace character
//
// If an item or field name includes a / or an unsupported character, use the item
// or field's unique identifier (ID) instead of its name.
//
// See https://developer.1password.com/docs/cli/secrets-reference-syntax/
type secretRef struct {
Vault string
Item string
Field optional.Option[string]
}
// op --format json item get --vault Personal "With Spaces"
func getItem(ctx context.Context, vault string, ref Ref) (*item, error) {
logger := log.FromContext(ctx)

var validCharsRegex = regexp.MustCompile(`^[a-zA-Z0-9\-_\. ]+$`)
args := []string{"--format", "json", "item", "get", "--vault", vault, ref.String()}
output, err := exec.Capture(ctx, ".", "op", args...)
logger.Debugf("Getting item with args %v", args)
if err != nil {
if strings.Contains(string(output), "isn't a vault") {
gak marked this conversation as resolved.
Show resolved Hide resolved
return nil, fmt.Errorf("vault %q not found: %w", vault, err)
gak marked this conversation as resolved.
Show resolved Hide resolved
}

func decodeSecretRef(ref string) (*secretRef, error) {
// Item not found, seen two ways of reporting this:
if strings.Contains(string(output), "not found in vault") {
return nil, notFoundError{vault, ref}
}
if strings.Contains(string(output), "isn't an item") {
return nil, notFoundError{vault, ref}
}

// Take out and check the "op://" prefix
const prefix = "op://"
if !strings.HasPrefix(ref, prefix) {
return nil, fmt.Errorf("must start with \"op://\"")
return nil, fmt.Errorf("run `op` with args %v: %w", args, err)
gak marked this conversation as resolved.
Show resolved Hide resolved
}
ref = ref[len(prefix):]

parts := strings.Split(ref, "/")

if len(parts) < 2 {
return nil, fmt.Errorf("must have at least 2 parts")
}
if len(parts) > 3 {
return nil, fmt.Errorf("must have at most 3 parts")
var full item
if err := json.Unmarshal(output, &full); err != nil {
return nil, fmt.Errorf("error decoding op full response: %w", err)
}
return &full, nil
}

for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("url parts must not be empty")
}

if !validCharsRegex.MatchString(part) {
return nil, fmt.Errorf("url part %q contains unsupported characters. regex: %q", part, validCharsRegex)
}
// op item create --category Password --vault FTL --title mod.ule "password=val ue"
gak marked this conversation as resolved.
Show resolved Hide resolved
func createItem(ctx context.Context, vault string, ref Ref, secret string) error {
args := []string{"item", "create", "--category", "Password", "--vault", vault, "--title", ref.String(), "password=" + secret}
_, err := exec.Capture(ctx, ".", "op", args...)
if err != nil {
return fmt.Errorf("create item failed in vault %q, ref %q: %w", vault, ref, err)
}

secret := secretRef{
Vault: parts[0],
Item: parts[1],
Field: optional.None[string](),
}
if len(parts) == 3 {
secret.Field = optional.Some(parts[2])
return nil
}

// op item edit --vault ftl test "password=with space"
func editItem(ctx context.Context, vault string, ref Ref, secret string) error {
args := []string{"item", "edit", "--vault", vault, ref.String(), "password=" + secret}
_, err := exec.Capture(ctx, ".", "op", args...)
if err != nil {
return fmt.Errorf("edit item failed in vault %q, ref %q: %w", vault, ref, err)
}

return &secret, nil
return nil
}
Loading
Loading