Skip to content

Latest commit

 

History

History
840 lines (684 loc) · 24.1 KB

File metadata and controls

840 lines (684 loc) · 24.1 KB

WorkOS Terraform Provider - Implementation Plan

Version: 1.0 Date: January 31, 2026 Status: Ready for Implementation Target: Terraform Registry Publication


Executive Summary

This plan outlines the phased development of a production-ready Terraform provider for the WorkOS API using the Terraform Plugin Framework. The provider will be published to the public Terraform Registry following HashiCorp best practices.

Timeline: 8-12 weeks for MVP (v1.0.0) Team Size: 1-2 developers


Best Practices Summary (Research Findings)

Go Module & Project Structure

terraform-provider-workos/
├── main.go                          # Entry point: providerserver.Serve()
├── go.mod                           # Module: github.com/oso-sh/terraform-provider-workos
├── go.sum
├── internal/
│   ├── provider/
│   │   ├── provider.go              # Provider configuration & registration
│   │   ├── provider_test.go
│   │   ├── resource_*.go            # One file per resource
│   │   ├── resource_*_test.go       # Acceptance tests per resource
│   │   ├── data_source_*.go         # One file per data source
│   │   └── data_source_*_test.go
│   └── client/
│       ├── client.go                # WorkOS API client wrapper
│       ├── errors.go                # Typed error handling
│       └── models.go                # Shared API models
├── examples/
│   ├── provider/
│   │   └── provider.tf
│   ├── resources/
│   │   └── workos_*/resource.tf
│   └── data-sources/
│       └── workos_*/data-source.tf
├── docs/                            # Auto-generated by terraform-plugin-docs
├── templates/                       # tfplugindocs templates
├── .github/
│   └── workflows/
│       ├── test.yml                 # CI: build, lint, unit tests
│       └── release.yml              # CD: GoReleaser + Registry
├── .goreleaser.yml
├── terraform-registry-manifest.json
├── Makefile
├── LICENSE                          # MPL-2.0 (required)
├── CHANGELOG.md                     # Keep a Changelog format
└── README.md

Key Dependencies

// go.mod
module github.com/oso-sh/terraform-provider-workos

go 1.21

require (
    github.com/hashicorp/terraform-plugin-framework v1.5.0
    github.com/hashicorp/terraform-plugin-go v0.20.0
    github.com/hashicorp/terraform-plugin-testing v1.6.0
    github.com/hashicorp/terraform-plugin-log v0.10.0
)

Schema Design Patterns

Pattern When to Use Example
Required() Must be provided by user name, email
Optional() User can override default enabled (default: true)
Computed() Server-determined value id, created_at
Sensitive() Secret data api_key, bearer_token
ForceNew (PlanModifier) Change requires recreation organization_id
UseStateForUnknown (PlanModifier) Preserve computed value id on updates

Error Handling Pattern

// Use typed errors with WorkOS error codes
if err != nil {
    if client.IsNotFound(err) {
        resp.State.RemoveResource(ctx)  // Handle external deletion
        return
    }
    resp.Diagnostics.AddError(
        "Unable to Read Organization",
        fmt.Sprintf("WorkOS API Error: %s\n\nPlease verify the organization exists.", err),
    )
    return
}

Testing Strategy

Type Coverage Target Gate
Unit Tests >80% business logic Always run
Acceptance Tests Every CRUD + Import TF_ACC=1
Manual Testing All examples Pre-release

Implementation Phases

Phase 0: Project Foundation & Scaffolding

Duration: 3-5 days Critical Path: Yes (all phases depend on this)

Objectives

  • Initialize Go module with correct dependencies
  • Create directory structure per best practices
  • Implement provider configuration (api_key, client_id, base_url)
  • Build WorkOS API client wrapper with:
    • HTTP client with authentication header injection
    • Rate limiting with exponential backoff (honor Retry-After)
    • Structured error types for API responses
    • Request/response logging via tflog
  • Set up CI workflow (build, lint, unit tests)
  • Create Makefile with standard targets

Architectural Decisions

Decision Recommendation Rationale
HTTP Client net/http with custom wrapper Fewer dependencies, full control
Rate Limiting Reactive with retry WorkOS provides Retry-After header
Error Types Typed errors with codes Better diagnostics in resources
Model Location Centralized internal/client/models.go Reduces duplication

