diff --git a/docs/resources/integration.md b/docs/resources/integration.md index 2eaf3a2..d789e13 100644 --- a/docs/resources/integration.md +++ b/docs/resources/integration.md @@ -10,7 +10,37 @@ description: |- Manages a Portkey integration. Integrations connect Portkey to AI providers like OpenAI, Anthropic, Azure, etc. +## Workspace-Level Integrations +Integrations can be created at two levels: + +- **Organisation-level**: Accessible across the entire organisation. Created when `workspace_id` is not specified and using an organisation API key. +- **Workspace-level**: Scoped to a specific workspace and only accessible within that workspace. Created by either: + - Setting `workspace_id` to scope the integration to a specific workspace + - Using a workspace-scoped API key (the integration is automatically scoped to that workspace) + +## Example Usage + +### Organisation-level integration + +```terraform +resource "portkey_integration" "openai" { + name = "OpenAI Production" + ai_provider_id = "openai" + key = var.openai_api_key +} +``` + +### Workspace-level integration + +```terraform +resource "portkey_integration" "openai_dev" { + name = "OpenAI Dev" + ai_provider_id = "openai" + key = var.openai_api_key + workspace_id = portkey_workspace.dev.id +} +``` ## Schema @@ -25,11 +55,15 @@ Manages a Portkey integration. Integrations connect Portkey to AI providers like - `configurations` (String, Sensitive) Provider-specific configurations as JSON. For AWS Bedrock with IAM Role, use: jsonencode({aws_role_arn = "arn:aws:iam::...", aws_region = "us-east-1"}). For Azure OpenAI: jsonencode({azure_auth_mode = "default", azure_resource_name = "...", azure_deployment_config = [{azure_deployment_name = "...", azure_api_version = "...", azure_model_slug = "gpt-4", is_default = true}]}). This is write-only and will not be returned by the API. - `description` (String) Optional description of the integration. - `key` (String, Sensitive) API key for the provider. This is write-only and will not be returned by the API. +- `key_wo` (String, Write-Only) API key for the provider (write-only). Never stored in Terraform state or shown in plan output. Requires Terraform 1.11+. Use with key_version to control when the key is sent to the API. +- `key_version` (Number) Trigger for applying the write-only API key. Only used with key_wo. Increment this value to update the key - the key is only sent to the API when key_version changes. - `slug` (String) URL-friendly identifier for the integration. Auto-generated if not provided. +- `workspace_id` (String) Workspace ID to scope this integration to. When set, creates a workspace-level integration that is only accessible within that workspace. When not set (and using an organisation API key), creates an organisation-level integration. If using a workspace-scoped API key, the integration is automatically scoped to that workspace. ### Read-Only - `created_at` (String) Timestamp when the integration was created. - `id` (String) Integration identifier (UUID). - `status` (String) Status of the integration (active, archived). +- `type` (String) Type of integration: 'organisation' for org-level integrations or 'workspace' for workspace-scoped integrations. - `updated_at` (String) Timestamp when the integration was last updated. diff --git a/internal/client/client.go b/internal/client/client.go index 434daf8..4fa7789 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -498,6 +498,8 @@ type Integration struct { Configurations map[string]interface{} `json:"configurations,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"last_updated_at"` + Type string `json:"type,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` } // CreateIntegrationRequest represents the request to create an integration @@ -508,6 +510,7 @@ type CreateIntegrationRequest struct { Key string `json:"key,omitempty"` Description string `json:"description,omitempty"` Configurations map[string]interface{} `json:"configurations,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` } // UpdateIntegrationRequest represents the request to update an integration diff --git a/internal/provider/integration_resource.go b/internal/provider/integration_resource.go index 340e1eb..dd56847 100644 --- a/internal/provider/integration_resource.go +++ b/internal/provider/integration_resource.go @@ -43,6 +43,8 @@ type integrationResourceModel struct { KeyVersion types.Int64 `tfsdk:"key_version"` Configurations types.String `tfsdk:"configurations"` Description types.String `tfsdk:"description"` + WorkspaceID types.String `tfsdk:"workspace_id"` + Type types.String `tfsdk:"type"` Status types.String `tfsdk:"status"` CreatedAt types.String `tfsdk:"created_at"` UpdatedAt types.String `tfsdk:"updated_at"` @@ -108,6 +110,22 @@ func (r *integrationResource) Schema(_ context.Context, _ resource.SchemaRequest Description: "Optional description of the integration.", Optional: true, }, + "workspace_id": schema.StringAttribute{ + Description: "Workspace ID to scope this integration to. When set, creates a workspace-level integration that is only accessible within that workspace. When not set (and using an organisation API key), creates an organisation-level integration. If using a workspace-scoped API key, the integration is automatically scoped to that workspace.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Description: "Type of integration: 'organisation' for org-level integrations or 'workspace' for workspace-scoped integrations.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "status": schema.StringAttribute{ Description: "Status of the integration (active, archived).", Computed: true, @@ -222,6 +240,10 @@ func (r *integrationResource) Create(ctx context.Context, req resource.CreateReq createReq.Description = plan.Description.ValueString() } + if !plan.WorkspaceID.IsNull() && !plan.WorkspaceID.IsUnknown() { + createReq.WorkspaceID = plan.WorkspaceID.ValueString() + } + createResp, err := r.client.CreateIntegration(ctx, createReq) if err != nil { resp.Diagnostics.AddError( @@ -252,6 +274,21 @@ func (r *integrationResource) Create(ctx context.Context, req resource.CreateReq plan.UpdatedAt = types.StringValue(integration.CreatedAt.Format("2006-01-02T15:04:05Z07:00")) } + // Set type from API response + if integration.Type != "" { + plan.Type = types.StringValue(integration.Type) + } + + // Preserve user-provided workspace_id (API may return slug format instead of UUID) + // Only update from API if user didn't provide workspace_id + if plan.WorkspaceID.IsNull() || plan.WorkspaceID.IsUnknown() { + if integration.WorkspaceID != "" { + plan.WorkspaceID = types.StringValue(integration.WorkspaceID) + } else { + plan.WorkspaceID = types.StringNull() + } + } + // Set state to fully populated data diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -300,6 +337,21 @@ func (r *integrationResource) Read(ctx context.Context, req resource.ReadRequest state.UpdatedAt = types.StringValue(integration.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")) } + // Set type from API response + if integration.Type != "" { + state.Type = types.StringValue(integration.Type) + } + + // Preserve user-provided workspace_id from state (API may return slug format instead of UUID) + // Only update from API if not already set in state + if state.WorkspaceID.IsNull() || state.WorkspaceID.IsUnknown() { + if integration.WorkspaceID != "" { + state.WorkspaceID = types.StringValue(integration.WorkspaceID) + } else { + state.WorkspaceID = types.StringNull() + } + } + // Set refreshed state diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) @@ -421,6 +473,14 @@ func (r *integrationResource) Update(ctx context.Context, req resource.UpdateReq plan.UpdatedAt = types.StringValue(integration.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")) } + // Set type from API response + if integration.Type != "" { + plan.Type = types.StringValue(integration.Type) + } + + // Preserve workspace_id from state (workspace_id is immutable, RequiresReplace) + plan.WorkspaceID = state.WorkspaceID + diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { diff --git a/internal/provider/integration_resource_test.go b/internal/provider/integration_resource_test.go index e235273..bf40196 100644 --- a/internal/provider/integration_resource_test.go +++ b/internal/provider/integration_resource_test.go @@ -606,3 +606,38 @@ resource "portkey_integration" "test" { } `, name, key) } + +func TestAccIntegrationResource_workspaceScoped(t *testing.T) { + rName := acctest.RandomWithPrefix("tf-acc-test") + workspaceID := testAccGetEnvOrSkip(t, "TEST_WORKSPACE_ID") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccIntegrationResourceConfigWithWorkspace(rName, workspaceID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("portkey_integration.test", "id"), + resource.TestCheckResourceAttr("portkey_integration.test", "name", rName), + resource.TestCheckResourceAttr("portkey_integration.test", "ai_provider_id", "openai"), + resource.TestCheckResourceAttr("portkey_integration.test", "type", "workspace"), + resource.TestCheckResourceAttrSet("portkey_integration.test", "workspace_id"), + ), + }, + }, + }) +} + +func testAccIntegrationResourceConfigWithWorkspace(name, workspaceID string) string { + return fmt.Sprintf(` +provider "portkey" {} + +resource "portkey_integration" "test" { + name = %[1]q + ai_provider_id = "openai" + key = "sk-test-placeholder" + workspace_id = %[2]q +} +`, name, workspaceID) +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 82b1f04..8781a21 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -102,6 +102,15 @@ func getTestVirtualKey() string { return "" } +// testAccGetEnvOrSkip returns the value of an environment variable or skips the test if not set +func testAccGetEnvOrSkip(t *testing.T, envVar string) string { + if v := os.Getenv(envVar); v != "" { + return v + } + t.Skipf("%s must be set for this test", envVar) + return "" +} + // providerConfig is a shared configuration for all acceptance tests. const providerConfig = ` provider "portkey" {}