Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/resources/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 generated by tfplugindocs -->
## Schema
Expand All @@ -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.
3 changes: 3 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions internal/provider/integration_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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() {
Expand Down
35 changes: 35 additions & 0 deletions internal/provider/integration_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions internal/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {}
Expand Down