Verification Checklist

  • go build succeeds
  • terraform init works with local dev override
  • Provider accepts configuration
  • Provider returns meaningful error on invalid API key
  • CI workflow passes
  • Makefile targets work: build, lint, test

Key Files to Create

// main.go
func main() {
    var debug bool
    flag.BoolVar(&debug, "debug", false, "set to true to run in debug mode")
    flag.Parse()

    opts := providerserver.ServeOpts{
        Address: "registry.terraform.io/oso-sh/workos",
        Debug:   debug,
    }

    err := providerserver.Serve(context.Background(), provider.New("dev"), opts)
    if err != nil {
        log.Fatal(err.Error())
    }
}
// internal/provider/provider.go
type WorkOSProvider struct {
    version string
}

type WorkOSProviderModel struct {
    APIKey   types.String `tfsdk:"api_key"`
    ClientID types.String `tfsdk:"client_id"`
    BaseURL  types.String `tfsdk:"base_url"`
}

func (p *WorkOSProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "api_key": schema.StringAttribute{
                Required:    true,
                Sensitive:   true,
                Description: "WorkOS API key (sk_*). Can also be set via WORKOS_API_KEY env var.",
            },
            "client_id": schema.StringAttribute{
                Optional:    true,
                Description: "WorkOS Client ID for certain operations. Can also be set via WORKOS_CLIENT_ID env var.",
            },
            "base_url": schema.StringAttribute{
                Optional:    true,
                Description: "WorkOS API base URL. Defaults to https://api.workos.com",
            },
        },
    }
}

Phase 1: Organization Resource (MVP Foundation)

Duration: 5-7 days Critical Path: Yes (most resources depend on Organization) Dependencies: Phase 0 complete

Objectives

  • Implement workos_organization resource with full CRUD
  • Implement workos_organization data source
  • Establish resource implementation patterns for all subsequent resources
  • Set up acceptance testing infrastructure
  • Generate initial documentation

Resource Schema

resource "workos_organization" "example" {
  name    = "Acme Corporation"          # Required
  domains = ["acme.com", "acmecorp.com"] # Optional, Set[String]

  allow_profiles_outside_organization = false  # Optional, default: false

  # Computed
  # id         = "org_01H..."
  # created_at = "2026-01-31T..."
  # updated_at = "2026-01-31T..."
}

API Mapping

Operation Endpoint Notes
Create POST /organizations Returns full object
Read GET /organizations/{id} 404 = externally deleted
Update PUT /organizations/{id} Partial updates supported
Delete DELETE /organizations/{id} Idempotent
Import By ID terraform import workos_organization.example org_01H...

Acceptance Tests Required

func TestAccOrganizationResource_Basic(t *testing.T)      // Create/Read
func TestAccOrganizationResource_Update(t *testing.T)     // Update name
func TestAccOrganizationResource_Domains(t *testing.T)    // Domain management
func TestAccOrganizationResource_Import(t *testing.T)     // Import state
func TestAccOrganizationResource_Disappears(t *testing.T) // External deletion

Verification Checklist

  • All CRUD operations work against WorkOS staging API
  • Import correctly populates state
  • All acceptance tests pass
  • Data source returns correct organization by ID and domain
  • Documentation generates correctly
  • Resource handles external deletion gracefully

Phase 2: SSO Connection Resource

Duration: 7-10 days Critical Path: Yes (core enterprise feature) Dependencies: Phase 1 complete

Objectives

  • Implement workos_connection resource with type-specific blocks
  • Handle SAML, OAuth, and OIDC connection types
  • Manage sensitive certificate data correctly
  • Implement workos_connection data source

Resource Schema

resource "workos_connection" "okta_sso" {
  organization_id = workos_organization.main.id  # Required, ForceNew
  connection_type = "OktaSAML"                   # Required, ForceNew
  name            = "Okta SSO"                   # Optional

  # Type-specific block (exactly one required)
  okta_saml {
    idp_entity_id   = "http://www.okta.com/..."
    idp_sso_url     = "https://example.okta.com/app/..."
    idp_certificate = file("${path.module}/okta-cert.pem")  # Sensitive
  }

  # Computed
  # id     = "conn_01H..."
  # state  = "active"
  # status = "linked"
}

