Version: 1.0 Date: January 31, 2026 Status: Ready for Implementation Target: Terraform Registry Publication
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
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
// 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
)| 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 |
// 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
}| 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 |
Duration: 3-5 days Critical Path: Yes (all phases depend on this)
- 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
| 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 |
-
go buildsucceeds -
terraform initworks with local dev override - Provider accepts configuration
- Provider returns meaningful error on invalid API key
- CI workflow passes
- Makefile targets work:
build,lint,test
// 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",
},
},
}
}Duration: 5-7 days Critical Path: Yes (most resources depend on Organization) Dependencies: Phase 0 complete
- Implement
workos_organizationresource with full CRUD - Implement
workos_organizationdata source - Establish resource implementation patterns for all subsequent resources
- Set up acceptance testing infrastructure
- Generate initial documentation
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..."
}| 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... |
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- 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
Duration: 7-10 days Critical Path: Yes (core enterprise feature) Dependencies: Phase 1 complete
- Implement
workos_connectionresource with type-specific blocks - Handle SAML, OAuth, and OIDC connection types
- Manage sensitive certificate data correctly
- Implement
workos_connectiondata source
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"
}| 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 |
- Exactly one type-specific block must be provided
connection_typemust match the provided block- Certificate must be valid PEM format
- 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_idandconnection_typechanges
Duration: 5-7 days Critical Path: Yes (core enterprise feature) Dependencies: Phase 1 complete
- Implement
workos_directoryresource - Handle SCIM endpoint and bearer token correctly
- Implement
workos_directorydata source - Implement
workos_directory_userdata source - Implement
workos_directory_groupdata source
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/..."
}azure scim v2.0okta scim v2.0generic scim v2.0google workspaceworkdayrippling
- 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
Duration: 3-5 days Critical Path: No (independent feature, can parallelize) Dependencies: Phase 0 complete
- Implement
workos_webhookresource - Handle event type validation
- Manage webhook secret correctly
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",
]
}- Validate against known WorkOS event types
- Warn (don't error) on unrecognized events for forward compatibility
- Webhook can be created with HTTPS URL
- Events can be updated
- Secret is marked sensitive
- Event validation works
- Import works correctly
Duration: 7-10 days Critical Path: Yes (required for MVP) Dependencies: Phase 1 complete
- Implement
workos_userresource - Handle write-only password field correctly
- Implement
workos_organization_membershipresource - Implement
workos_userdata source
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..."
}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
}// 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
}
}
}- 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
Duration: 5-7 days Critical Path: Yes (required for Registry) Dependencies: Phases 1-5 complete
- Complete all documentation using terraform-plugin-docs
- Create comprehensive example configurations
- Polish error messages
- Prepare CHANGELOG.md
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
examples/
├── provider/
│ └── provider.tf
├── resources/
│ ├── workos_organization/
│ │ └── resource.tf
│ ├── workos_connection/
│ │ └── resource.tf
│ └── ...
├── data-sources/
│ └── ...
└── complete-sso/
├── main.tf
├── variables.tf
└── outputs.tf
# Install
go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest
# Generate documentation
tfplugindocs generate
# Validate
tfplugindocs validate-
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
Duration: 3-5 days Critical Path: Yes (final gate) Dependencies: All previous phases complete
- Complete release automation with GoReleaser
- Set up GPG signing
- Publish to Terraform Registry
- Perform production validation
| 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 |
☐ |
{
"version": 1,
"metadata": {
"protocol_versions": ["6.0"]
}
}# .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# 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- Sign into registry.terraform.io with GitHub account
- Authorize Terraform Registry GitHub app
- Add GPG public key to Registry signing keys
- Click "Publish" → "Provider"
- Select
oso-sh/terraform-provider-workos - Registry validates and publishes
| 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 |
- 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
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 | 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 |
- 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)
- 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
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)
}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)
}- Review and approve this plan
- Set up repository structure (Phase 0)
- Obtain WorkOS staging API credentials for acceptance testing
- Begin Priority 1 resource development (Phase 1)
- Set up CI/CD pipeline (parallel with Phase 1)
Document End