diff --git a/docs/resources/user_gpgkey.md b/docs/resources/user_gpgkey.md new file mode 100644 index 000000000..89b52368f --- /dev/null +++ b/docs/resources/user_gpgkey.md @@ -0,0 +1,65 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "gitlab_user_gpgkey Resource - terraform-provider-gitlab" +subcategory: "" +description: |- + The gitlab_user_gpgkey resource allows to manage the lifecycle of a GPG key assigned to the current user or a specific user. + -> Managing GPG keys for arbitrary users requires admin privileges. + Upstream API: GitLab REST API docs https://docs.gitlab.com/ee/api/users.html#get-a-specific-gpg-key +--- + +# gitlab_user_gpgkey (Resource) + +The `gitlab_user_gpgkey` resource allows to manage the lifecycle of a GPG key assigned to the current user or a specific user. + +-> Managing GPG keys for arbitrary users requires admin privileges. + +**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/users.html#get-a-specific-gpg-key) + +## Example Usage + +```terraform +data "gitlab_user" "example" { + username = "example-user" +} + +# Manages a GPG key for the specified user. An admin token is required if `user_id` is specified. +resource "gitlab_user_gpgkey" "example" { + user_id = data.gitlab_user.example.id + key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----" +} + +# Manages a GPG key for the current user +resource "gitlab_user_gpgkey" "example_user" { + key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----" +} +``` + + +## Schema + +### Required + +- `key` (String) The armored GPG public key. + +### Optional + +- `user_id` (Number) The ID of the user to add the GPG key to. If this field is omitted, this resource manages a GPG key for the current user. Otherwise, this resource manages a GPG key for the speicifed user, and an admin token is required. + +### Read-Only + +- `created_at` (String) The time when this key was created in GitLab. +- `id` (String) The ID of this resource. +- `key_id` (Number) The ID of the GPG key. + +## Import + +Import is supported using the following syntax: + +```shell +# You can import a GPG key for a specific user using an id made up of `{user-id}:{key}`, e.g. +terraform import gitlab_user_gpgkey.example 42:1 + +# Alternatively, you can import a GPG key for the current user using an id made up of `{key}`, e.g. +terraform import gitlab_user_gpgkey.example_user 1 +``` diff --git a/examples/resources/gitlab_user_gpgkey/import.sh b/examples/resources/gitlab_user_gpgkey/import.sh new file mode 100644 index 000000000..8ea9bd8e8 --- /dev/null +++ b/examples/resources/gitlab_user_gpgkey/import.sh @@ -0,0 +1,5 @@ +# You can import a GPG key for a specific user using an id made up of `{user-id}:{key}`, e.g. +terraform import gitlab_user_gpgkey.example 42:1 + +# Alternatively, you can import a GPG key for the current user using an id made up of `{key}`, e.g. +terraform import gitlab_user_gpgkey.example_user 1 diff --git a/examples/resources/gitlab_user_gpgkey/resource.tf b/examples/resources/gitlab_user_gpgkey/resource.tf new file mode 100644 index 000000000..e53f459f9 --- /dev/null +++ b/examples/resources/gitlab_user_gpgkey/resource.tf @@ -0,0 +1,14 @@ +data "gitlab_user" "example" { + username = "example-user" +} + +# Manages a GPG key for the specified user. An admin token is required if `user_id` is specified. +resource "gitlab_user_gpgkey" "example" { + user_id = data.gitlab_user.example.id + key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----" +} + +# Manages a GPG key for the current user +resource "gitlab_user_gpgkey" "example_user" { + key = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----" +} diff --git a/internal/provider/resource_gitlab_user_gpgkey.go b/internal/provider/resource_gitlab_user_gpgkey.go new file mode 100644 index 000000000..cd0011f1a --- /dev/null +++ b/internal/provider/resource_gitlab_user_gpgkey.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + gitlab "github.com/xanzy/go-gitlab" +) + +var _ = registerResource("gitlab_user_gpgkey", func() *schema.Resource { + return &schema.Resource{ + Description: `The ` + "`" + `gitlab_user_gpgkey` + "`" + ` resource allows to manage the lifecycle of a GPG key assigned to the current user or a specific user. + +-> Managing GPG keys for arbitrary users requires admin privileges. + +**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/users.html#get-a-specific-gpg-key)`, + + CreateContext: resourceGitlabUserGPGKeyCreate, + ReadContext: resourceGitlabUserGPGKeyRead, + DeleteContext: resourceGitlabUserGPGKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "user_id": { + Description: "The ID of the user to add the GPG key to. If this field is omitted, this resource manages a GPG key for the current user. Otherwise, this resource manages a GPG key for the speicifed user, and an admin token is required.", + Type: schema.TypeInt, + ForceNew: true, + Optional: true, + }, + "key": { + Description: "The armored GPG public key.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + strippedOld := strings.TrimSpace(old) + strippedNew := strings.TrimSpace(new) + return strippedOld == strippedNew + }, + }, + "key_id": { + Description: "The ID of the GPG key.", + Type: schema.TypeInt, + Computed: true, + }, + "created_at": { + Description: "The time when this key was created in GitLab.", + Type: schema.TypeString, + Computed: true, + }, + }, + } +}) + +func resourceGitlabUserGPGKeyCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + + options := &gitlab.AddGPGKeyOptions{ + Key: gitlab.String(strings.TrimSpace(d.Get("key").(string))), + } + + var isAdmin bool + var key *gitlab.GPGKey + var err error + userID, userIDOk := d.GetOk("user_id") + if userIDOk { + isAdmin, err = isCurrentUserAdmin(ctx, client) + if err != nil { + return diag.Errorf("failed to check if user is admin for configuring GPG keys for a user") + } + if !isAdmin { + return diag.Errorf("current user needs to be admin for configuring GPG keys for a user") + } + key, _, err = client.Users.AddGPGKeyForUser(userID.(int), options, gitlab.WithContext(ctx)) + } else { + key, _, err = client.Users.AddGPGKey(options, gitlab.WithContext(ctx)) + } + if err != nil { + return diag.FromErr(err) + } + + keyIDForID := fmt.Sprintf("%d", key.ID) + if userIDOk { + userIDForID := fmt.Sprintf("%d", userID) + d.SetId(buildTwoPartID(&userIDForID, &keyIDForID)) + } else { + d.SetId(keyIDForID) + } + return resourceGitlabUserGPGKeyRead(ctx, d, meta) +} + +func resourceGitlabUserGPGKeyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + + userID, keyID, err := resourceGitlabUserGPGKeyParseID(d.Id()) + if err != nil { + return diag.Errorf("unable to parse user GPG key resource id: %s: %v", d.Id(), err) + } + + var key *gitlab.GPGKey + if userID != 0 { + key, _, err = client.Users.GetGPGKeyForUser(userID, keyID, gitlab.WithContext(ctx)) + } else { + key, _, err = client.Users.GetGPGKey(keyID, gitlab.WithContext(ctx)) + } + if err != nil { + if is404(err) { + log.Printf("Could not find GPG key %d for user %d, removing from state", keyID, userID) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if userID != 0 { + d.Set("user_id", userID) + } + d.Set("key_id", keyID) + d.Set("key", strings.TrimSpace(key.Key)) + d.Set("created_at", key.CreatedAt.Format(time.RFC3339)) + return nil +} + +func resourceGitlabUserGPGKeyDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + + var isAdmin bool + _, keyID, err := resourceGitlabUserGPGKeyParseID(d.Id()) + if err != nil { + return diag.Errorf("unable to parse user GPG key resource id: %s: %v", d.Id(), err) + } + + if userID, ok := d.GetOk("user_id"); ok { + isAdmin, err = isCurrentUserAdmin(ctx, client) + if err != nil { + return diag.Errorf("failed to check if user is admin for configuring GPG keys for a user") + } + if !isAdmin { + return diag.Errorf("current user needs to be admin for configuring GPG keys for a user") + } + _, err = client.Users.DeleteGPGKeyForUser(userID.(int), keyID, gitlab.WithContext(ctx)) + } else { + _, err = client.Users.DeleteGPGKey(keyID, gitlab.WithContext(ctx)) + } + if err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGitlabUserGPGKeyParseID(id string) (int, int, error) { + userIDFromID, keyIDFromID, err := parseTwoPartID(id) + if err != nil { + keyID, errKeyID := strconv.Atoi(id) + if errKeyID != nil { + return 0, 0, err + } else { + return 0, keyID, nil + } + } + userID, err := strconv.Atoi(userIDFromID) + if err != nil { + return 0, 0, err + } + keyID, err := strconv.Atoi(keyIDFromID) + if err != nil { + return 0, 0, err + } + + return userID, keyID, nil +} diff --git a/internal/provider/resource_gitlab_user_gpgkey_test.go b/internal/provider/resource_gitlab_user_gpgkey_test.go new file mode 100644 index 000000000..dabee58a7 --- /dev/null +++ b/internal/provider/resource_gitlab_user_gpgkey_test.go @@ -0,0 +1,264 @@ +//go:build acceptance +// +build acceptance + +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/xanzy/go-gitlab" +) + +/* Keys are generated via: + +$ cat gen-key-script +Key-Type: eddsa +Key-Curve: ed25519 +Key-Usage: sign +Subkey-Type: eddsa +Subkey-Curve: ed25519 +Subkey-Usage: sign +Name-Real: Terraform +Name-Email: terraform@gitlab.com +Expire-Date: 0 +Passphrase: '' +$ gpg --batch --gen-key gen-key-script +*/ + +var testEd25519GPGPubKey string = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYtzLgRYJKwYBBAHaRw8BAQdArtnkLgGRJyUI0QiBidLC6tZ++xAQ0ofQ0sxR ++lZbIsO0IFRlcnJhZm9ybSA8dGVycmFmb3JtQGdpdGxhYi5jb20+iJAEExYIADgW +IQTP8v16wSfKRS9E/hWPJsCZR6QoUQUCYtzLgQIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRCPJsCZR6QoUeiaAQDEqci/HGBKHgGy3G2wJmvdywL3SEwgsSfu +j8betD1cKQD/Q1FRHOT6nsshhgHLxuuM6Nc7EaTjQyaXxFvEJ8YOXwS4MwRi3MuB +FgkrBgEEAdpHDwEBB0BneBgfymn64IZIxsaJQNpRAehycYE9VyNMLbfPzHFUm4jv +BBgWCAAgFiEEz/L9esEnykUvRP4VjybAmUekKFEFAmLcy4ECGwIAgQkQjybAmUek +KFF2IAQZFggAHRYhBPKpgJFOiwH7W8NFVckaYlcKYj8ZBQJi3MuBAAoJEMkaYlcK +Yj8ZGcoA/RUR48JzgKB0QtUsVHbxH5HxGljJzX+fojk9amOfly+aAQCPB6//nRA2 +RLwa75kqGYGYS6cpY/ZSaqi19342XjiJD+NtAQDq+XWq7qVf1DZ6VPBpsZJfQ/ws +1piPsvmKI/2koOa1wQD/ebxTge120L4AVDMpGhDjpqt+B4qN4SiEKynbSJcWiAg= +=q0aF +-----END PGP PUBLIC KEY BLOCK-----` +var updatedEd25519GPGPubKey string = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYtzQ6RYJKwYBBAHaRw8BAQdASvO1H8QsiJSN+qmEmBwtgeNi61lXzCmCfUF3 +/5e1qgC0IFRlcnJhZm9ybSA8dGVycmFmb3JtQGdpdGxhYi5jb20+iJAEExYIADgW +IQTqH8ino1RNUdUDG7H+AGMyH5oPPwUCYtzQ6QIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRD+AGMyH5oPP+pbAP0YgRmwvpipmaBuK4/gDWnE3gO2lR4x+vzL +ciPmYxshZQD+KAK69/eUwsdbW+uapbtSUNDF4l6PWyKES6qYSUlpswi4MwRi3NDp +FgkrBgEEAdpHDwEBB0A2d2HUVSFIudz5xegw3HjmH5tDoodbyoURpD7NZhslpojv +BBgWCAAgFiEE6h/Ip6NUTVHVAxux/gBjMh+aDz8FAmLc0OkCGwIAgQkQ/gBjMh+a +Dz92IAQZFggAHRYhBEA7LZIX/tW2nvyiCfF9dLzyMph4BQJi3NDpAAoJEPF9dLzy +Mph4TYEBAPSRfGNlQESyYfUmqV795iFkIgCM5nVzqHHfw0mcH50EAP4qZK9Iobvs +/yG4eD5jp4nGkRfDlXA+ZsIjmChtIRT2DVy/AQDRFeywZIsvuje6DF1AP7qeqbs+ +dZcWp5qRyFu5zodW2gEAgw7OxfYmlQbz7wwKr3IYnY9MyVp+JwSxyAxZ4X+odAM= +=XSo3 +-----END PGP PUBLIC KEY BLOCK-----` +var testEd25519GPGPubKeyForCurrentUser string = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYtzl7hYJKwYBBAHaRw8BAQdAF8RE2x2wE3w/QJAbB8uVv+9HqYQZJGyNZeLt +eKyyh2i0IFRlcnJhZm9ybSA8dGVycmFmb3JtQGdpdGxhYi5jb20+iJAEExYIADgW +IQShzcoy44uIRzOHNnGXiWgn/RdiBQUCYtzl7gIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRCXiWgn/RdiBUDpAQDpcNH5rxVlJS4ATpkFPxbyMuZBdr6eaw2G +3o8ZPykfbAEAt62eRBhyep+rjPyAHW8YyX4+e+SdCZw/KH0NDbZCGAW4MwRi3OXu +FgkrBgEEAdpHDwEBB0DE69f1NA/05px8W0ZUkIl0kheMosQ3+UFbt/Jba0DQiojv +BBgWCAAgFiEEoc3KMuOLiEczhzZxl4loJ/0XYgUFAmLc5e4CGwIAgQkQl4loJ/0X +YgV2IAQZFggAHRYhBCB2JV+gsGWeld0PcwehRlcCKxpcBQJi3OXuAAoJEAehRlcC +KxpcSmcBALG/ZILOrVHratA+cZchDQEtOM2rymXdw6AgnllhMj48AQCjWmESbs+C +GcNNwjo6hAu59BiCvU5+W2of9fpSxBM5CJQ4AQDjyZwDcf6kMu5+bYY9aNcz9skX +BEMqhn2i2EtNNfH6eAEA1m21vnE8pseBCYHtl9/XJGkB5JH0gUqqRrCf5CAqsgk= +=/bV6 +-----END PGP PUBLIC KEY BLOCK-----` +var testEd25519GPGPubKeyWithTrailingNewline string = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYtzSYhYJKwYBBAHaRw8BAQdAaSePzlrqUWT/hBRqO/oIdfBPh5m57cPwpmjL +dgUJGcW0IFRlcnJhZm9ybSA8dGVycmFmb3JtQGdpdGxhYi5jb20+iJAEExYIADgW +IQTFohelsWBqQdLTMuS4BhhIqbpAJQUCYtzSYgIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRC4BhhIqbpAJX8hAP9XCLVIMzO8pleoU82XV+yEVxgKA44uSvat +zcsIlQrUpwEAkFllbHnquiMxOr+UTXFXIj2L48V2oysbwbhadfIeGwO4MwRi3NJi +FgkrBgEEAdpHDwEBB0AzJRFF4Ufc8MFl42TDhkMNvMYxVdHogLyOXwK5zoQIo4jv +BBgWCAAgFiEExaIXpbFgakHS0zLkuAYYSKm6QCUFAmLc0mICGwIAgQkQuAYYSKm6 +QCV2IAQZFggAHRYhBKkMHhbuzwzdKeMiMw7wawPgy8RgBQJi3NJiAAoJEA7wawPg +y8RgobsA/1uo4Z1ybThL//tNXFeV8J4vr5Kj+1mUO92v4E8oMhL0AQDsWH2H6kn2 +yXEq4d7IEMN2bZOgnIK8q9wcQm7ouYXkDZ2rAP96iz2c2HYWouFZXf0vqzrgJWVB +ubvgn0HwJkdP6VIUKwEArIF90cFLtKmephxMlwp7h+djEWpVIQ/T31pLYSAH4QA= +=5+sB +-----END PGP PUBLIC KEY BLOCK----- +` + +func TestAccGitlabUserGPGKey_basic(t *testing.T) { + testUser := testAccCreateUsers(t, 1)[0] + + resource.ParallelTest(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabUserGPGKeyDestroy, + Steps: []resource.TestStep{ + // Create a user + gpgkey + { + Config: fmt.Sprintf(` + resource "gitlab_user_gpgkey" "foo_key" { + key = <