Supported Connection Types

Type Block Name Key Attributes
OktaSAML okta_saml idp_entity_id, idp_sso_url, idp_certificate
AzureSAML azure_saml idp_entity_id, idp_sso_url, idp_certificate
GoogleSAML google_saml idp_entity_id, idp_sso_url, idp_certificate
GenericSAML generic_saml idp_entity_id, idp_sso_url, idp_certificate
GenericOIDC generic_oidc client_id, client_secret, issuer

Validation Rules

  • Exactly one type-specific block must be provided
  • connection_type must match the provided block
  • Certificate must be valid PEM format

Verification Checklist

  • SAML connection can be created with certificate
  • OIDC connection can be created
  • Connection type validation works correctly
  • Certificate is marked sensitive and not logged
  • Import works correctly
  • ForceNew triggers on organization_id and connection_type changes

Phase 3: Directory Sync Resource

Duration: 5-7 days Critical Path: Yes (core enterprise feature) Dependencies: Phase 1 complete

Objectives

  • Implement workos_directory resource
  • Handle SCIM endpoint and bearer token correctly
  • Implement workos_directory data source
  • Implement workos_directory_user data source
  • Implement workos_directory_group data source

Resource Schema

resource "workos_directory" "okta_scim" {
  organization_id = workos_organization.main.id  # Required, ForceNew
  name            = "Okta Directory"             # Required
  type            = "okta scim v2.0"             # Required, ForceNew

  # Computed
  # id           = "directory_01H..."
  # state        = "linked"
  # bearer_token = "..."  # Sensitive
  # endpoint     = "https://api.workos.com/scim/v2/directories/..."
}

Supported Directory Types

  • azure scim v2.0
  • okta scim v2.0
  • generic scim v2.0
  • google workspace
  • workday
  • rippling

Verification Checklist

  • Directory can be created for each supported type
  • Bearer token is computed and marked sensitive
  • SCIM endpoint is computed correctly
  • Directory user data source returns correct user
  • Directory group data source returns correct group
  • Import works correctly

Phase 4: Webhook Resource

Duration: 3-5 days Critical Path: No (independent feature, can parallelize) Dependencies: Phase 0 complete

Objectives

  • Implement workos_webhook resource
  • Handle event type validation
  • Manage webhook secret correctly

Resource Schema

resource "workos_webhook" "main" {
  url     = "https://api.example.com/webhooks/workos"  # Required, HTTPS
  secret  = var.webhook_secret                          # Required, Sensitive
  enabled = true                                        # Optional, default: true

  events = [
    "dsync.activated",
    "dsync.user.created",
    "user.created",
    "organization.created",
  ]
}

