diff --git a/Makefile b/Makefile index 72dbd36b..03590268 100644 --- a/Makefile +++ b/Makefile @@ -54,3 +54,22 @@ endif ifneq ($(WORKTREE_CLEAN), 0) @echo "[ERROR] Uncommitted changes found in worktree. Address them and try again."; exit 1; endif + +## Local Installation +# Example Provider For Local Testing +# terraform { +# required_providers { +# onepassword = { +# source = "terraform.example.com/local/onepassword" +# version = "~> 1.0.2" +# } +# } +#} +local: VERSION = "1.0.2" +local: PLUGIN_PATH = ~/.terraform.d/plugins/terraform.example.com/local/onepassword/${VERSION}/darwin_amd64 +local: build + mkdir -p $(PLUGIN_PATH) + cp ./dist/terraform-provider-onepassword $(PLUGIN_PATH) + rm ./examples/cli/.terraform.lock.hcl + rm -r ./examples/cli/.terraform + cd ./examples/cli/ && terraform init \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index cdad5fee..fcb088c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,18 +11,37 @@ Use the 1Password Connect Terraform Provider to reference, create, or update ite ## Example Usage +Connecting to 1Password Connect + ```terraform provider "onepassword" { url = "http://localhost:8080" } ``` +Connecting to 1Password local CLI + +```terraform +provider "onepassword" { + account = "username" + password = "password" +} +``` + ## Schema -### Required +### Optional + +- **token** (String, Optional) A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN. + +### Optional + +- **url** (String, Optional) 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. + +### Optional -- **token** (String, Required) A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN. +- **account** (String, Optional) Account to use for the 1Password CLI. Can also be sourced from OP_ACCOUNT ### Optional -- **url** (String, Optional) The HTTP(S) URL where your 1Password Connect API can be found. Must be provided through the the OP_CONNECT_HOST environment variable if this attribute is not set. +- **password** (String, Optional) Password to use for the 1Password CLI. Can also be sourced from OP_PASSWORD \ No newline at end of file diff --git a/examples/cli/main.tf b/examples/cli/main.tf new file mode 100644 index 00000000..d1eabc75 --- /dev/null +++ b/examples/cli/main.tf @@ -0,0 +1,30 @@ +variable "op_account" { + type = string +} +variable "op_password" { + type = string +} + +terraform { + required_providers { + onepassword = { + source = "terraform.example.com/local/onepassword" + version = "~> 1.0.2" + } + } +} + + +provider "onepassword" { + account = var.op_account + password = var.op_password +} + +data "onepassword_item" "item" { + vault = "Private" + title = "Example" +} + +output "password" { + value = data.onepassword_item.item.section +} diff --git a/onepassword/provider.go b/onepassword/provider.go index 1c152905..fa780812 100644 --- a/onepassword/provider.go +++ b/onepassword/provider.go @@ -1,11 +1,16 @@ package onepassword import ( + "context" "fmt" + "os/exec" "github.com/1Password/connect-sdk-go/connect" - "github.com/1Password/terraform-provider-onepassword/version" + "github.com/1Password/terraform-provider-onepassword/opcli" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/1Password/terraform-provider-onepassword/version" ) const ( @@ -33,6 +38,18 @@ func Provider() *schema.Provider { providerUserAgent := fmt.Sprintf(terraformProviderUserAgent, version.ProviderVersion) provider := &schema.Provider{ Schema: map[string]*schema.Schema{ + "account": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("OP_ACCOUNT", nil), + Description: "The account to execute the command by account shorthand, sign-in address, account UUID, or user UUID.", + }, + "password": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("OP_PASSWORD", nil), + Description: "The password to interact with the CLI", + }, "url": { Type: schema.TypeString, Optional: true, @@ -41,7 +58,7 @@ func Provider() *schema.Provider { }, "token": { Type: schema.TypeString, - Required: true, + Optional: true, DefaultFunc: schema.EnvDefaultFunc("OP_CONNECT_TOKEN", nil), Description: "A valid token for your 1Password Connect API. Can also be sourced from OP_CONNECT_TOKEN.", }, @@ -54,20 +71,63 @@ func Provider() *schema.Provider { "onepassword_item": resourceOnepasswordItem(), }, } - provider.ConfigureFunc = func(d *schema.ResourceData) (interface{}, error) { + provider.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { + var op bool url := d.Get("url").(string) token := d.Get("token").(string) + account := d.Get("account").(string) + password := d.Get("password").(string) - // This is not handled by setting Required to true because Terraform does not handle - // multiple required attributes well. If only one is set in the provider configuration, - // the other one is prompted for, but Terraform then forgets the value for the one that - // is defined in the code. This confusing user-experience can be avoided by handling the - // requirement of one of the attributes manually. - if url == "" { - return nil, fmt.Errorf("URL for Connect API is not set. Either provide the \"url\" field in the provider configuration or set the OP_CONNECT_HOST environment variable") + if _, err := exec.LookPath("op"); err == nil { + op = true } - return connect.NewClientWithUserAgent(url, token, providerUserAgent), nil + if url != "" || token != "" { + if url == "" { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "URL for Connect API is not set", + Detail: "Either provide the \"url\" field in the provider configuration or set the OP_CONNECT_HOST environment variable", + }} + } + if token == "" { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "TOKEN for Connect API is not set", + Detail: "Either provide the \"token\" field in the provider configuration or set the OP_CONNECT_TOKEN environment variable", + }} + } + return connect.NewClientWithUserAgent(url, token, providerUserAgent), nil + } else if account == "" { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "ACCOUNT is not set", + Detail: "Either provide the \"account\" field in the provider configuration or set the OP_ACCOUNT environment variable", + }} + } else if !op { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "op executable not found", + Detail: "Please ensure you have the 1password-cli >= 2.0.0 installed in your $PATH.", + }} + } else if password == "" { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Password is not set", + Detail: "Provide the OP_PASSWORD environment variable.", + }} + } else { + provider, err := opcli.NewCLIClient(account, password) + if err != nil { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Could not initialize CLI provider", + Detail: err.Error(), + }} + } + + return provider, nil + } } return provider } diff --git a/opcli/cli.go b/opcli/cli.go new file mode 100644 index 00000000..4f28d707 --- /dev/null +++ b/opcli/cli.go @@ -0,0 +1,83 @@ +package opcli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/1Password/connect-sdk-go/onepassword" +) + +type OnePasswordCLI struct { + account string + token string +} + +func (cli OnePasswordCLI) GetItem(item, vault string) (*onepassword.Item, error) { + output, err := cli.command( + defaultOnePasswordPath, + "item", "get", item, + "--vault", vault, + "--format", "json", + ) + if err != nil { + return nil, err + } + + var value *onepassword.Item + err = json.Unmarshal(output, &value) + if err != nil { + return nil, fmt.Errorf("failed to unmarshlal: %v data: %v", err, string(output)) + } + return value, nil +} + +func (cli OnePasswordCLI) command(name string, args ...string) (output []byte, err error) { + if cli.token == "" { + return nil, errors.New("OP client not authenticated") + } + args = append(args, + "--session", cli.token, + "--account", cli.account, + "--no-color", + ) + cmd := exec.Command(name, args...) + var stdout, stdin, stderr bytes.Buffer + + cmd.Stdin = &stdin + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + if err != nil { + stdout.Write(stderr.Bytes()) + return nil, fmt.Errorf(string(stdout.Bytes())) + } + return stdout.Bytes(), nil +} + +func getOnePasswordSessionToken(account, password string) (string, error) { + cmd := exec.Command(defaultOnePasswordPath, + "signin", + "--account", account, + "--raw", + ) + var stdout, stdin, stderr bytes.Buffer + cmd.Env = os.Environ() + + stdin.Write([]byte(password)) + cmd.Stdin = &stdin + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + stdout.Write(stderr.Bytes()) + return "", fmt.Errorf("failed to sign in: %v", string(stdout.Bytes())) + } + + return string(stdout.Bytes()), nil +} diff --git a/opcli/client.go b/opcli/client.go new file mode 100644 index 00000000..3db78534 --- /dev/null +++ b/opcli/client.go @@ -0,0 +1,194 @@ +package opcli + +import ( + "time" + + "github.com/1Password/connect-sdk-go/onepassword" +) + +const ( + defaultOnePasswordPath = "/usr/local/bin/op" +) + +type cliProvider struct { + cli OnePasswordCLI +} + +type ( + // Item represents an item returned to the consumer + Item struct { + ID string `json:"id"` + Title string `json:"title"` + + URLs []onepassword.ItemURL `json:"urls,omitempty"` + Favorite bool `json:"favorite,omitempty"` + Tags []string `json:"tags,omitempty"` + Version int `json:"version,omitempty"` + Trashed bool `json:"trashed,omitempty"` + + Vault onepassword.ItemVault `json:"vault"` + Category onepassword.ItemCategory `json:"category,omitempty"` + + Sections []*onepassword.ItemSection `json:"sections,omitempty"` + Fields []*ItemField `json:"fields,omitempty"` + Files []*onepassword.File `json:"files,omitempty"` + + LastEditedBy string `json:"last_edited_by,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + } + ItemField struct { + ID string `json:"id"` + Section *onepassword.ItemSection `json:"section,omitempty"` + Type string `json:"type"` + Purpose string `json:"purpose,omitempty"` + Label string `json:"label,omitempty"` + Value string `json:"value,omitempty"` + Generate bool `json:"generate,omitempty"` + Recipe *GeneratorRecipe `json:"recipe,omitempty"` + Entropy float64 `json:"entropy,omitempty"` + } + GeneratorRecipe struct { + Length int `json:"length,omitempty"` + CharacterSets []string `json:"character_sets,omitempty"` + } +) + +func NewCLIClient(account, password string) (*cliProvider, error) { + cli, err := NewOnePasswordCLI(account, password) + if err != nil { + return nil, err + } + return &cliProvider{ + cli: cli, + }, nil +} + +func NewItemFields(itemFields []*ItemField) []*onepassword.ItemField { + if itemFields == nil { + return nil + } + ret := make([]*onepassword.ItemField, 0, len(itemFields)) + for _, itemField := range itemFields { + ret = append(ret, NewItemField(itemField)) + } + return ret +} + +func NewItemField(itemField *ItemField) *onepassword.ItemField { + if itemField == nil { + return nil + } + return &onepassword.ItemField{ + ID: itemField.ID, + Section: itemField.Section, + Type: itemField.Type, + Purpose: itemField.Purpose, + Label: itemField.Label, + Value: itemField.Value, + Generate: itemField.Generate, + Recipe: NewGeneratorRecipe(itemField.Recipe), + Entropy: itemField.Entropy, + } +} + +func NewGeneratorRecipe(generatorRecipe *GeneratorRecipe) *onepassword.GeneratorRecipe { + if generatorRecipe == nil { + return nil + } + return &onepassword.GeneratorRecipe{ + Length: generatorRecipe.Length, + CharacterSets: generatorRecipe.CharacterSets, + } +} + +func NewItem(item *Item) *onepassword.Item { + if item == nil { + return nil + } + return &onepassword.Item{ + ID: item.ID, + Title: item.Title, + URLs: item.URLs, + Favorite: item.Favorite, + Tags: item.Tags, + Version: item.Version, + Trashed: item.Trashed, + Vault: item.Vault, + Category: item.Category, + Sections: item.Sections, + Fields: NewItemFields(item.Fields), + Files: item.Files, + LastEditedBy: item.LastEditedBy, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func (c *cliProvider) GetVaults() ([]onepassword.Vault, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) GetVault(uuid string) (*onepassword.Vault, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) GetVaultsByTitle(uuid string) ([]onepassword.Vault, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) GetItem(uuid string, vaultUUID string) (*onepassword.Item, error) { + return c.cli.GetItem(uuid, vaultUUID) +} + +func (c *cliProvider) GetItems(vaultUUID string) ([]onepassword.Item, error) { + return []onepassword.Item{}, nil +} + +func (c *cliProvider) GetItemsByTitle(title string, vaultUUID string) ([]onepassword.Item, error) { + return []onepassword.Item{}, nil +} + +func (c *cliProvider) GetItemByTitle(title string, vaultUUID string) (*onepassword.Item, error) { + return c.cli.GetItem(title, vaultUUID) +} + +func (c *cliProvider) CreateItem(item *onepassword.Item, vaultUUID string) (*onepassword.Item, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) UpdateItem(item *onepassword.Item, vaultUUID string) (*onepassword.Item, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) DeleteItem(item *onepassword.Item, vaultUUID string) error { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) GetFile(fileUUID string, itemUUID string, vaultUUID string) (*onepassword.File, error) { + //TODO implement me + panic("implement me") +} + +func (c *cliProvider) GetFileContent(file *onepassword.File) ([]byte, error) { + //TODO implement me + panic("implement me") +} + +func NewOnePasswordCLI(account, password string) (OnePasswordCLI, error) { + token, err := getOnePasswordSessionToken(account, password) + if err != nil { + return OnePasswordCLI{}, err + } + + return OnePasswordCLI{ + account: account, + token: token, + }, nil +}