Skip to content

Commit

Permalink
Merge pull request #99 from tim-oster/main
Browse files Browse the repository at this point in the history
Add Service Account support
  • Loading branch information
volodymyrZotov authored Nov 28, 2023
2 parents 4e28fa3 + 606a57d commit ba2f48f
Show file tree
Hide file tree
Showing 15 changed files with 602 additions and 191 deletions.
16 changes: 11 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ description: |-

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

## Using a Service Account Token

The 1Password Connect Terraform Provider supports both the [1Password Connect Server](https://developer.1password.com/docs/secrets-automation/#1password-connect-server)
and [1Password Service Accounts](https://developer.1password.com/docs/secrets-automation/#1password-service-accounts). To use a service account token, the
[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
[here](https://developer.hashicorp.com/terraform/cloud-docs/run/install-software#only-install-standalone-binaries).

## Example Usage

```terraform
Expand All @@ -20,10 +27,9 @@ provider "onepassword" {
<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `token` (String) A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN.

### Optional

- `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.
- `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`.
- `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.
- `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.
- `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.
179 changes: 179 additions & 0 deletions onepassword/cli/op.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"

"github.com/1Password/connect-sdk-go/onepassword"
"github.com/Masterminds/semver/v3"
"github.com/hashicorp/terraform-plugin-log/tflog"
)

type OP struct {
binaryPath string
serviceAccountToken string
}

func New(serviceAccountToken, binaryPath string) *OP {
return &OP{
binaryPath: binaryPath,
serviceAccountToken: serviceAccountToken,
}
}

func (op *OP) GetVersion(ctx context.Context) (*semver.Version, error) {
result, err := op.execRaw(ctx, nil, p("--version"))
if err != nil {
return nil, err
}
versionString := strings.TrimSpace(string(result))
version, err := semver.NewVersion(versionString)
if err != nil {
return nil, fmt.Errorf("%w (input is: %s)", err, versionString)
}
return version, nil
}

func (op *OP) GetVault(ctx context.Context, uuid string) (*onepassword.Vault, error) {
var res *onepassword.Vault
err := op.execJson(ctx, &res, nil, p("vault"), p("get"), p(uuid))
if err != nil {
return nil, err
}
return res, nil
}

func (op *OP) GetVaultsByTitle(ctx context.Context, title string) ([]onepassword.Vault, error) {
var allVaults []onepassword.Vault
err := op.execJson(ctx, &allVaults, nil, p("vault"), p("list"))
if err != nil {
return nil, err
}

var res []onepassword.Vault
for _, v := range allVaults {
if v.Name == title {
res = append(res, v)
}
}
return res, nil
}

func (op *OP) GetItem(ctx context.Context, itemUuid, vaultUuid string) (*onepassword.Item, error) {
var res *onepassword.Item
err := op.execJson(ctx, &res, nil, p("item"), p("get"), p(itemUuid), f("vault", vaultUuid))
if err != nil {
return nil, err
}
return res, nil
}

func (op *OP) GetItemByTitle(ctx context.Context, title string, vaultUuid string) (*onepassword.Item, error) {
return op.GetItem(ctx, title, vaultUuid)
}

func (op *OP) CreateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) {
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
return nil, errors.New("item payload contains vault id that does not match vault uuid")
}
item.Vault.ID = vaultUuid

payload, err := json.Marshal(item)
if err != nil {
return nil, err
}

var res *onepassword.Item
args := []opArg{p("item"), p("create"), p("-")}
// 'op item create' command doesn't support generating passwords when using templates
// therefore need to use --generate-password flag to set it
if pf := passwordField(item); pf != nil {
recipeStr := "letters,digits,32"
if pf.Recipe != nil {
recipeStr = passwordRecipeToString(pf.Recipe)
}
args = append(args, f("generate-password", recipeStr))
}

err = op.execJson(ctx, &res, payload, args...)
if err != nil {
return nil, err
}
return res, nil
}

func (op *OP) UpdateItem(ctx context.Context, item *onepassword.Item, vaultUuid string) (*onepassword.Item, error) {
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
return nil, errors.New("item payload contains vault id that does not match vault uuid")
}
item.Vault.ID = vaultUuid

payload, err := json.Marshal(item)
if err != nil {
return nil, err
}

var res *onepassword.Item
err = op.execJson(ctx, &res, payload, p("item"), p("edit"), p(item.ID), f("vault", vaultUuid))
if err != nil {
return nil, err
}
return res, nil
}

func (op *OP) DeleteItem(ctx context.Context, item *onepassword.Item, vaultUuid string) error {
if item.Vault.ID != "" && item.Vault.ID != vaultUuid {
return errors.New("item payload contains vault id that does not match vault uuid")
}
item.Vault.ID = vaultUuid

return op.execJson(ctx, nil, nil, p("item"), p("delete"), p(item.ID), f("vault", vaultUuid))
}

func (op *OP) execJson(ctx context.Context, dst any, stdin []byte, args ...opArg) error {
result, err := op.execRaw(ctx, stdin, args...)
if err != nil {
return err
}
if dst != nil {
return json.Unmarshal(result, dst)
}
return nil
}

func (op *OP) execRaw(ctx context.Context, stdin []byte, args ...opArg) ([]byte, error) {
var cmdArgs []string
for _, arg := range args {
cmdArgs = append(cmdArgs, arg.format())
}

cmd := exec.CommandContext(ctx, op.binaryPath, cmdArgs...)
cmd.Env = append(cmd.Environ(),
"OP_SERVICE_ACCOUNT_TOKEN="+op.serviceAccountToken,
"OP_FORMAT=json",
"OP_INTEGRATION_NAME=terraform-provider-connect",
"OP_INTEGRATION_ID=GO",
//"OP_INTEGRATION_BUILDNUMBER="+version.ProviderVersion, // causes bad request errors from CLI
)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}

tflog.Debug(ctx, "running op command: "+cmd.String())

result, err := cmd.Output()
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return nil, parseCliError(exitError.Stderr)
}
if err != nil {
return nil, fmt.Errorf("failed to execute command: %w", err)
}

return result, nil
}
72 changes: 72 additions & 0 deletions onepassword/cli/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cli

import (
"fmt"
"regexp"
"strings"

"github.com/1Password/connect-sdk-go/onepassword"
)

type opArg interface {
format() string
}

type opFlag struct {
name string
value string
}

func (f opFlag) format() string {
return fmt.Sprintf("--%s=%s", f.name, f.value)
}

func f(name, value string) opArg {
return opFlag{name: name, value: value}
}

type opParam struct {
value string
}

func (p opParam) format() string {
return p.value
}

func p(value string) opArg {
return opParam{value: value}
}

var cliErrorRegex = regexp.MustCompile(`(?m)^\[ERROR] (\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}) (.+)$`)

func parseCliError(stderr []byte) error {
subMatches := cliErrorRegex.FindStringSubmatch(string(stderr))
if len(subMatches) != 3 {
return fmt.Errorf("unkown op error: %s", string(stderr))
}
return fmt.Errorf("op error: %s", subMatches[2])
}

func passwordField(item *onepassword.Item) *onepassword.ItemField {
for _, f := range item.Fields {
if f.Purpose == onepassword.FieldPurposePassword {
return f
}
}
return nil
}

func passwordRecipeToString(recipe *onepassword.GeneratorRecipe) string {
str := ""
if recipe != nil {
str += strings.Join(recipe.CharacterSets, ",")
if recipe.Length > 0 {
if str == "" {
str += fmt.Sprintf("%d", recipe.Length)
} else {
str += fmt.Sprintf(",%d", recipe.Length)
}
}
}
return str
}
101 changes: 101 additions & 0 deletions onepassword/cli/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package cli

import (
"reflect"
"testing"

"github.com/1Password/connect-sdk-go/onepassword"
)

func TestPasswordField(t *testing.T) {
tests := map[string]struct {
item *onepassword.Item
expectedField *onepassword.ItemField
}{
"should return nil if item has no fields": {
item: &onepassword.Item{},
expectedField: nil,
},
"should return nil if no password field": {
item: &onepassword.Item{
Fields: []*onepassword.ItemField{
{Purpose: onepassword.FieldPurposeNotes},
},
},
expectedField: nil,
},
"should return password field": {
item: &onepassword.Item{
Fields: []*onepassword.ItemField{
{ID: "username", Purpose: onepassword.FieldPurposeUsername},
{ID: "password", Purpose: onepassword.FieldPurposePassword},
{ID: "notes", Purpose: onepassword.FieldPurposeNotes},
},
},
expectedField: &onepassword.ItemField{
ID: "password",
Purpose: onepassword.FieldPurposePassword,
},
},
}

for description, test := range tests {
t.Run(description, func(t *testing.T) {
f := passwordField(test.item)

if !reflect.DeepEqual(f, test.expectedField) {
t.Errorf("Expected to \"%+v\" field, but got \"%+v\"", *test.expectedField, *f)
}
})
}
}

func TestPasswordRecipeToString(t *testing.T) {
tests := map[string]struct {
recipe *onepassword.GeneratorRecipe
expectedString string
}{
"should return empty string if recipe is nil": {
recipe: nil,
expectedString: "",
},
"should return empty string if recipe is default": {
recipe: &onepassword.GeneratorRecipe{},
expectedString: "",
},
"should contain expected length": {
recipe: &onepassword.GeneratorRecipe{
Length: 30,
},
expectedString: "30",
},
"should contain letters charset": {
recipe: &onepassword.GeneratorRecipe{
CharacterSets: []string{"letters"},
},
expectedString: "letters",
},
"should contain letters and digits charsets": {
recipe: &onepassword.GeneratorRecipe{
CharacterSets: []string{"letters", "digits"},
},
expectedString: "letters,digits",
},
"should contain letters and digits charsets and length": {
recipe: &onepassword.GeneratorRecipe{
Length: 30,
CharacterSets: []string{"letters", "digits"},
},
expectedString: "letters,digits,30",
},
}

for description, test := range tests {
t.Run(description, func(t *testing.T) {
actualString := passwordRecipeToString(test.recipe)
if actualString != test.expectedString {
t.Errorf("Unexpected password recipe string. Expected \"%s\", but got \"%s\"", test.expectedString, actualString)
}
})
}
}
Loading

0 comments on commit ba2f48f

Please sign in to comment.