Event Validation

  • Validate against known WorkOS event types
  • Warn (don't error) on unrecognized events for forward compatibility

Verification Checklist

  • Webhook can be created with HTTPS URL
  • Events can be updated
  • Secret is marked sensitive
  • Event validation works
  • Import works correctly

Phase 5: AuthKit User Resources

Duration: 7-10 days Critical Path: Yes (required for MVP) Dependencies: Phase 1 complete

Objectives

  • Implement workos_user resource
  • Handle write-only password field correctly
  • Implement workos_organization_membership resource
  • Implement workos_user data source

User Resource Schema

resource "workos_user" "admin" {
  email      = "admin@acme.com"  # Required
  first_name = "Jane"           # Optional
  last_name  = "Admin"          # Optional

  email_verified = true         # Optional, default: false
  password       = var.password # Optional, Sensitive, WriteOnly

  # Computed
  # id         = "user_01H..."
  # created_at = "2026-01-31T..."
  # updated_at = "2026-01-31T..."
}

Organization Membership Schema

resource "workos_organization_membership" "jane_admin" {
  user_id         = workos_user.admin.id          # Required, ForceNew
  organization_id = workos_organization.main.id   # Required, ForceNew
  role_slug       = "admin"                       # Optional
}

Write-Only Password Handling

// Custom plan modifier to prevent password drift
func (m passwordPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
    // Password is write-only: if state has a value and config has same value,
    // preserve state to prevent perpetual diff
    if !req.StateValue.IsNull() && !req.ConfigValue.IsNull() {
        if req.StateValue.ValueString() == req.ConfigValue.ValueString() {
            resp.PlanValue = req.StateValue
        }
    }
}

Verification Checklist

  • User can be created with password
  • Password is never read back or stored in plain state
  • Email update triggers correctly
  • Organization membership can be created
  • Role can be updated
  • Import works for both resources

Phase 6: Documentation, Examples & Polish

Duration: 5-7 days Critical Path: Yes (required for Registry) Dependencies: Phases 1-5 complete

Objectives

  • Complete all documentation using terraform-plugin-docs
  • Create comprehensive example configurations
  • Polish error messages
  • Prepare CHANGELOG.md

Documentation Structure

docs/
├── index.md                           # Provider overview
├── resources/
│   ├── organization.md
│   ├── connection.md
│   ├── directory.md
│   ├── webhook.md
│   ├── user.md
│   └── organization_membership.md
└── data-sources/
    ├── organization.md
    ├── connection.md
    ├── directory.md
    ├── directory_user.md
    ├── directory_group.md
    └── user.md

Example Configurations

examples/
├── provider/
│   └── provider.tf
├── resources/
│   ├── workos_organization/
│   │   └── resource.tf
│   ├── workos_connection/
│   │   └── resource.tf
│   └── ...
├── data-sources/
│   └── ...
└── complete-sso/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf

terraform-plugin-docs Usage

# Install
go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest

# Generate documentation
tfplugindocs generate

# Validate
tfplugindocs validate

Verification Checklist

  • go generate ./... produces correct documentation
  • All examples are syntactically valid (terraform validate)
  • Documentation renders correctly on Registry preview
  • CHANGELOG follows Keep a Changelog format
  • All error messages are user-friendly and actionable

Phase 7: Release & Registry Publication

Duration: 3-5 days Critical Path: Yes (final gate) Dependencies: All previous phases complete

Objectives

  • Complete release automation with GoReleaser
  • Set up GPG signing
  • Publish to Terraform Registry
  • Perform production validation

Registry Requirements Checklist

Requirement Status
Public GitHub repository
Repository named terraform-provider-workos
LICENSE file (MPL-2.0)
README.md present
terraform-registry-manifest.json present
Semantic version tag (v1.0.0)
GPG-signed releases
docs/ directory with generated docs

terraform-registry-manifest.json

{
  "version": 1,
  "metadata": {
    "protocol_versions": ["6.0"]
  }
}

GoReleaser Configuration

# .goreleaser.yml
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - env:
      - CGO_ENABLED=0
    mod_timestamp: '{{ .CommitTimestamp }}'
    flags:
      - -trimpath
    ldflags:
      - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}'
    goos:
      - freebsd
      - windows
      - linux
      - darwin
    goarch:
      - amd64
      - '386'
      - arm
      - arm64
    ignore:
      - goos: darwin
        goarch: '386'
    binary: '{{ .ProjectName }}_v{{ .Version }}'

archives:
  - format: zip
    name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'

checksum:
  name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS'
  algorithm: sha256

signs:
  - artifacts: checksum
    args:
      - "--batch"
      - "--local-user"
      - "{{ .Env.GPG_FINGERPRINT }}"
      - "--output"
      - "${signature}"
      - "--detach-sign"
      - "${artifact}"

release:
  draft: false

Release Process

# 1. Update CHANGELOG.md
# 2. Commit changes
git add .
git commit -m "chore: prepare v1.0.0 release"

# 3. Create and push tag
git tag v1.0.0
git push origin v1.0.0

# 4. GitHub Actions runs GoReleaser automatically
# 5. Terraform Registry detects new release via webhook

Publication Steps

  1. Sign into registry.terraform.io with GitHub account
  2. Authorize Terraform Registry GitHub app
  3. Add GPG public key to Registry signing keys
  4. Click "Publish" → "Provider"
  5. Select oso-sh/terraform-provider-workos
  6. Registry validates and publishes

Common Pitfalls to Avoid

