diff --git a/build.sh b/build.sh index 553d4cd..333f154 100755 --- a/build.sh +++ b/build.sh @@ -1,19 +1,16 @@ #!/bin/bash # Set the version -VERSION="0.1.1" +VERSION="0.2.0" -# Build the provider -go build -o terraform-provider-phase +# Build the provider with the expected naming convention +go build -o terraform-provider-phase_v${VERSION} -# Create the plugin directory if it doesn't exist -mkdir -p ~/.terraform.d/plugins/registry.terraform.io/phasehq/phase/${VERSION}/$(go env GOOS)_$(go env GOARCH)/ - -# Move the binary to the plugin directory -mv terraform-provider-phase ~/.terraform.d/plugins/registry.terraform.io/phasehq/phase/${VERSION}/$(go env GOOS)_$(go env GOARCH)/ +# Create a symlink with the exact naming convention Terraform expects +# Format: terraform-provider-{NAME}_v{VERSION}_{OS}_{ARCH} +OS=$(go env GOOS) +ARCH=$(go env GOARCH) +ln -sf terraform-provider-phase_v${VERSION} terraform-provider-phase_v${VERSION}_${OS}_${ARCH} # Remove the lock file if it exists rm -f .terraform.lock.hcl - -# Initialize Terraform -terraform init \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 1b7c97c..e462570 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,163 +1,232 @@ -# Phase Provider +# Phase Provider Documentation -The Phase provider is used to interact with secrets stored in Phase. The provider needs to be configured with the proper credentials before it can be used. +The Phase Terraform provider allows you to manage secrets and interact with the Phase API directly from your Terraform configurations. ## Example Usage +Here's a basic example of configuring the provider and managing a secret: + ```hcl terraform { required_providers { phase = { source = "phasehq/phase" - version = "0.1.1" // replace with latest version + version = ">= 0.2.0" // Use the latest appropriate version + } + random = { + source = "hashicorp/random" + version = "~> 3.6" } } } # Configure the Phase Provider +# Ensure PHASE_TOKEN environment variable is set, or provide phase_token directly. provider "phase" { - phase_token = "pss_service:v1:..." # or "pss_user:v1:..." // A Phase Service Token or a Phase User Token (PAT) + # host = "https://your-self-hosted-phase.com" # Optional: for self-hosted instances + # skip_tls_verification = true # Optional: if using self-signed certs } -# Retrieve all secrets for an app -data "phase_secrets" "all" { - env = "development" - app_id = "your-app-id" - path = "" +# Generate a random value for a secret +resource "random_password" "db_password" { + length = 32 + special = true + override_special = "_%@" } -# Get all secrets -output "all_secret_keys" { - value = data.phase_secrets.all.secrets - sensitive = true +# Create or manage a secret in Phase +resource "phase_secret" "database_password" { + app_id = "your-app-id" # Replace with your actual App ID + env = "production" # Specify the environment + key = "DATABASE_PASSWORD" # The key for the secret + value = random_password.db_password.result + path = "/database" # Optional: specify a path (defaults to "/") + tags = ["database", "credentials"] # Optional: add tags that have already been created + comment = "Managed by Terraform" # Optional: add a comment } -# Alternatively, retrieve all secrets under a specific path -data "phase_secrets" "path_secrets" { - env = "development" - app_id = "your-app-id" - path = "/backend" +# Fetch secrets (example: all secrets at a specific path) +data "phase_secrets" "database_secrets" { + app_id = phase_secret.database_password.app_id # Use values from managed resources + env = phase_secret.database_password.env + path = phase_secret.database_password.path + tags = ["database"] # Optional: filter by tags +} + +# Output a specific secret fetched by the data source +output "db_password_read" { + value = data.phase_secrets.database_secrets.secrets["DATABASE_PASSWORD"] + sensitive = true # Always mark sensitive outputs } -# Get a single secret from that path -output "backend_secret_keys" { - value = data.phase_secrets.path_secrets.secrets["JWT_SECRET"] - sensitive = true +# Output attributes of the managed secret +output "managed_secret_version" { + value = phase_secret.database_password.version +} + +output "managed_secret_updated_at" { + value = phase_secret.database_password.updated_at } ``` -## Argument Reference +## Provider Configuration -The following arguments are supported in the provider configuration: +The following arguments are supported in the `provider "phase"` block: -* `phase_token` - (Required) The Phase authentication token. This can be either a service token or a personal access token. It can be specified with the `PHASE_SERVICE_TOKEN` or `PHASE_PAT_TOKEN` environment variable. -* `host` - (Optional) The Phase API host. Defaults to `https://api.phase.dev` for Phase Cloud. This can be specified with the `PHASE_HOST` environment variable. If a custom host is provided, "/service/public" will be appended to the URL. +* `phase_token` - (Optional, **Required** if env var not set) The Phase authentication token. This can be a Service Token (`pss_service:...`) or a Personal Access Token (`pss_user:...`). + * **Environment Variables:** This value can be provided via `PHASE_TOKEN`, `PHASE_SERVICE_TOKEN`, or `PHASE_PAT_TOKEN` environment variables (checked in that order). Providing it in the configuration block takes precedence. + * **Sensitive:** This value is sensitive. +* `host` - (Optional) The base URL for the Phase API. + * **Default:** `https://api.phase.dev` (for Phase Cloud). + * **Environment Variable:** Can be set using `PHASE_HOST`. + * **Behavior:** If a custom host is provided (not the default), the provider automatically appends `/service/public` to the URL to target the correct API endpoint (e.g., `https://your-host.com/service/public`). +* `skip_tls_verification` - (Optional) Set to `true` to disable SSL/TLS certificate validation for the `host`. Useful for self-hosted instances with self-signed certificates. **Use with caution.** Defaults to `false`. -## Data Sources +## Resources -### phase_secrets +### `phase_secret` -Retrieve secrets from Phase. +Manages a single secret within a specific application and environment in Phase. -#### Argument Reference +The provider handles create, read, update, and delete operations. If a `phase_secret` resource is defined for a secret that already exists (based on `app_id`, `env`, `path`, `key`), the provider will manage the existing secret and update it if necessary, rather than failing. -The following arguments are supported: +#### Argument Reference -* `env` - (Required) The environment name. -* `app_id` - (Required) The application ID. -* `path` - (Optional) The path to fetch secrets from. If not provided, fetches secrets from all paths. -* `key` - (Optional) A specific secret key to fetch. If provided, only this secret will be returned. +* `app_id` - (Required, ForceNew) The UUID of the Phase application where the secret resides. Changing this forces a new resource to be created. +* `env` - (Required, ForceNew) The name of the environment within the application (e.g., `development`, `production`). Changing this forces a new resource to be created. +* `key` - (Required) The key (name) of the secret (e.g., `DATABASE_URL`, `API_KEY`). +* `value` - (Required, Sensitive) The value of the secret. +* `path` - (Optional) The path where the secret is stored within the environment. Defaults to `/` (root). Example: `/database/credentials`. +* `comment` - (Optional) A description or comment for the secret. +* `tags` - (Optional) A list of strings to tag the secret with. Tags can be used for filtering when reading secrets. +* `override` - (Optional) A block to configure a **Personal Secret Override**. This requires authenticating with a User Token (PAT). **Note:** This block *configures* the override value in Phase; its *activation* must still be done via the Phase Console or CLI. + * `value` - (Required, Sensitive) The value to use when this override is active for the authenticated user. + * `is_active` - (Required, Boolean) Must be set to `true` to configure the override. The provider currently only supports setting active overrides via this block. Setting it to `false` may not explicitly deactivate it via the API, but removes the override configuration from the state. #### Attribute Reference -The following attributes are exported: +In addition to the arguments above, the following attributes are exported: -* `secrets` - A map of secret keys to their corresponding values. +* `id` - The unique UUID assigned to the secret by Phase upon creation. +* `version` - The current version number of the secret. Incremented on each update. +* `created_at` - The timestamp (UTC RFC3339 format) when the secret was first created. +* `updated_at` - The timestamp (UTC RFC3339 format) when the secret was last updated. -## Fetching Secrets +## Data Sources -### Fetching All Secrets for an App +### `phase_secrets` -To fetch all secrets for a given app: +Fetches multiple secrets from Phase based on specified filters. -```hcl -data "phase_secrets" "all" { - env = "development" - app_id = "your-app-id" - path = "" -} +#### Argument Reference -output "all_secret_keys" { - value = data.phase_secrets.all.secrets - sensitive = true -} -``` +* `app_id` - (Required) The UUID of the Phase application. +* `env` - (Required) The name of the environment. +* `path` - (Optional) The path to filter secrets by. If omitted or empty, secrets from the root path (`/`) are fetched by default (behavior might depend on API specifics, explicitly use `/` for root). **Note:** The API endpoint used might primarily fetch based on `key` if provided, potentially ignoring `path`. For guaranteed path-based fetching without a specific key, ensure `key` is omitted. For fetching *all* secrets regardless of path, this might require multiple data source calls or future provider enhancements if the API requires path specification. +* `key` - (Optional) The key of a *specific* secret to fetch. If provided, only the secret matching this key (within the specified `app_id` and `env`, considering `path` behavior mentioned above) will be returned. +* `tags` - (Optional) A list of strings (tags) to filter secrets by. Secrets matching *any* of the provided tags will be included (OR logic). -This will fetch all secrets for the specified app and environment, and output their keys. +#### Attribute Reference -### Fetching a Single Secret +* `secrets` - (Computed, Sensitive) A map where keys are the secret keys (e.g., `DATABASE_URL`) and values are their corresponding secret values. If a Personal Secret Override is active for the authenticated user, the override value will be returned here. +* `id` - A unique identifier constructed by the provider for this data source instance based on the input arguments (`app_id`, `env`, `path`, `key`, `tags`). -To fetch a specific secret: +## Importing -```hcl -data "phase_secrets" "single" { - env = "development" - app_id = "your-app-id" -} +Existing secrets managed outside of Terraform can be imported into your Terraform state. -output "database_url" { - value = data.phase_secrets.single.secrets["DATABASE_URL"] - sensitive = true -} -``` +Use the `terraform import` command with the following ID format: -This will fetch only the specified secret and output its value. +```bash +terraform import phase_secret. "{app_id}:{env}:{path}:{key}" +``` -### Fetching Secrets from a Specific Path +**Components:** -To fetch all secrets under a specific path: +* `phase_secret.`: The type and name of the resource block in your Terraform configuration (`.tf` file) that corresponds to the secret you want to import. +* `{app_id}`: The UUID of the application. +* `{env}`: The name of the environment. +* `{path}`: The **exact** path where the secret exists in Phase, including leading and trailing slashes if applicable (e.g., `/`, `/database/`, `/folder/path/`). +* `{key}`: The key of the secret. -```hcl -data "phase_secrets" "path_secrets" { - env = "development" - app_id = "your-app-id" - path = "/backend" -} +**Example:** -output "backend_secret_keys" { - value = data.phase_secrets.path_secrets.secrets["JWT_SECRET"] - sensitive = true -} +```bash +# Assuming a resource block like: resource "phase_secret" "imported_secret" { ... } +terraform import phase_secret.imported_secret "907549ca-1430-4aa0-9998-290525741005:production:/database/:DB_HOST" ``` -This will fetch all secrets under the specified path and output their keys. +After importing, run `terraform plan` to see any differences between your configuration and the imported state, and adjust your `.tf` file accordingly. -### Using Secrets +## Advanced Topics -You can use the fetched secrets in your Terraform configurations like this: +### Personal Secret Overrides -```hcl -resource "some_resource" "example" { - database_url = data.phase_secrets.single.secrets["DATABASE_URL"] - api_key = data.phase_secrets.all.secrets["API_KEY"] - backend_config = data.phase_secrets.path_secrets.secrets["BACKEND_CONFIG"] -} -``` +Personal Secret Overrides allow individual users (authenticating with a User Token/PAT) to temporarily use a different value for a secret without affecting the globally stored value. + +* **Authentication:** Requires a `pss_user:...` token. Service tokens (`pss_service:...`) cannot read or manage overrides. +* **Provider Interaction:** + * **Reading (`data "phase_secrets"`):** If an override is *active* in Phase for the authenticated user, the data source will return the override value. + * **Managing (`resource "phase_secret"`):** You can define the `override` block in a `phase_secret` resource to *configure* the override value in Phase. However, **activating** the override must still be done separately through the Phase Console or CLI. The provider essentially sets the stage for the override. +* **Visibility:** Overrides are personal. Only the user who created and activated the override (and is authenticated with their PAT) will see the overridden value via the provider. -Always mark outputs containing secret values as sensitive to prevent them from being displayed in console output or logs. +### Working with Tags -## Personal Secret Overrides +Tags provide a way to categorize and filter secrets. -Personal Secret Overrides allow individual users to temporarily override the value of a secret for their own use, without affecting the secret's value for other users or systems. Here are some important points to note about Personal Secret Overrides: +Please note: To be able to assign tags, they must be already created in the Phase Console before hand. -1. **User Token Requirement**: To use Personal Secret Overrides, you must authenticate with a Phase User Token (Personal Access Token or PAT). Service tokens do not support Personal Secret Overrides. +* **Assigning Tags:** Use the `tags` argument in the `phase_secret` resource: + ```hcl + resource "phase_secret" "api_key" { + # ... other args ... + key = "THIRD_PARTY_API_KEY" + path = "/integrations/" + tags = ["api", "billing", "external"] + } + ``` +* **Filtering by Tags:** Use the `tags` argument in the `phase_secrets` data source. It returns secrets matching *any* of the specified tags (OR logic). + ```hcl + # Fetch secrets tagged with 'database' OR 'redis' + data "phase_secrets" "cache_and_db" { + app_id = "your-app-id" + env = "staging" + tags = ["database", "redis"] + } -2. **Activation**: Personal Secret Overrides must be activated through the Phase Console or the Phase CLI. They cannot be directly triggered or modified through the Terraform provider. + # Fetch 'api' tagged secrets specifically from the '/backend' path + data "phase_secrets" "backend_api" { + app_id = "your-app-id" + env = "staging" + path = "/backend/" + tags = ["api"] + } -3. **Behavior**: When a Personal Secret Override is active for a user, the Terraform provider will automatically use the overridden value instead of the main secret value when fetching secrets. + output "api_keys" { + value = data.phase_secrets.backend_api.secrets + sensitive = true + } + ``` -4. **Visibility**: Personal Secret Overrides are only visible and applicable to the user who created them. Other users and systems will continue to see and use the main secret value. +### Secret Metadata -5. **Temporary Nature**: Personal Secret Overrides are intended for temporary use, such as during development or testing. They should not be relied upon for production configurations. +The `phase_secret` resource exports metadata about the managed secret: -Remember that the presence and value of Personal Secret Overrides depend on the authenticated user and the state of overrides in the Phase system, not on the Terraform configuration itself. \ No newline at end of file +```hcl +resource "phase_secret" "config" { + app_id = "your-app-id" + env = "production" + key = "FEATURE_FLAG_X" + value = "true" +} + +output "config_version" { + description = "Current version of the feature flag secret." + value = phase_secret.config.version +} + +output "config_last_updated" { + description = "Timestamp when the feature flag was last modified." + value = phase_secret.config.updated_at +} +``` \ No newline at end of file diff --git a/internal/provider/const.go b/internal/provider/const.go index 03c8d02..4aa7a91 100644 --- a/internal/provider/const.go +++ b/internal/provider/const.go @@ -7,7 +7,7 @@ import ( const ( // Version of the provider - Version = "0.1.2" + Version = "0.2.0" // DefaultHostURL is the default host for Phase API DefaultHostURL = "https://api.phase.dev" @@ -18,20 +18,26 @@ const ( // PhaseClient represents the client for interacting with the Phase API type PhaseClient struct { - HostURL string - HTTPClient *http.Client - Token string - TokenType string + HostURL string + HTTPClient *http.Client + Token string + TokenType string + SkipTLSVerification bool } // Secret represents a secret in the Phase API type Secret struct { - ID string `json:"id,omitempty"` - Key string `json:"key"` - Value string `json:"value"` - Comment string `json:"comment,omitempty"` - Path string `json:"path,omitempty"` - Override *SecretOverride `json:"override,omitempty"` + ID string `json:"id,omitempty"` + Key string `json:"key"` + Value string `json:"value"` + Comment string `json:"comment,omitempty"` + Path string `json:"path,omitempty"` + Tags []string `json:"tags,omitempty"` + Version int `json:"version,omitempty"` + KeyDigest string `json:"keyDigest,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Override *SecretOverride `json:"override,omitempty"` } // SecretOverride represents a personal secret override diff --git a/internal/provider/network.go b/internal/provider/network.go index ecd2f75..470f142 100644 --- a/internal/provider/network.go +++ b/internal/provider/network.go @@ -2,6 +2,7 @@ package provider import ( "bytes" + "crypto/tls" "encoding/json" "fmt" "io" @@ -16,7 +17,7 @@ import ( func (c *PhaseClient) setHeaders(req *http.Request, tokenType string) { osType := runtime.GOOS architecture := runtime.GOARCH - + details := []string{fmt.Sprintf("%s %s", osType, architecture)} currentUser, err := user.Current() @@ -33,6 +34,14 @@ func (c *PhaseClient) setHeaders(req *http.Request, tokenType string) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, c.Token)) req.Header.Set("User-Agent", userAgent) + + if c.SkipTLSVerification { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + c.HTTPClient.Transport = transport + } + } // CreateSecret creates a new secret @@ -65,7 +74,7 @@ func (c *PhaseClient) CreateSecret(appID, env, tokenType string, secret Secret) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to create secret: %s", resp.Status) + return nil, fmt.Errorf("failed to create secret: %s - %s", resp.Status, string(responseBody)) } var createdSecrets []Secret @@ -82,7 +91,7 @@ func (c *PhaseClient) CreateSecret(appID, env, tokenType string, secret Secret) } // If secretKey is empty, it fetches all secrets for the given app and environment. -func (c *PhaseClient) ReadSecret(appID, env, secretKey, tokenType string) ([]Secret, error) { +func (c *PhaseClient) ReadSecret(appID, env, secretKey, tokenType string, tags ...string) ([]Secret, error) { var url string if secretKey != "" { url = fmt.Sprintf("%s/v1/secrets/?app_id=%s&env=%s&key=%s", c.HostURL, appID, env, secretKey) @@ -90,6 +99,11 @@ func (c *PhaseClient) ReadSecret(appID, env, secretKey, tokenType string) ([]Sec url = fmt.Sprintf("%s/v1/secrets/?app_id=%s&env=%s", c.HostURL, appID, env) } + // Add tags filter if provided + if len(tags) > 0 && tags[0] != "" { + url = fmt.Sprintf("%s&tags=%s", url, strings.Join(tags, ",")) + } + req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err @@ -109,7 +123,7 @@ func (c *PhaseClient) ReadSecret(appID, env, secretKey, tokenType string) ([]Sec } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to read secret(s): %s", resp.Status) + return nil, fmt.Errorf("failed to read secret(s): %s - %s", resp.Status, string(responseBody)) } var secrets []Secret @@ -155,7 +169,7 @@ func (c *PhaseClient) UpdateSecret(appID, env, tokenType string, secret Secret) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to update secret: %s", resp.Status) + return nil, fmt.Errorf("failed to update secret: %s - %s", resp.Status, string(responseBody)) } var updatedSecrets []Secret @@ -195,8 +209,13 @@ func (c *PhaseClient) DeleteSecret(appID, env, secretID, tokenType string) error } defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to delete secret: %s", resp.Status) + return fmt.Errorf("failed to delete secret: %s - %s", resp.Status, string(responseBody)) } return nil @@ -225,7 +244,7 @@ func (c *PhaseClient) ListSecrets(appID, env, path, tokenType string) ([]Secret, } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to list secrets: %s", resp.Status) + return nil, fmt.Errorf("failed to list secrets: %s - %s", resp.Status, string(responseBody)) } var secrets []Secret @@ -235,4 +254,4 @@ func (c *PhaseClient) ListSecrets(appID, env, path, tokenType string) ([]Secret, } return secrets, nil -} \ No newline at end of file +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3fabb6a..3acdb3d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -26,6 +26,12 @@ func Provider() *schema.Provider { DefaultFunc: schema.MultiEnvDefaultFunc([]string{"PHASE_TOKEN", "PHASE_SERVICE_TOKEN", "PHASE_PAT_TOKEN"}, nil), Description: "The token for authenticating with Phase. Can be a service token or a personal access token (PAT). Can be set with PHASE_TOKEN, PHASE_SERVICE_TOKEN, or PHASE_PAT_TOKEN environment variables.", }, + "skip_tls_verification": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to skip SSL/TLS certificate validation for the PHASE_HOST. Defaults to false.", + }, }, ResourcesMap: map[string]*schema.Resource{ "phase_secret": resourceSecret(), @@ -40,6 +46,7 @@ func Provider() *schema.Provider { func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { phaseToken := d.Get("phase_token").(string) host := d.Get("host").(string) + skipTLSVerification := d.Get("skip_tls_verification").(bool) if host != DefaultHostURL { host = fmt.Sprintf("%s/service/public", host) @@ -48,10 +55,11 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} tokenType, bearerToken := extractTokenInfo(phaseToken) client := &PhaseClient{ - HostURL: host, - HTTPClient: &http.Client{}, - Token: bearerToken, - TokenType: tokenType, + HostURL: host, + HTTPClient: &http.Client{}, + Token: bearerToken, + TokenType: tokenType, + SkipTLSVerification: skipTLSVerification, } return client, nil @@ -90,6 +98,9 @@ func resourceSecret() *schema.Resource { ReadContext: resourceSecretRead, UpdateContext: resourceSecretUpdate, DeleteContext: resourceSecretDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceSecretImportState, + }, Schema: map[string]*schema.Schema{ "app_id": { @@ -120,6 +131,25 @@ func resourceSecret() *schema.Resource { Optional: true, Default: "/", }, + "tags": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "version": { + Type: schema.TypeInt, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, "override": { Type: schema.TypeSet, Optional: true, @@ -152,6 +182,15 @@ func resourceSecretCreate(ctx context.Context, d *schema.ResourceData, meta inte Path: d.Get("path").(string), } + // Handle tags if present + if v, ok := d.GetOk("tags"); ok { + tags := make([]string, 0) + for _, tag := range v.([]interface{}) { + tags = append(tags, tag.(string)) + } + secret.Tags = tags + } + if v, ok := d.GetOk("override"); ok { overrideSet := v.(*schema.Set).List() if len(overrideSet) > 0 { @@ -166,8 +205,33 @@ func resourceSecretCreate(ctx context.Context, d *schema.ResourceData, meta inte appID := d.Get("app_id").(string) env := d.Get("env").(string) + // First, try to create the secret - workaround for updating secrets via KEYs. createdSecret, err := client.CreateSecret(appID, env, fmt.Sprintf("Bearer %s", client.TokenType), secret) if err != nil { + // If we get a 409 Conflict error, the secret already exists, so try to update it instead + if strings.Contains(err.Error(), "409 Conflict") { + // Try to read the existing secret first to get its ID + existingSecrets, readErr := client.ReadSecret(appID, env, secret.Key, fmt.Sprintf("Bearer %s", client.TokenType)) + if readErr != nil { + return diag.FromErr(fmt.Errorf("error reading existing secret: %w", readErr)) + } + + if len(existingSecrets) > 0 { + // Set the ID from the existing secret + secret.ID = existingSecrets[0].ID + + // Now attempt to update + updatedSecret, updateErr := client.UpdateSecret(appID, env, fmt.Sprintf("Bearer %s", client.TokenType), secret) + if updateErr != nil { + return diag.FromErr(fmt.Errorf("error updating existing secret: %w", updateErr)) + } + + d.SetId(updatedSecret.ID) + return resourceSecretRead(ctx, d, meta) + } else { + return diag.FromErr(fmt.Errorf("received 409 Conflict but couldn't find existing secret: %w", err)) + } + } return diag.FromErr(err) } @@ -194,11 +258,19 @@ func resourceSecretRead(ctx context.Context, d *schema.ResourceData, meta interf // If a specific key was provided, use the first (and should be only) secret secret := secrets[0] - d.SetId(secret.Key) // Use the key as the ID + d.SetId(secret.ID) d.Set("key", secret.Key) d.Set("comment", secret.Comment) d.Set("path", secret.Path) + // Set the new fields + if secret.Tags != nil { + d.Set("tags", secret.Tags) + } + d.Set("version", secret.Version) + d.Set("created_at", secret.CreatedAt) + d.Set("updated_at", secret.UpdatedAt) + if secret.Override != nil && secret.Override.IsActive { d.Set("value", secret.Override.Value) d.Set("override", []interface{}{ @@ -226,6 +298,15 @@ func resourceSecretUpdate(ctx context.Context, d *schema.ResourceData, meta inte Path: d.Get("path").(string), } + // Handle tags if present + if v, ok := d.GetOk("tags"); ok { + tags := make([]string, 0) + for _, tag := range v.([]interface{}) { + tags = append(tags, tag.(string)) + } + secret.Tags = tags + } + if v, ok := d.GetOk("override"); ok { overrideSet := v.(*schema.Set).List() if len(overrideSet) > 0 { @@ -264,6 +345,78 @@ func resourceSecretDelete(ctx context.Context, d *schema.ResourceData, meta inte return nil } +func resourceSecretImportState(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + client := meta.(*PhaseClient) + importID := d.Id() + + // Parse the secret imported secret {app_id}:{env}:{path}:{key} - 907549ca-1430-4aa0-9998-290525741005:production:/folder/path/:SECRET_1 + parts := strings.SplitN(importID, ":", 4) + if len(parts) != 4 || parts[0] == "" || parts[1] == "" || parts[2] == "" || parts[3] == "" { + return nil, fmt.Errorf("unexpected format of ID (%s), expected {app_id}:{env}:{path}:{key}", importID) + } + + appID := parts[0] + env := parts[1] + path := parts[2] + key := parts[3] + + // Fetch all secrets at that given path + secretsAtPath, err := client.ListSecrets(appID, env, path, fmt.Sprintf("Bearer %s", client.TokenType)) + if err != nil { + // Handle API errors from ListSecrets + return nil, fmt.Errorf("error listing secrets for path '%s' during import: %w", path, err) + } + + // Find the specific secret matching the key within the results from the path + var targetSecret *Secret + for i := range secretsAtPath { + if secretsAtPath[i].Key == key { + targetSecret = &secretsAtPath[i] + break + } + } + + // If no secret was found, return an error + if targetSecret == nil { + return nil, fmt.Errorf("no secret found with key '%s' at path '%s' in app '%s', env '%s'", key, path, appID, env) + } + + // Populate the rest of the resource data + d.Set("app_id", appID) + d.Set("env", env) + d.Set("key", key) + d.SetId(targetSecret.ID) + d.Set("path", targetSecret.Path) + d.Set("comment", targetSecret.Comment) + d.Set("tags", targetSecret.Tags) + d.Set("version", targetSecret.Version) + d.Set("created_at", targetSecret.CreatedAt) + d.Set("updated_at", targetSecret.UpdatedAt) + + // Handle personal secret overrides + if targetSecret.Override != nil && targetSecret.Override.IsActive { + d.Set("value", targetSecret.Override.Value) + // Ensure the override block in state reflects the imported override + overrideState := []interface{}{ + map[string]interface{}{ // Convert SecretOverride struct to map[string]interface{} + "value": targetSecret.Override.Value, + "is_active": targetSecret.Override.IsActive, + }, + } + if err := d.Set("override", overrideState); err != nil { + return nil, fmt.Errorf("error setting override state during import: %w", err) + } + } else { + d.Set("value", targetSecret.Value) + // Clear the override block if no active override exists + if err := d.Set("override", []interface{}{}); err != nil { + return nil, fmt.Errorf("error clearing override state during import: %w", err) + } + } + + return []*schema.ResourceData{d}, nil +} + func dataSourceSecrets() *schema.Resource { return &schema.Resource{ ReadContext: dataSourceSecretsRead, @@ -289,6 +442,14 @@ func dataSourceSecrets() *schema.Resource { Optional: true, Description: "The key of a specific secret to fetch.", }, + "tags": { + Type: schema.TypeList, + Optional: true, + Description: "List of tags to filter secrets by. Multiple tags are combined with OR logic.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, "secrets": { Type: schema.TypeMap, Computed: true, @@ -309,10 +470,22 @@ func dataSourceSecretsRead(ctx context.Context, d *schema.ResourceData, meta int path := d.Get("path").(string) key := d.Get("key").(string) + // Handle tags if present + var tagsFilter string + if v, ok := d.GetOk("tags"); ok { + tags := make([]string, 0) + for _, tag := range v.([]interface{}) { + tags = append(tags, tag.(string)) + } + if len(tags) > 0 { + tagsFilter = strings.Join(tags, ",") + } + } + // Determine if we're fetching all secrets fetchingAll := path == "" - secrets, err := client.ReadSecret(appID, env, key, fmt.Sprintf("Bearer %s", client.TokenType)) + secrets, err := client.ReadSecret(appID, env, key, fmt.Sprintf("Bearer %s", client.TokenType), tagsFilter) if err != nil { return diag.FromErr(err) } @@ -338,7 +511,7 @@ func dataSourceSecretsRead(ctx context.Context, d *schema.ResourceData, meta int } // Generate a unique ID for the data source - d.SetId(fmt.Sprintf("%s-%s-%s-%s", appID, env, path, key)) + d.SetId(fmt.Sprintf("%s-%s-%s-%s-%s", appID, env, path, key, tagsFilter)) return nil }