Skip to content

Commit ba2f48f

Browse files
Merge pull request #99 from tim-oster/main
Add Service Account support
2 parents 4e28fa3 + 606a57d commit ba2f48f

15 files changed

+602
-191
lines changed

docs/index.md

+11-5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ description: |-
99

1010
Use the 1Password Connect Terraform Provider to reference, create, or update items in your existing vaults using [1Password Secrets Automation](https://1password.com/secrets).
1111

12+
## Using a Service Account Token
13+
14+
The 1Password Connect Terraform Provider supports both the [1Password Connect Server](https://developer.1password.com/docs/secrets-automation/#1password-connect-server)
15+
and [1Password Service Accounts](https://developer.1password.com/docs/secrets-automation/#1password-service-accounts). To use a service account token, the
16+
[1Password CLI](https://developer.1password.com/docs/cli) has to be installed on the machine running terraform. For how to do this in terraform cloud, see
17+
[here](https://developer.hashicorp.com/terraform/cloud-docs/run/install-software#only-install-standalone-binaries).
18+
1219
## Example Usage
1320

1421
```terraform
@@ -20,10 +27,9 @@ provider "onepassword" {
2027
<!-- schema generated by tfplugindocs -->
2128
## Schema
2229

23-
### Required
24-
25-
- `token` (String) A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN.
26-
2730
### Optional
2831

29-
- `url` (String) The HTTP(S) URL where your 1Password Connect API can be found. Must be provided through the OP_CONNECT_HOST environment variable if this attribute is not set.
32+
- `op_cli_path` (String) The path to the 1Password CLI binary. Can also be sourced from OP_CLI_PATH. Defaults to `op`. Only used when setting a `service_account_token`.
33+
- `service_account_token` (String) A valid token for your 1Password Service Account. Can also be sourced from OP_SERVICE_ACCOUNT_TOKEN. Either this or `token` must be set.
34+
- `token` (String) A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN. Either this or `service_account_token` must be set.
35+
- `url` (String) The HTTP(S) URL where your 1Password Connect API can be found. Must be provided through the OP_CONNECT_HOST environment variable if this attribute is not set. Can be omitted, if service_account_token is set.

onepassword/cli/op.go

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"os/exec"
10+
"strings"
11+
12+
"github.com/1Password/connect-sdk-go/onepassword"
13+
"github.com/Masterminds/semver/v3"
14+
"github.com/hashicorp/terraform-plugin-log/tflog"
15+
)
16+
17+
type OP struct {
18+
binaryPath string
19+
serviceAccountToken string
20+
}
21+
22+
func New(serviceAccountToken, binaryPath string) *OP {
23+
return &OP{
24+
binaryPath: binaryPath,
25+
serviceAccountToken: serviceAccountToken,
26+
}
27+
}
28+
29+
func (op *OP) GetVersion(ctx context.Context) (*semver.Version, error) {
30+
result, err := op.execRaw(ctx, nil, p("--version"))
31+
if err != nil {
32+
return nil, err
33+
}
34+
versionString := strings.TrimSpace(string(result))
35+
version, err := semver.NewVersion(versionString)
36+
if err != nil {
37+
return nil, fmt.Errorf("%w (input is: %s)", err, versionString)
38+
}
39+
return version, nil
40+
}
41+
42+
func (op *OP) GetVault(ctx context.Context, uuid string) (*onepassword.Vault, error) {
43+
var res *onepassword.Vault
44+
err := op.execJson(ctx, &res, nil, p("vault"), p("get"), p(uuid))
45+
if err != nil {
46+
return nil, err
47+
}
48+
return res, nil
49+
}
50+
51+
func (op *OP) GetVaultsByTitle(ctx context.Context, title string) ([]onepassword.Vault, error) {
52+
var allVaults []onepassword.Vault
53+
err := op.execJson(ctx, &allVaults, nil, p("vault"), p("list"))
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
var res []onepassword.Vault
59+
for _, v := range allVaults {
60+
if v.Name == title {
61+
res = append(res, v)
62+
}
63+
}
64+
return res, nil
65+
}
66+
67+
func (op *OP) GetItem(ctx context.Context, itemUuid, vaultUuid string) (*onepassword.Item, error) {
68+
var res *onepassword.Item
69+
err := op.execJson(ctx, &res, nil, p("item"), p("get"), p(itemUuid), f("vault", vaultUuid))
70+
if err != nil {
71+
return nil, err
72+
}
73+
return res, nil
74+
}
75+
76+
func (op *OP) GetItemByTitle(ctx context.Context, title string, vaultUuid string) (*onepassword.Item, error) {
77+
return op.GetItem(ctx, title, vaultUuid)
78+
}
79+
80+
func (op *OP) CreateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) {
81+
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
82+
return nil, errors.New("item payload contains vault id that does not match vault uuid")
83+
}
84+
item.Vault.ID = vaultUuid
85+
86+
payload, err := json.Marshal(item)
87+
if err != nil {
88+
return nil, err
89+
}
90+
91+
var res *onepassword.Item
92+
args := []opArg{p("item"), p("create"), p("-")}
93+
// 'op item create' command doesn't support generating passwords when using templates
94+
// therefore need to use --generate-password flag to set it
95+
if pf := passwordField(item); pf != nil {
96+
recipeStr := "letters,digits,32"
97+
if pf.Recipe != nil {
98+
recipeStr = passwordRecipeToString(pf.Recipe)
99+
}
100+
args = append(args, f("generate-password", recipeStr))
101+
}
102+
103+
err = op.execJson(ctx, &res, payload, args...)
104+
if err != nil {
105+
return nil, err
106+
}
107+
return res, nil
108+
}
109+
110+
func (op *OP) UpdateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) {
111+
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
112+
return nil, errors.New("item payload contains vault id that does not match vault uuid")
113+
}
114+
item.Vault.ID = vaultUuid
115+
116+
payload, err := json.Marshal(item)
117+
if err != nil {
118+
return nil, err
119+
}
120+
121+
var res *onepassword.Item
122+
err = op.execJson(ctx, &res, payload, p("item"), p("edit"), p(item.ID), f("vault", vaultUuid))
123+
if err != nil {
124+
return nil, err
125+
}
126+
return res, nil
127+
}
128+
129+
func (op *OP) DeleteItem(ctx context.Context, item *onepassword.Item, vaultUuid string) error {
130+
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
131+
return errors.New("item payload contains vault id that does not match vault uuid")
132+
}
133+
item.Vault.ID = vaultUuid
134+
135+
return op.execJson(ctx, nil, nil, p("item"), p("delete"), p(item.ID), f("vault", vaultUuid))
136+
}
137+
138+
func (op *OP) execJson(ctx context.Context, dst any, stdin []byte, args ...opArg) error {
139+
result, err := op.execRaw(ctx, stdin, args...)
140+
if err != nil {
141+
return err
142+
}
143+
if dst != nil {
144+
return json.Unmarshal(result, dst)
145+
}
146+
return nil
147+
}
148+
149+
func (op *OP) execRaw(ctx context.Context, stdin []byte, args ...opArg) ([]byte, error) {
150+
var cmdArgs []string
151+
for _, arg := range args {
152+
cmdArgs = append(cmdArgs, arg.format())
153+
}
154+
155+
cmd := exec.CommandContext(ctx, op.binaryPath, cmdArgs...)
156+
cmd.Env = append(cmd.Environ(),
157+
"OP_SERVICE_ACCOUNT_TOKEN="+op.serviceAccountToken,
158+
"OP_FORMAT=json",
159+
"OP_INTEGRATION_NAME=terraform-provider-connect",
160+
"OP_INTEGRATION_ID=GO",
161+
//"OP_INTEGRATION_BUILDNUMBER="+version.ProviderVersion, // causes bad request errors from CLI
162+
)
163+
if stdin != nil {
164+
cmd.Stdin = bytes.NewReader(stdin)
165+
}
166+
167+
tflog.Debug(ctx, "running op command: "+cmd.String())
168+
169+
result, err := cmd.Output()
170+
var exitError *exec.ExitError
171+
if errors.As(err, &exitError) {
172+
return nil, parseCliError(exitError.Stderr)
173+
}
174+
if err != nil {
175+
return nil, fmt.Errorf("failed to execute command: %w", err)
176+
}
177+
178+
return result, nil
179+
}

onepassword/cli/utils.go

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/1Password/connect-sdk-go/onepassword"
9+
)
10+
11+
type opArg interface {
12+
format() string
13+
}
14+
15+
type opFlag struct {
16+
name string
17+
value string
18+
}
19+
20+
func (f opFlag) format() string {
21+
return fmt.Sprintf("--%s=%s", f.name, f.value)
22+
}
23+
24+
func f(name, value string) opArg {
25+
return opFlag{name: name, value: value}
26+
}
27+
28+
type opParam struct {
29+
value string
30+
}
31+
32+
func (p opParam) format() string {
33+
return p.value
34+
}
35+
36+
func p(value string) opArg {
37+
return opParam{value: value}
38+
}
39+
40+
var cliErrorRegex = regexp.MustCompile(`(?m)^\[ERROR] (\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) (.+)$`)
41+
42+
func parseCliError(stderr []byte) error {
43+
subMatches := cliErrorRegex.FindStringSubmatch(string(stderr))
44+
if len(subMatches) != 3 {
45+
return fmt.Errorf("unkown op error: %s", string(stderr))
46+
}
47+
return fmt.Errorf("op error: %s", subMatches[2])
48+
}
49+
50+
func passwordField(item *onepassword.Item) *onepassword.ItemField {
51+
for _, f := range item.Fields {
52+
if f.Purpose == onepassword.FieldPurposePassword {
53+
return f
54+
}
55+
}
56+
return nil
57+
}
58+
59+
func passwordRecipeToString(recipe *onepassword.GeneratorRecipe) string {
60+
str := ""
61+
if recipe != nil {
62+
str += strings.Join(recipe.CharacterSets, ",")
63+
if recipe.Length > 0 {
64+
if str == "" {
65+
str += fmt.Sprintf("%d", recipe.Length)
66+
} else {
67+
str += fmt.Sprintf(",%d", recipe.Length)
68+
}
69+
}
70+
}
71+
return str
72+
}

onepassword/cli/utils_test.go

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package cli
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/1Password/connect-sdk-go/onepassword"
8+
)
9+
10+
func TestPasswordField(t *testing.T) {
11+
tests := map[string]struct {
12+
item *onepassword.Item
13+
expectedField *onepassword.ItemField
14+
}{
15+
"should return nil if item has no fields": {
16+
item: &onepassword.Item{},
17+
expectedField: nil,
18+
},
19+
"should return nil if no password field": {
20+
item: &onepassword.Item{
21+
Fields: []*onepassword.ItemField{
22+
{Purpose: onepassword.FieldPurposeNotes},
23+
},
24+
},
25+
expectedField: nil,
26+
},
27+
"should return password field": {
28+
item: &onepassword.Item{
29+
Fields: []*onepassword.ItemField{
30+
{ID: "username", Purpose: onepassword.FieldPurposeUsername},
31+
{ID: "password", Purpose: onepassword.FieldPurposePassword},
32+
{ID: "notes", Purpose: onepassword.FieldPurposeNotes},
33+
},
34+
},
35+
expectedField: &onepassword.ItemField{
36+
ID: "password",
37+
Purpose: onepassword.FieldPurposePassword,
38+
},
39+
},
40+
}
41+
42+
for description, test := range tests {
43+
t.Run(description, func(t *testing.T) {
44+
f := passwordField(test.item)
45+
46+
if !reflect.DeepEqual(f, test.expectedField) {
47+
t.Errorf("Expected to \"%+v\" field, but got \"%+v\"", *test.expectedField, *f)
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestPasswordRecipeToString(t *testing.T) {
54+
tests := map[string]struct {
55+
recipe *onepassword.GeneratorRecipe
56+
expectedString string
57+
}{
58+
"should return empty string if recipe is nil": {
59+
recipe: nil,
60+
expectedString: "",
61+
},
62+
"should return empty string if recipe is default": {
63+
recipe: &onepassword.GeneratorRecipe{},
64+
expectedString: "",
65+
},
66+
"should contain expected length": {
67+
recipe: &onepassword.GeneratorRecipe{
68+
Length: 30,
69+
},
70+
expectedString: "30",
71+
},
72+
"should contain letters charset": {
73+
recipe: &onepassword.GeneratorRecipe{
74+
CharacterSets: []string{"letters"},
75+
},
76+
expectedString: "letters",
77+
},
78+
"should contain letters and digits charsets": {
79+
recipe: &onepassword.GeneratorRecipe{
80+
CharacterSets: []string{"letters", "digits"},
81+
},
82+
expectedString: "letters,digits",
83+
},
84+
"should contain letters and digits charsets and length": {
85+
recipe: &onepassword.GeneratorRecipe{
86+
Length: 30,
87+
CharacterSets: []string{"letters", "digits"},
88+
},
89+
expectedString: "letters,digits,30",
90+
},
91+
}
92+
93+
for description, test := range tests {
94+
t.Run(description, func(t *testing.T) {
95+
actualString := passwordRecipeToString(test.recipe)
96+
if actualString != test.expectedString {
97+
t.Errorf("Unexpected password recipe string. Expected \"%s\", but got \"%s\"", test.expectedString, actualString)
98+
}
99+
})
100+
}
101+
}

0 commit comments

Comments
 (0)