Pitfall Prevention
Private repository Ensure repo is public
Missing tags Use vX.Y.Z format
Unsigned releases Configure GPG in GoReleaser
Missing LICENSE Include MPL-2.0
Docs over 500KB Keep examples concise
Missing platform binaries Configure all targets in GoReleaser

Verification Checklist

  • GoReleaser builds all platforms successfully
  • Releases are GPG signed
  • Registry detects and publishes release
  • Provider installable via terraform init
  • All examples work with published provider
  • Documentation visible on Registry

Critical Path Summary

Phase 0: Foundation (3-5 days)
    │
    ▼
Phase 1: Organization (5-7 days) ──────► Phase 2: Connection (7-10 days)
    │                                           │
    │                                           ▼
    └──────────────────────────────────► Phase 3: Directory (5-7 days)
    │
    ▼
Phase 4: Webhook (3-5 days) [Can parallelize with 2-3]
    │
    ▼
Phase 5: User/Membership (7-10 days)
    │
    ▼
Phase 6: Documentation (5-7 days)
    │
    ▼
Phase 7: Release (3-5 days)

Total Estimated Duration: 8-12 weeks


Risk Register

Risk Phase Probability Impact Mitigation
WorkOS API changes All Medium High Subscribe to changelog, version client
Rate limiting in CI 2-5 Medium Medium Exponential backoff, serial tests
Complex SSO testing 2 High Medium Use WorkOS sandbox or mock IdP
Write-only password handling 5 High Medium Document limitation, custom plan modifier
Registry publication issues 7 Low High Follow HashiCorp checklist exactly
Certificate format variations 2 Medium Medium Document formats, test multiple certs

Quality Gates

Before Each Phase Completion

  • All acceptance tests pass (TF_ACC=1 go test -v ./...)
  • Unit test coverage >80% for new code
  • No linting errors (golangci-lint run)
  • Documentation generates successfully
  • Examples validate (terraform validate)

Before v1.0.0 Release

  • All Priority 1 & 2 resources implemented
  • Test coverage >80% overall
  • All examples tested manually
  • Import works for all resources
  • Error messages are user-friendly
  • Rate limiting works correctly
  • Documentation complete and accurate

Appendix: Testing Patterns

Acceptance Test Template

func TestAccOrganizationResource_Basic(t *testing.T) {
    name := acctest.RandomWithPrefix("tf-acc-test")

    resource.Test(t, resource.TestCase{
        PreCheck:                 func() { testAccPreCheck(t) },
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        CheckDestroy:             testAccCheckOrganizationDestroy,
        Steps: []resource.TestStep{
            // Create and Read
            {
                Config: testAccOrganizationResourceConfig(name),
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr("workos_organization.test", "name", name),
                    resource.TestCheckResourceAttrSet("workos_organization.test", "id"),
                    resource.TestCheckResourceAttrSet("workos_organization.test", "created_at"),
                ),
            },
            // ImportState
            {
                ResourceName:      "workos_organization.test",
                ImportState:       true,
                ImportStateVerify: true,
            },
            // Update
            {
                Config: testAccOrganizationResourceConfig(name + "-updated"),
                Check: resource.ComposeAggregateTestCheckFunc(
                    resource.TestCheckResourceAttr("workos_organization.test", "name", name+"-updated"),
                ),
            },
        },
    })
}

func testAccOrganizationResourceConfig(name string) string {
    return fmt.Sprintf(`
resource "workos_organization" "test" {
  name = %[1]q
}
`, name)
}

Mock Server Pattern for Unit Tests

func setupMockServer(t *testing.T) *httptest.Server {
    mux := http.NewServeMux()

    mux.HandleFunc("/organizations", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodPost:
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusCreated)
            json.NewEncoder(w).Encode(map[string]interface{}{
                "id":         "org_01H...",
                "name":       "Test Org",
                "object":     "organization",
                "created_at": "2026-01-31T00:00:00Z",
                "updated_at": "2026-01-31T00:00:00Z",
            })
        }
    })

    return httptest.NewServer(mux)
}

Next Steps

  1. Review and approve this plan
  2. Set up repository structure (Phase 0)
  3. Obtain WorkOS staging API credentials for acceptance testing
  4. Begin Priority 1 resource development (Phase 1)
  5. Set up CI/CD pipeline (parallel with Phase 1)

Document End