From a2b8ebfbd6acf32679ed6e75fdf4d01d631c6079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erwan=20Queff=C3=A9lec?= Date: Wed, 28 Oct 2026 01:25:03 +0100 Subject: [PATCH] feat: add github_organization_private_registry resource and data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets Terraform manage organization private registries (for example an npm or Maven registry that Dependabot uses) and read them back through a data source. Builds on https://github.com/integrations/terraform-provider-github/pull/3424, then follows provider conventions and addresses its review: no Read after Create or Update, auth_type forces a new resource, registry_type is not sent on update, and out-of-band secret changes are detected and repaired. Co-authored-by: Lasse Gaardsholt Signed-off-by: Erwan Queffélec --- .../organization_private_registry.md | 48 ++ .../organization_private_registry.md | 111 ++++ .../data-source.tf | 3 + .../import-by-string-id.tf | 4 + .../import.sh | 1 + .../resource_1.tf | 8 + .../resource_2.tf | 14 + .../resource_3.tf | 12 + ...ce_github_organization_private_registry.go | 145 +++++ ...thub_organization_private_registry_test.go | 49 ++ github/provider.go | 2 + ...ce_github_organization_private_registry.go | 535 ++++++++++++++++++ ...thub_organization_private_registry_test.go | 174 ++++++ 13 files changed, 1106 insertions(+) create mode 100644 docs/data-sources/organization_private_registry.md create mode 100644 docs/resources/organization_private_registry.md create mode 100644 examples/data-sources/github_organization_private_registry/data-source.tf create mode 100644 examples/resources/github_organization_private_registry/import-by-string-id.tf create mode 100644 examples/resources/github_organization_private_registry/import.sh create mode 100644 examples/resources/github_organization_private_registry/resource_1.tf create mode 100644 examples/resources/github_organization_private_registry/resource_2.tf create mode 100644 examples/resources/github_organization_private_registry/resource_3.tf create mode 100644 github/data_source_github_organization_private_registry.go create mode 100644 github/data_source_github_organization_private_registry_test.go create mode 100644 github/resource_github_organization_private_registry.go create mode 100644 github/resource_github_organization_private_registry_test.go diff --git a/docs/data-sources/organization_private_registry.md b/docs/data-sources/organization_private_registry.md new file mode 100644 index 0000000000..5ba4ea5afc --- /dev/null +++ b/docs/data-sources/organization_private_registry.md @@ -0,0 +1,48 @@ +--- +page_title: "github_organization_private_registry (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve information about a specific organization private registry. +--- + +# github_organization_private_registry (Data Source) + +Use this data source to retrieve information about a specific organization private registry. + +## Example Usage + +```terraform +data "github_organization_private_registry" "example" { + name = "NPM_REGISTRY_1" +} +``` + + +## Schema + +### Required + +- `name` (String) The auto-generated name of the private registry (computed by GitHub). + +### Read-Only + +- `auth_type` (String) The authentication type for the private registry. +- `created_at` (String) The time the registry was created. +- `id` (String) The ID of this resource. +- `oidc_audience` (String) The JWT audience. +- `oidc_aws_account_id` (String) The AWS account ID. +- `oidc_aws_domain` (String) The AWS domain. +- `oidc_aws_domain_owner` (String) The AWS domain owner. +- `oidc_aws_region` (String) The AWS region. +- `oidc_aws_role_name` (String) The AWS role name. +- `oidc_azure_client_id` (String) The Azure client ID. +- `oidc_azure_tenant_id` (String) The Azure tenant ID. +- `oidc_jfrog_identity_mapping_name` (String) The JFrog identity mapping name. +- `oidc_jfrog_provider_name` (String) The JFrog provider name. +- `registry_type` (String) The registry type. +- `replaces_base` (Boolean) Whether the private registry should replace the public base registry. +- `selected_repository_ids` (Set of Number) An array of repository IDs that can access the organization private registry. +- `updated_at` (String) The time the registry was updated. +- `url` (String) The registry URL. +- `username` (String) The registry username. +- `visibility` (String) Configures the access that repositories have to the organization private registry. diff --git a/docs/resources/organization_private_registry.md b/docs/resources/organization_private_registry.md new file mode 100644 index 0000000000..33edabf1ed --- /dev/null +++ b/docs/resources/organization_private_registry.md @@ -0,0 +1,111 @@ +--- +page_title: "github_organization_private_registry (Resource) - GitHub" +subcategory: "" +description: |- + This resource allows you to create and manage an organization private registry for Dependabot. +--- + +# github_organization_private_registry (Resource) + +This resource allows you to create and manage an organization private registry for Dependabot. + +## Example Usage + +```terraform +# Token-authenticated npm registry, available to every repository of the organization. +resource "github_organization_private_registry" "example" { + registry_type = "npm_registry" + url = "https://npm.pkg.github.com" + auth_type = "token" + value = var.npm_token + visibility = "all" +} +``` + +```terraform +# Username and password registry, restricted to selected repositories. +resource "github_repository" "example" { + name = "example" +} + +resource "github_organization_private_registry" "example" { + registry_type = "maven_repository" + url = "https://maven.example.com/releases" + auth_type = "username_password" + username = "deploy" + value = var.maven_password + visibility = "selected" + selected_repository_ids = [github_repository.example.repo_id] +} +``` + +```terraform +# AWS CodeArtifact registry authenticated through OIDC, no stored secret. +resource "github_organization_private_registry" "example" { + registry_type = "maven_repository" + url = "https://example-111122223333.d.codeartifact.eu-west-3.amazonaws.com/maven/releases/" + auth_type = "oidc_aws" + oidc_aws_region = "eu-west-3" + oidc_aws_account_id = "111122223333" + oidc_aws_role_name = "dependabot-codeartifact" + oidc_aws_domain = "example" + oidc_aws_domain_owner = "111122223333" + visibility = "private" +} +``` + + +## Schema + +### Required + +- `registry_type` (String) The registry type. Must be one of `maven_repository`, `nuget_feed`, `goproxy_server`, `npm_registry`, `rubygems_server`, `cargo_registry`, `composer_repository`, `docker_registry`, `git_source`, `helm_registry`, `hex_organization`, `hex_repository`, `pub_repository`, `python_index`, or `terraform_registry`. +- `url` (String) The URL of the private registry. +- `visibility` (String) Configures the access that repositories have to the organization private registry. Must be one of `all`, `private`, or `selected`. + +### Optional + +- `auth_type` (String) The authentication type for the private registry. Can be `token`, `username_password`, `oidc_azure`, `oidc_aws`, or `oidc_jfrog`. Defaults to `token`. Cannot be changed after creation. +- `key_id` (String) ID of the public key used to encrypt the secret. Required if `value_encrypted` is set. +- `oidc_audience` (String) The OIDC audience. +- `oidc_aws_account_id` (String) The AWS account ID. Required when `auth_type` is `oidc_aws`. +- `oidc_aws_domain` (String) The CodeArtifact domain. Required when `auth_type` is `oidc_aws`. +- `oidc_aws_domain_owner` (String) The CodeArtifact domain owner. Required when `auth_type` is `oidc_aws`. +- `oidc_aws_region` (String) The AWS region. Required when `auth_type` is `oidc_aws`. +- `oidc_aws_role_name` (String) The AWS IAM role name. Required when `auth_type` is `oidc_aws`. +- `oidc_azure_client_id` (String) The client ID of the Azure AD application. Required when `auth_type` is `oidc_azure`. +- `oidc_azure_tenant_id` (String) The tenant ID of the Azure AD application. Required when `auth_type` is `oidc_azure`. +- `oidc_jfrog_identity_mapping_name` (String) The JFrog identity mapping name. +- `oidc_jfrog_provider_name` (String) The JFrog OIDC provider name. Required when `auth_type` is `oidc_jfrog`. +- `replaces_base` (Boolean) Indicates whether this private registry should replace the base registry. +- `selected_repository_ids` (Set of Number) An array of repository IDs that can access the organization private registry. Only valid when `visibility` is `selected`. GitHub does not return this list when reading a registry, so it is kept from the configuration and cannot be recovered by `terraform import`. +- `username` (String) The username to use when authenticating with the private registry. +- `value` (String, Sensitive) The plaintext secret to be encrypted and sent to GitHub. This is used for a token when `auth_type` is `token`, and for a password when `auth_type` is `username_password`. One of `value` or `value_encrypted` is required for those auth types. +- `value_encrypted` (String, Sensitive) The secret encrypted with the organization private registries public key, in Base64 format. One of `value` or `value_encrypted` is required when `auth_type` is `token` or `username_password`. + +### Read-Only + +- `created_at` (String) The timestamp when the private registry was created. +- `id` (String) The ID of this resource. +- `name` (String) The auto-generated name of the private registry (computed by GitHub). +- `remote_updated_at` (String) The timestamp when the private registry was last updated in GitHub. A difference with `updated_at` means the secret was changed outside of Terraform. +- `updated_at` (String) The timestamp when the private registry was last updated by this resource. + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +```terraform +import { + to = github_organization_private_registry.example + id = "registry-name" +} +``` + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import github_organization_private_registry.example registry-name +``` diff --git a/examples/data-sources/github_organization_private_registry/data-source.tf b/examples/data-sources/github_organization_private_registry/data-source.tf new file mode 100644 index 0000000000..1cb0b0da0f --- /dev/null +++ b/examples/data-sources/github_organization_private_registry/data-source.tf @@ -0,0 +1,3 @@ +data "github_organization_private_registry" "example" { + name = "NPM_REGISTRY_1" +} diff --git a/examples/resources/github_organization_private_registry/import-by-string-id.tf b/examples/resources/github_organization_private_registry/import-by-string-id.tf new file mode 100644 index 0000000000..1abae589e4 --- /dev/null +++ b/examples/resources/github_organization_private_registry/import-by-string-id.tf @@ -0,0 +1,4 @@ +import { + to = github_organization_private_registry.example + id = "registry-name" +} diff --git a/examples/resources/github_organization_private_registry/import.sh b/examples/resources/github_organization_private_registry/import.sh new file mode 100644 index 0000000000..c9e993175d --- /dev/null +++ b/examples/resources/github_organization_private_registry/import.sh @@ -0,0 +1 @@ +terraform import github_organization_private_registry.example registry-name diff --git a/examples/resources/github_organization_private_registry/resource_1.tf b/examples/resources/github_organization_private_registry/resource_1.tf new file mode 100644 index 0000000000..0b79042d14 --- /dev/null +++ b/examples/resources/github_organization_private_registry/resource_1.tf @@ -0,0 +1,8 @@ +# Token-authenticated npm registry, available to every repository of the organization. +resource "github_organization_private_registry" "example" { + registry_type = "npm_registry" + url = "https://npm.pkg.github.com" + auth_type = "token" + value = var.npm_token + visibility = "all" +} diff --git a/examples/resources/github_organization_private_registry/resource_2.tf b/examples/resources/github_organization_private_registry/resource_2.tf new file mode 100644 index 0000000000..bd51010b52 --- /dev/null +++ b/examples/resources/github_organization_private_registry/resource_2.tf @@ -0,0 +1,14 @@ +# Username and password registry, restricted to selected repositories. +resource "github_repository" "example" { + name = "example" +} + +resource "github_organization_private_registry" "example" { + registry_type = "maven_repository" + url = "https://maven.example.com/releases" + auth_type = "username_password" + username = "deploy" + value = var.maven_password + visibility = "selected" + selected_repository_ids = [github_repository.example.repo_id] +} diff --git a/examples/resources/github_organization_private_registry/resource_3.tf b/examples/resources/github_organization_private_registry/resource_3.tf new file mode 100644 index 0000000000..f9cc778ac4 --- /dev/null +++ b/examples/resources/github_organization_private_registry/resource_3.tf @@ -0,0 +1,12 @@ +# AWS CodeArtifact registry authenticated through OIDC, no stored secret. +resource "github_organization_private_registry" "example" { + registry_type = "maven_repository" + url = "https://example-111122223333.d.codeartifact.eu-west-3.amazonaws.com/maven/releases/" + auth_type = "oidc_aws" + oidc_aws_region = "eu-west-3" + oidc_aws_account_id = "111122223333" + oidc_aws_role_name = "dependabot-codeartifact" + oidc_aws_domain = "example" + oidc_aws_domain_owner = "111122223333" + visibility = "private" +} diff --git a/github/data_source_github_organization_private_registry.go b/github/data_source_github_organization_private_registry.go new file mode 100644 index 0000000000..3b02a8f341 --- /dev/null +++ b/github/data_source_github_organization_private_registry.go @@ -0,0 +1,145 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/integrations/terraform-provider-github/v6/internal/tfpluginv2util" +) + +func dataSourceGithubOrganizationPrivateRegistry() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubOrganizationPrivateRegistryRead, + Description: "Use this data source to retrieve information about a specific organization private registry.", + Schema: map[string]*schema.Schema{ + "name": { + Description: "The auto-generated name of the private registry (computed by GitHub).", + Type: schema.TypeString, + Required: true, + }, + "registry_type": { + Description: "The registry type.", + Type: schema.TypeString, + Computed: true, + }, + "url": { + Description: "The registry URL.", + Type: schema.TypeString, + Computed: true, + }, + "username": { + Description: "The registry username.", + Type: schema.TypeString, + Computed: true, + }, + "replaces_base": { + Description: "Whether the private registry should replace the public base registry.", + Type: schema.TypeBool, + Computed: true, + }, + "visibility": { + Description: "Configures the access that repositories have to the organization private registry.", + Type: schema.TypeString, + Computed: true, + }, + "auth_type": { + Description: "The authentication type for the private registry.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_azure_tenant_id": { + Description: "The Azure tenant ID.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_azure_client_id": { + Description: "The Azure client ID.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_aws_region": { + Description: "The AWS region.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_aws_account_id": { + Description: "The AWS account ID.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_aws_role_name": { + Description: "The AWS role name.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_aws_domain": { + Description: "The AWS domain.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_aws_domain_owner": { + Description: "The AWS domain owner.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_jfrog_provider_name": { + Description: "The JFrog provider name.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_audience": { + Description: "The JWT audience.", + Type: schema.TypeString, + Computed: true, + }, + "oidc_jfrog_identity_mapping_name": { + Description: "The JFrog identity mapping name.", + Type: schema.TypeString, + Computed: true, + }, + "selected_repository_ids": { + Description: "An array of repository IDs that can access the organization private registry.", + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + }, + "created_at": { + Description: "The time the registry was created.", + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Description: "The time the registry was updated.", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubOrganizationPrivateRegistryRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + registryName := tfpluginv2util.Get[string](d, "name") + + registry, _, err := client.PrivateRegistries.GetOrganizationPrivateRegistry(ctx, owner, registryName) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(registry.GetName()) + + if err := setPrivateRegistryAttributes(d, registry); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_organization_private_registry_test.go b/github/data_source_github_organization_private_registry_test.go new file mode 100644 index 0000000000..e7e56c50e9 --- /dev/null +++ b/github/data_source_github_organization_private_registry_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubDataSourceOrganizationPrivateRegistry(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` +resource "github_organization_private_registry" "test" { + registry_type = "npm_registry" + url = "https://npm-%s.example.com" + username = "github-actions" + value = "super_secret_token_123" + visibility = "private" +} + +data "github_organization_private_registry" "test" { + name = github_organization_private_registry.test.name +} +`, acctest.RandString(testRandomIDLength)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessMode(t, organization) + skipUnlessHasOrgs(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_organization_private_registry.test", tfjsonpath.New("id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("data.github_organization_private_registry.test", tfjsonpath.New("registry_type"), knownvalue.StringExact("npm_registry")), + statecheck.ExpectKnownValue("data.github_organization_private_registry.test", tfjsonpath.New("username"), knownvalue.StringExact("github-actions")), + statecheck.ExpectKnownValue("data.github_organization_private_registry.test", tfjsonpath.New("visibility"), knownvalue.StringExact("private")), + }, + }, + }, + }) +} diff --git a/github/provider.go b/github/provider.go index c391d8454c..89c644d0c0 100644 --- a/github/provider.go +++ b/github/provider.go @@ -212,6 +212,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_organization_block": resourceOrganizationBlock(), "github_organization_custom_role": resourceGithubOrganizationCustomRole(), "github_organization_custom_properties": resourceGithubOrganizationCustomProperties(), + "github_organization_private_registry": resourceGithubOrganizationPrivateRegistry(), "github_organization_project": resourceGithubOrganizationProject(), "github_organization_repository_role": resourceGithubOrganizationRepositoryRole(), "github_organization_role": resourceGithubOrganizationRole(), @@ -300,6 +301,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_organization_custom_properties": dataSourceGithubOrganizationCustomProperties(), "github_organization_external_identities": dataSourceGithubOrganizationExternalIdentities(), "github_organization_ip_allow_list": dataSourceGithubOrganizationIpAllowList(), + "github_organization_private_registry": dataSourceGithubOrganizationPrivateRegistry(), "github_organization_members": dataSourceGithubOrganizationMembers(), "github_organization_repositories": dataSourceGithubOrganizationRepositories(), "github_organization_repository_role": dataSourceGithubOrganizationRepositoryRole(), diff --git a/github/resource_github_organization_private_registry.go b/github/resource_github_organization_private_registry.go new file mode 100644 index 0000000000..b162e2e090 --- /dev/null +++ b/github/resource_github_organization_private_registry.go @@ -0,0 +1,535 @@ +package github + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v92/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + + "github.com/integrations/terraform-provider-github/v6/internal/tfpluginv2util" +) + +func resourceGithubOrganizationPrivateRegistry() *schema.Resource { + return &schema.Resource{ + Description: "This resource allows you to create and manage an organization private registry for Dependabot.", + CreateContext: resourceGithubOrganizationPrivateRegistryCreate, + ReadContext: resourceGithubOrganizationPrivateRegistryRead, + UpdateContext: resourceGithubOrganizationPrivateRegistryUpdate, + DeleteContext: resourceGithubOrganizationPrivateRegistryDelete, + CustomizeDiff: customdiff.All(resourceGithubOrganizationPrivateRegistryDiff, diffPrivateRegistrySecret, diffSecretVariableVisibility), + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The auto-generated name of the private registry (computed by GitHub).", + }, + "registry_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"maven_repository", "nuget_feed", "goproxy_server", "npm_registry", "rubygems_server", "cargo_registry", "composer_repository", "docker_registry", "git_source", "helm_registry", "hex_organization", "hex_repository", "pub_repository", "python_index", "terraform_registry"}, false)), + Description: "The registry type. Must be one of `maven_repository`, `nuget_feed`, `goproxy_server`, `npm_registry`, `rubygems_server`, `cargo_registry`, `composer_repository`, `docker_registry`, `git_source`, `helm_registry`, `hex_organization`, `hex_repository`, `pub_repository`, `python_index`, or `terraform_registry`.", + }, + "url": { + Type: schema.TypeString, + Required: true, + Description: "The URL of the private registry.", + }, + "username": { + Type: schema.TypeString, + Optional: true, + Description: "The username to use when authenticating with the private registry.", + }, + "replaces_base": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Indicates whether this private registry should replace the base registry.", + }, + "value": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"value_encrypted"}, + Description: "The plaintext secret to be encrypted and sent to GitHub. This is used for a token when `auth_type` is `token`, and for a password when `auth_type` is `username_password`. One of `value` or `value_encrypted` is required for those auth types.", + }, + "value_encrypted": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + ConflictsWith: []string{"value"}, + RequiredWith: []string{"key_id"}, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsBase64), + Description: "The secret encrypted with the organization private registries public key, in Base64 format. One of `value` or `value_encrypted` is required when `auth_type` is `token` or `username_password`.", + }, + "key_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + RequiredWith: []string{"value_encrypted"}, + ConflictsWith: []string{"value"}, + Description: "ID of the public key used to encrypt the secret. Required if `value_encrypted` is set.", + }, + "visibility": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "private", "selected"}, false)), + Description: "Configures the access that repositories have to the organization private registry. Must be one of `all`, `private`, or `selected`.", + }, + "selected_repository_ids": { + Type: schema.TypeSet, + Optional: true, + Set: schema.HashInt, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Description: "An array of repository IDs that can access the organization private registry. Only valid when `visibility` is `selected`. GitHub does not return this list when reading a registry, so it is kept from the configuration and cannot be recovered by `terraform import`.", + }, + "auth_type": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "token", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"token", "username_password", "oidc_azure", "oidc_aws", "oidc_jfrog"}, false)), + Description: "The authentication type for the private registry. Can be `token`, `username_password`, `oidc_azure`, `oidc_aws`, or `oidc_jfrog`. Defaults to `token`. Cannot be changed after creation.", + }, + "oidc_azure_tenant_id": { + Type: schema.TypeString, + Optional: true, + Description: "The tenant ID of the Azure AD application. Required when `auth_type` is `oidc_azure`.", + }, + "oidc_azure_client_id": { + Type: schema.TypeString, + Optional: true, + Description: "The client ID of the Azure AD application. Required when `auth_type` is `oidc_azure`.", + }, + "oidc_aws_region": { + Type: schema.TypeString, + Optional: true, + Description: "The AWS region. Required when `auth_type` is `oidc_aws`.", + }, + "oidc_aws_account_id": { + Type: schema.TypeString, + Optional: true, + Description: "The AWS account ID. Required when `auth_type` is `oidc_aws`.", + }, + "oidc_aws_role_name": { + Type: schema.TypeString, + Optional: true, + Description: "The AWS IAM role name. Required when `auth_type` is `oidc_aws`.", + }, + "oidc_aws_domain": { + Type: schema.TypeString, + Optional: true, + Description: "The CodeArtifact domain. Required when `auth_type` is `oidc_aws`.", + }, + "oidc_aws_domain_owner": { + Type: schema.TypeString, + Optional: true, + Description: "The CodeArtifact domain owner. Required when `auth_type` is `oidc_aws`.", + }, + "oidc_jfrog_provider_name": { + Type: schema.TypeString, + Optional: true, + Description: "The JFrog OIDC provider name. Required when `auth_type` is `oidc_jfrog`.", + }, + "oidc_audience": { + Type: schema.TypeString, + Optional: true, + Description: "The OIDC audience.", + }, + "oidc_jfrog_identity_mapping_name": { + Type: schema.TypeString, + Optional: true, + Description: "The JFrog identity mapping name.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "The timestamp when the private registry was created.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "The timestamp when the private registry was last updated by this resource.", + }, + "remote_updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "The timestamp when the private registry was last updated in GitHub. A difference with `updated_at` means the secret was changed outside of Terraform.", + }, + }, + } +} + +func registryAuthTypeUsesSecret(authType string) bool { + return authType == "token" || authType == "username_password" +} + +// resourceGithubOrganizationPrivateRegistryDiff enforces that token and username_password registries carry a secret, +// and that OIDC registries do not, since the API silently ignores a secret sent for them. +func resourceGithubOrganizationPrivateRegistryDiff(_ context.Context, d *schema.ResourceDiff, _ any) error { + if _, ok := d.GetOk("selected_repository_ids"); ok && tfpluginv2util.Get[string](d, "visibility") != "selected" { + return fmt.Errorf("cannot use selected_repository_ids without visibility being set to selected") + } + + authType := tfpluginv2util.Get[string](d, "auth_type") + // A value that is unknown at plan time (for example from random_password) counts as present. + _, hasValue := d.GetOk("value") + hasValue = hasValue || !d.NewValueKnown("value") + _, hasEncrypted := d.GetOk("value_encrypted") + hasEncrypted = hasEncrypted || !d.NewValueKnown("value_encrypted") + + if registryAuthTypeUsesSecret(authType) { + if !hasValue && !hasEncrypted { + return fmt.Errorf("one of `value,value_encrypted` must be specified when auth_type is %q", authType) + } + return nil + } + + // key_id is Optional+Computed, so it carries a stale value across an auth_type replacement and is not checked here. + if hasValue || hasEncrypted { + return fmt.Errorf("`value` and `value_encrypted` cannot be set when auth_type is %q", authType) + } + return nil +} + +// diffPrivateRegistrySecret runs the shared secret drift detection only for auth types that store a secret. +func diffPrivateRegistrySecret(ctx context.Context, d *schema.ResourceDiff, m any) error { + if !registryAuthTypeUsesSecret(tfpluginv2util.Get[string](d, "auth_type")) { + return nil + } + return diffSecret(ctx, d, m) +} + +// registryEncryptedSecret returns the encrypted secret and key ID to send to GitHub, +// encrypting the plaintext value with the organization public key when no encrypted value is configured. +func registryEncryptedSecret(ctx context.Context, client *github.Client, owner string, d *schema.ResourceData) (string, string, error) { + encryptedValue := tfpluginv2util.Get[string](d, "value_encrypted") + keyID := tfpluginv2util.Get[string](d, "key_id") + + if keyID != "" && encryptedValue != "" { + return encryptedValue, keyID, nil + } + + publicKey, _, err := client.PrivateRegistries.GetOrganizationPrivateRegistriesPublicKey(ctx, owner) + if err != nil { + return "", "", err + } + keyID = publicKey.GetKeyID() + + if encryptedValue == "" { + encryptedBytes, err := encryptPlaintext(tfpluginv2util.Get[string](d, "value"), publicKey.GetKey()) + if err != nil { + return "", "", err + } + encryptedValue = base64.StdEncoding.EncodeToString(encryptedBytes) + } + + return encryptedValue, keyID, nil +} + +func registrySelectedRepositoryIDs(d *schema.ResourceData) []int64 { + var ids []int64 + for _, id := range tfpluginv2util.GetSet[int](d, "selected_repository_ids", true) { + ids = append(ids, int64(id)) + } + return ids +} + +// optString returns nil for an unset attribute so omitempty drops it from the request body. +func optString(d *schema.ResourceData, key string) *string { + if v, ok := tfpluginv2util.GetOk[string](d, key); ok { + return new(v) + } + return nil +} + +// privateRegistryWriteRequest holds the desired state shared by the create and update request bodies. +type privateRegistryWriteRequest struct { + url string + username *string + replacesBase bool + visibility github.PrivateRegistryVisibility + selectedRepositoryIDs []int64 + encryptedValue *string + keyID *string + tenantID *string + clientID *string + awsRegion *string + accountID *string + roleName *string + domain *string + domainOwner *string + jfrogOIDCProviderName *string + audience *string + identityMappingName *string +} + +// privateRegistryDesiredState maps the full configuration to the request body so Create and Update send the same shape. +// The secret is included only when sendSecret is set; the returned key ID is written to state once the API call succeeds. +func privateRegistryDesiredState(ctx context.Context, client *github.Client, owner string, d *schema.ResourceData, sendSecret bool) (*privateRegistryWriteRequest, string, error) { + req := &privateRegistryWriteRequest{ + url: tfpluginv2util.Get[string](d, "url"), + username: optString(d, "username"), + replacesBase: tfpluginv2util.Get[bool](d, "replaces_base"), + visibility: github.PrivateRegistryVisibility(tfpluginv2util.Get[string](d, "visibility")), + selectedRepositoryIDs: registrySelectedRepositoryIDs(d), + tenantID: optString(d, "oidc_azure_tenant_id"), + clientID: optString(d, "oidc_azure_client_id"), + awsRegion: optString(d, "oidc_aws_region"), + accountID: optString(d, "oidc_aws_account_id"), + roleName: optString(d, "oidc_aws_role_name"), + domain: optString(d, "oidc_aws_domain"), + domainOwner: optString(d, "oidc_aws_domain_owner"), + jfrogOIDCProviderName: optString(d, "oidc_jfrog_provider_name"), + audience: optString(d, "oidc_audience"), + identityMappingName: optString(d, "oidc_jfrog_identity_mapping_name"), + } + + if !sendSecret || !registryAuthTypeUsesSecret(tfpluginv2util.Get[string](d, "auth_type")) { + return req, "", nil + } + + encryptedValue, keyID, err := registryEncryptedSecret(ctx, client, owner, d) + if err != nil { + return nil, "", err + } + req.encryptedValue = new(encryptedValue) + req.keyID = new(keyID) + + return req, keyID, nil +} + +func setPrivateRegistryAttributes(d *schema.ResourceData, registry *github.PrivateRegistry) error { + fields := map[string]any{ + "name": registry.GetName(), + "url": registry.GetURL(), + "created_at": registry.GetCreatedAt().String(), + } + // Optional fields are only written when the API returned them, so a fresh import matches a fresh create. + optional := map[string]*string{ + "username": registry.Username, + "oidc_azure_tenant_id": registry.TenantID, + "oidc_azure_client_id": registry.ClientID, + "oidc_aws_region": registry.AWSRegion, + "oidc_aws_account_id": registry.AccountID, + "oidc_aws_role_name": registry.RoleName, + "oidc_aws_domain": registry.Domain, + "oidc_aws_domain_owner": registry.DomainOwner, + "oidc_jfrog_provider_name": registry.JFrogOIDCProviderName, + "oidc_audience": registry.Audience, + "oidc_jfrog_identity_mapping_name": registry.IdentityMappingName, + } + for key, value := range optional { + if value != nil { + fields[key] = *value + } + } + if registry.ReplacesBase != nil { + fields["replaces_base"] = registry.GetReplacesBase() + } + // The GET endpoint omits selected_repository_ids, so the configured list stays in state unless the API returned one. + if registry.SelectedRepositoryIDs != nil { + fields["selected_repository_ids"] = registry.SelectedRepositoryIDs + } + if registry.RegistryType != nil { + fields["registry_type"] = string(*registry.RegistryType) + } + if registry.Visibility != nil { + fields["visibility"] = string(*registry.Visibility) + } + if registry.AuthType != nil { + fields["auth_type"] = string(*registry.AuthType) + } + + for key, value := range fields { + if err := d.Set(key, value); err != nil { + return err + } + } + return nil +} + +func resourceGithubOrganizationPrivateRegistryCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + desired, keyID, err := privateRegistryDesiredState(ctx, client, owner, d, true) + if err != nil { + return diag.FromErr(err) + } + + authType := tfpluginv2util.Get[string](d, "auth_type") + payload := github.CreateOrganizationPrivateRegistry{ + RegistryType: github.PrivateRegistryType(tfpluginv2util.Get[string](d, "registry_type")), + AuthType: new(authType), + URL: desired.url, + Username: desired.username, + ReplacesBase: new(desired.replacesBase), + Visibility: desired.visibility, + SelectedRepositoryIDs: desired.selectedRepositoryIDs, + EncryptedValue: desired.encryptedValue, + KeyID: desired.keyID, + TenantID: desired.tenantID, + ClientID: desired.clientID, + AWSRegion: desired.awsRegion, + AccountID: desired.accountID, + RoleName: desired.roleName, + Domain: desired.domain, + DomainOwner: desired.domainOwner, + JFrogOIDCProviderName: desired.jfrogOIDCProviderName, + Audience: desired.audience, + IdentityMappingName: desired.identityMappingName, + } + + registry, _, err := client.PrivateRegistries.CreateOrganizationPrivateRegistry(ctx, owner, payload) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(registry.GetName()) + + if err := d.Set("name", registry.GetName()); err != nil { + return diag.FromErr(err) + } + if keyID != "" { + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + } + if err := d.Set("created_at", registry.GetCreatedAt().String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationPrivateRegistryRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + registry, _, err := client.PrivateRegistries.GetOrganizationPrivateRegistry(ctx, owner, d.Id()) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing organization private registry from state because it no longer exists in GitHub", map[string]any{"name": d.Id()}) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := setPrivateRegistryAttributes(d, registry); err != nil { + return diag.FromErr(err) + } + + // updated_at tracks the last write made by this resource and remote_updated_at tracks GitHub, + // so diffSecret can detect a registry changed outside of Terraform. + if tfpluginv2util.Get[string](d, "updated_at") == "" { + if err := d.Set("updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + } + if err := d.Set("remote_updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationPrivateRegistryUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + // updated_at changes when diffSecret detected an out-of-band change, so the secret is re-sent to restore it. + sendSecret := d.HasChanges("value", "value_encrypted", "key_id", "updated_at") + desired, keyID, err := privateRegistryDesiredState(ctx, client, owner, d, sendSecret) + if err != nil { + return diag.FromErr(err) + } + + payload := github.UpdateOrganizationPrivateRegistry{ + URL: new(desired.url), + Username: desired.username, + ReplacesBase: new(desired.replacesBase), + Visibility: new(desired.visibility), + SelectedRepositoryIDs: desired.selectedRepositoryIDs, + EncryptedValue: desired.encryptedValue, + KeyID: desired.keyID, + TenantID: desired.tenantID, + ClientID: desired.clientID, + AWSRegion: desired.awsRegion, + AccountID: desired.accountID, + RoleName: desired.roleName, + Domain: desired.domain, + DomainOwner: desired.domainOwner, + JFrogOIDCProviderName: desired.jfrogOIDCProviderName, + Audience: desired.audience, + IdentityMappingName: desired.identityMappingName, + } + + // omitempty drops a nil username, so a removed username is sent as an empty string to clear it. + if d.HasChange("username") && payload.Username == nil { + payload.Username = new("") + } + + if _, err := client.PrivateRegistries.UpdateOrganizationPrivateRegistry(ctx, owner, d.Id(), payload); err != nil { + return diag.FromErr(err) + } + + if keyID != "" { + if err := d.Set("key_id", keyID); err != nil { + return diag.FromErr(err) + } + } + + // The update endpoint returns no body, so the registry is fetched once to record the new timestamp. + registry, _, err := client.PrivateRegistries.GetOrganizationPrivateRegistry(ctx, owner, d.Id()) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("remote_updated_at", registry.GetUpdatedAt().String()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationPrivateRegistryDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + _, err := client.PrivateRegistries.DeleteOrganizationPrivateRegistry(ctx, owner, d.Id()) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + return nil +} diff --git a/github/resource_github_organization_private_registry_test.go b/github/resource_github_organization_private_registry_test.go new file mode 100644 index 0000000000..7b36210c20 --- /dev/null +++ b/github/resource_github_organization_private_registry_test.go @@ -0,0 +1,174 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubOrganizationPrivateRegistry(t *testing.T) { + t.Parallel() + + skipUnlessMode(t, organization) + skipUnlessHasOrgs(t) + + t.Run("creates_imports_and_updates", func(t *testing.T) { + t.Parallel() + + host := acctest.RandString(testRandomIDLength) + + configTmpl := ` +resource "github_organization_private_registry" "test" { + registry_type = "npm_registry" + url = "%s" + auth_type = "username_password" + username = "github-actions" + value = "super_secret_token_123" + visibility = "%s" +} +` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(configTmpl, fmt.Sprintf("https://npm-%s.example.com", host), "private"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("name"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("key_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("created_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("updated_at"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("registry_type"), knownvalue.StringExact("npm_registry")), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("url"), knownvalue.StringExact(fmt.Sprintf("https://npm-%s.example.com", host))), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("username"), knownvalue.StringExact("github-actions")), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("visibility"), knownvalue.StringExact("private")), + }, + }, + { + ResourceName: "github_organization_private_registry.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"value", "key_id"}, + }, + { + Config: fmt.Sprintf(configTmpl, fmt.Sprintf("https://npm-%s-updated.example.com", host), "all"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_organization_private_registry.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("url"), knownvalue.StringExact(fmt.Sprintf("https://npm-%s-updated.example.com", host))), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("visibility"), knownvalue.StringExact("all")), + }, + }, + }, + }) + }) + + t.Run("with_selected_repositories", func(t *testing.T) { + t.Parallel() + + repoName := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" +} + +resource "github_organization_private_registry" "test" { + registry_type = "maven_repository" + url = "https://maven-%s.example.com/releases" + auth_type = "token" + value = "super_secret_token_123" + visibility = "selected" + selected_repository_ids = [github_repository.test.repo_id] +} +`, repoName, acctest.RandString(testRandomIDLength)) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("visibility"), knownvalue.StringExact("selected")), + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("selected_repository_ids"), knownvalue.SetSizeExact(1)), + }, + }, + }, + }) + }) + + t.Run("forces_new_when_registry_type_changes", func(t *testing.T) { + t.Parallel() + + host := acctest.RandString(testRandomIDLength) + + configTmpl := ` +resource "github_organization_private_registry" "test" { + registry_type = "%s" + url = "%s" + value = "super_secret_token_123" + visibility = "private" +} +` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(configTmpl, "npm_registry", fmt.Sprintf("https://npm-%s.example.com", host)), + }, + { + Config: fmt.Sprintf(configTmpl, "rubygems_server", fmt.Sprintf("https://gems-%s.example.com", host)), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_organization_private_registry.test", plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + }, + }, + }) + }) + + t.Run("clears_username_when_removed", func(t *testing.T) { + t.Parallel() + + host := acctest.RandString(testRandomIDLength) + + configTmpl := ` +resource "github_organization_private_registry" "test" { + registry_type = "npm_registry" + url = "https://npm-%s.example.com" + auth_type = "username_password" + value = "super_secret_token_123" + visibility = "private" +%s} +` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(configTmpl, host, " username = \"deploy\"\n"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("username"), knownvalue.StringExact("deploy")), + }, + }, + { + Config: fmt.Sprintf(configTmpl, host, ""), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_private_registry.test", tfjsonpath.New("username"), knownvalue.StringExact("")), + }, + }, + }, + }) + }) +}