generated from TBD54566975/tbd-project-template
-
Notifications
You must be signed in to change notification settings - Fork 7
/
1password_provider.go
182 lines (150 loc) · 5.19 KB
/
1password_provider.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
package configuration
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/kballard/go-shellquote"
"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 {
Vault string
}
func (OnePasswordProvider) Role() Secrets { return Secrets{} }
func (o OnePasswordProvider) Key() string { return "op" }
func (o OnePasswordProvider) Delete(ctx context.Context, ref Ref) error {
return nil
}
// Load returns the secret stored in 1password.
func (o OnePasswordProvider) Load(ctx context.Context, ref Ref, key *url.URL) ([]byte, error) {
if err := checkOpBinary(); err != nil {
return nil, err
}
vault := key.Host
full, err := getItem(ctx, vault, ref)
if err != nil {
return nil, fmt.Errorf("get item failed: %w", err)
}
secret, ok := full.password()
if !ok {
return nil, fmt.Errorf("password field not found in item %q", ref)
}
// Just to verify that it is JSON encoded.
var decoded interface{}
err = json.Unmarshal(secret, &decoded)
if err != nil {
return nil, fmt.Errorf("secret is not JSON encoded: %w", err)
}
return secret, nil
}
var vaultRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
// 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
}
if o.Vault == "" {
return nil, fmt.Errorf("vault missing, specify vault as a flag to the controller")
}
if !vaultRegex.MatchString(o.Vault) {
return nil, fmt.Errorf("vault name %q contains invalid characters. a-z A-Z 0-9 _ . - are valid", o.Vault)
}
url := &url.URL{Scheme: "op", Host: o.Vault}
_, err := getItem(ctx, o.Vault, ref)
if errors.As(err, new(itemNotFoundError)) {
err = createItem(ctx, o.Vault, ref, value)
if err != nil {
return nil, fmt.Errorf("create item failed: %w", err)
}
return url, nil
} else if err != nil {
return nil, fmt.Errorf("get item failed: %w", err)
}
err = editItem(ctx, o.Vault, ref, value)
if err != nil {
return nil, fmt.Errorf("edit item failed: %w", err)
}
return url, nil
}
func checkOpBinary() error {
_, err := exec.LookPath("op")
if err != nil {
return fmt.Errorf("1Password CLI tool \"op\" not found: %w", err)
}
return nil
}
type itemNotFoundError struct {
vault string
ref Ref
}
func (e itemNotFoundError) Error() string {
return fmt.Sprintf("item %q not found in vault %q", e.ref, e.vault)
}
// item is the JSON response from `op item get`.
type item struct {
Fields []entry `json:"fields"`
}
type entry struct {
ID string `json:"id"`
Value string `json:"value"`
}
func (i item) password() ([]byte, bool) {
secret, ok := slices.Find(i.Fields, func(item entry) bool {
return item.ID == "password"
})
return []byte(secret.Value), ok
}
// op --format json item get --vault Personal "With Spaces"
func getItem(ctx context.Context, vault string, ref Ref) (*item, error) {
logger := log.FromContext(ctx)
args := []string{"--format", "json", "item", "get", "--vault", vault, ref.String()}
output, err := exec.Capture(ctx, ".", "op", args...)
logger.Debugf("Getting item with args %s", shellquote.Join(args...))
if err != nil {
// This is specifically not itemNotFoundError, to distinguish between vault not found and item not found.
if strings.Contains(string(output), "isn't a vault") {
return nil, fmt.Errorf("vault %q not found: %w", vault, err)
}
// Item not found, seen two ways of reporting this:
if strings.Contains(string(output), "not found in vault") {
return nil, itemNotFoundError{vault, ref}
}
if strings.Contains(string(output), "isn't an item") {
return nil, itemNotFoundError{vault, ref}
}
return nil, fmt.Errorf("run `op` with args %s: %w", shellquote.Join(args...), err)
}
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
}
// op item create --category Password --vault FTL --title mod.ule "password=val ue"
func createItem(ctx context.Context, vault string, ref Ref, secret []byte) error {
args := []string{"item", "create", "--category", "Password", "--vault", vault, "--title", ref.String(), "password=" + string(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)
}
return nil
}
// op item edit --vault ftl test "password=with space"
func editItem(ctx context.Context, vault string, ref Ref, secret []byte) error {
args := []string{"item", "edit", "--vault", vault, ref.String(), "password=" + string(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 nil
}