-
Notifications
You must be signed in to change notification settings - Fork 48
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #99 from tim-oster/main
Add Service Account support
- Loading branch information
Showing
15 changed files
with
602 additions
and
191 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.