diff --git a/registry/coder/modules/github-upload-public-key/README.md b/registry/coder/modules/github-upload-public-key/README.md index 0f8fc52c7..88ef06f06 100644 --- a/registry/coder/modules/github-upload-public-key/README.md +++ b/registry/coder/modules/github-upload-public-key/README.md @@ -14,7 +14,7 @@ Templates that utilize Github External Auth can automatically ensure that the Co module "github-upload-public-key" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/github-upload-public-key/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.main.id key_name = "ACME Coder Workspaces" } @@ -22,6 +22,22 @@ module "github-upload-public-key" { `key_name` is optional. If omitted, GitHub displays the uploaded key as ` Workspaces`. +## Commit signing + +By default, the module uploads the Coder public key as a GitHub authentication key. Set `key_type` to `signing` to upload it as a GitHub SSH signing key instead, so GitHub can verify commits signed with it, or to `both` to upload it as both: + +```tf +module "github-upload-public-key" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/github-upload-public-key/coder" + version = "1.2.0" + agent_id = coder_agent.main.id + key_type = "both" +} +``` + +This module only uploads the key. Git inside the workspace must still be configured to sign commits with the Coder SSH key. + ## Requirements This module requires `curl` and `jq` to be installed inside your workspace. @@ -38,6 +54,12 @@ CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key" Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes. +When `key_type` is `signing` or `both`, the Github app must also have read and write permissions to "SSH signing keys", and the external auth configuration must include the `write:ssh_signing_key` scope. For example: + +```txt +CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key,write:ssh_signing_key" +``` + ## Example Using a coder github external auth with a non-default id: (default is `github`) @@ -50,7 +72,7 @@ data "coder_external_auth" "github" { module "github-upload-public-key" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/github-upload-public-key/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.main.id external_auth_id = data.coder_external_auth.github.id } diff --git a/registry/coder/modules/github-upload-public-key/main.test.ts b/registry/coder/modules/github-upload-public-key/main.test.ts index 7e7f2b8fa..c5193b440 100644 --- a/registry/coder/modules/github-upload-public-key/main.test.ts +++ b/registry/coder/modules/github-upload-public-key/main.test.ts @@ -35,11 +35,16 @@ afterEach(async () => { } }); +type Upload = { path: string; title: string }; + +const AUTH_KEYS_PATH = "/user/keys"; +const SIGNING_KEYS_PATH = "/user/ssh_signing_keys"; + const setupContainer = async ( image = "lorello/alpine-bash", vars: Record = {}, ) => { - const { server, uploadedKeyNames } = setupServer(); + const { server, uploads } = setupServer(); const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", ...vars, @@ -54,11 +59,24 @@ const setupContainer = async ( await removeContainer(id); }); - return { id, instance, server, uploadedKeyNames }; + const url = server.url.toString().slice(0, -1); + const run = () => + execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + + return { id, url, run, uploads }; }; const setupServer = () => { - const uploadedKeyNames: string[] = []; + const uploads: Upload[] = []; const server = serve({ fetch: async (req) => { const url = new URL(req.url); @@ -68,10 +86,13 @@ const setupServer = () => { }); } - if (url.pathname === "/user/keys") { + if ( + url.pathname === AUTH_KEYS_PATH || + url.pathname === SIGNING_KEYS_PATH + ) { if (req.method === "POST") { const body = (await req.json()) as { title: string }; - uploadedKeyNames.push(body.title); + uploads.push({ path: url.pathname, title: body.title }); return createJSONResponse( { key: "created", @@ -92,6 +113,19 @@ const setupServer = () => { ]); } + // case: key exists on the second page + if (req.headers.get("Authorization") === "Bearer paginated") { + const perPage = Number(url.searchParams.get("per_page") ?? 30); + const page = Number(url.searchParams.get("page") ?? 1); + const keys = [ + ...Array.from({ length: perPage }, (_, i) => ({ key: `foo-${i}` })), + { key: "exists" }, + ]; + return createJSONResponse( + keys.slice((page - 1) * perPage, page * perPage), + ); + } + // case: key does not exist return createJSONResponse([ { @@ -110,7 +144,7 @@ const setupServer = () => { port: 0, }); - return { server, uploadedKeyNames }; + return { server, uploads }; }; setDefaultTimeout(30 * 1000); @@ -125,98 +159,129 @@ describe("github-upload-public-key", () => { }); it("creates new key if one does not exist", async () => { - const { instance, id, server, uploadedKeyNames } = await setupContainer(); + const { id, url, run, uploads } = await setupContainer(); await writeCoder(id, "echo foo"); - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, - ]); + const exec = await run(); expect(exec.stdout).toContain( - "Your Coder public key has been added to GitHub!", + "Your Coder public key has been added as a GitHub authentication key!", ); expect(exec.exitCode).toBe(0); - expect(uploadedKeyNames).toEqual([`${url} Workspaces`]); + expect(uploads).toEqual([ + { path: AUTH_KEYS_PATH, title: `${url} Workspaces` }, + ]); }); it("uses a custom key name", async () => { const keyName = "ACME Coder Workspaces"; - const { instance, id, server, uploadedKeyNames } = await setupContainer( - undefined, - { key_name: keyName }, - ); + const { id, run, uploads } = await setupContainer(undefined, { + key_name: keyName, + }); await writeCoder(id, "echo foo"); - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, - ]); + const exec = await run(); expect(exec.stdout).toContain( - "Your Coder public key has been added to GitHub!", + "Your Coder public key has been added as a GitHub authentication key!", ); expect(exec.exitCode).toBe(0); - expect(uploadedKeyNames).toEqual([keyName]); + expect(uploads).toEqual([{ path: AUTH_KEYS_PATH, title: keyName }]); }); it("uses a key name with special characters", async () => { const keyName = 'Special $(id) "chars" `test`'; - const { instance, id, server, uploadedKeyNames } = await setupContainer( - undefined, - { key_name: keyName }, - ); + const { id, run, uploads } = await setupContainer(undefined, { + key_name: keyName, + }); await writeCoder(id, "echo foo"); - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, - ]); + const exec = await run(); expect(exec.stdout).toContain( - "Your Coder public key has been added to GitHub!", + "Your Coder public key has been added as a GitHub authentication key!", ); expect(exec.exitCode).toBe(0); - expect(uploadedKeyNames).toEqual([keyName]); + expect(uploads).toEqual([{ path: AUTH_KEYS_PATH, title: keyName }]); }); it("does nothing if one already exists", async () => { - const { instance, id, server } = await setupContainer(); + const { id, run, uploads } = await setupContainer(); // use keyword to make server return a existing key await writeCoder(id, "echo findkey"); - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, + const exec = await run(); + expect(exec.stdout).toContain( + "Your Coder public key is already a GitHub authentication key!", + ); + expect(exec.exitCode).toBe(0); + expect(uploads).toEqual([]); + }); + + it("uploads only a signing key when key_type is signing", async () => { + const { id, url, run, uploads } = await setupContainer(undefined, { + key_type: "signing", + }); + await writeCoder(id, "echo foo"); + + const exec = await run(); + expect(exec.stdout).toContain( + "Your Coder public key has been added as a GitHub signing key!", + ); + expect(exec.exitCode).toBe(0); + expect(uploads).toEqual([ + { path: SIGNING_KEYS_PATH, title: `${url} Workspaces` }, + ]); + }); + + it("uploads authentication and signing keys when key_type is both", async () => { + const { id, url, run, uploads } = await setupContainer(undefined, { + key_type: "both", + }); + await writeCoder(id, "echo foo"); + + const exec = await run(); + expect(exec.stdout).toContain( + "Your Coder public key has been added as a GitHub authentication key!", + ); + expect(exec.stdout).toContain( + "Your Coder public key has been added as a GitHub signing key!", + ); + expect(exec.exitCode).toBe(0); + expect(uploads).toEqual([ + { path: AUTH_KEYS_PATH, title: `${url} Workspaces` }, + { path: SIGNING_KEYS_PATH, title: `${url} Workspaces` }, ]); + }); + + it("finds an existing key on a later page", async () => { + const { id, run, uploads } = await setupContainer(undefined, { + key_type: "both", + }); + await writeCoder(id, "echo paginated"); + + const exec = await run(); + expect(exec.stdout).toContain( + "Your Coder public key is already a GitHub authentication key!", + ); + expect(exec.stdout).toContain( + "Your Coder public key is already a GitHub signing key!", + ); + expect(exec.exitCode).toBe(0); + expect(uploads).toEqual([]); + }); + + it("skips existing keys when key_type is both", async () => { + const { id, run, uploads } = await setupContainer(undefined, { + key_type: "both", + }); + await writeCoder(id, "echo findkey"); + + const exec = await run(); + expect(exec.stdout).toContain( + "Your Coder public key is already a GitHub authentication key!", + ); expect(exec.stdout).toContain( - "Your Coder public key is already on GitHub!", + "Your Coder public key is already a GitHub signing key!", ); expect(exec.exitCode).toBe(0); + expect(uploads).toEqual([]); }); }); diff --git a/registry/coder/modules/github-upload-public-key/main.tf b/registry/coder/modules/github-upload-public-key/main.tf index 48da51ab9..cf87f0b65 100644 --- a/registry/coder/modules/github-upload-public-key/main.tf +++ b/registry/coder/modules/github-upload-public-key/main.tf @@ -32,6 +32,16 @@ variable "key_name" { default = null } +variable "key_type" { + description = "Where to upload the Coder public key on GitHub: 'auth' for an authentication key, 'signing' for a commit signing key, or 'both'." + type = string + default = "auth" + validation { + condition = contains(["auth", "signing", "both"], var.key_type) + error_message = "key_type must be one of 'auth', 'signing', or 'both'." + } +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} @@ -43,6 +53,7 @@ resource "coder_script" "github_upload_public_key" { CODER_EXTERNAL_AUTH_ID : var.external_auth_id, GITHUB_API_URL : var.github_api_url, CODER_PUBLIC_KEY_NAME : base64encode(var.key_name == null ? "" : var.key_name), + KEY_TYPE : var.key_type, }) display_name = "Github Upload Public Key" icon = "/icon/github.svg" diff --git a/registry/coder/modules/github-upload-public-key/main.tftest.hcl b/registry/coder/modules/github-upload-public-key/main.tftest.hcl new file mode 100644 index 000000000..fb11a13cc --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/main.tftest.hcl @@ -0,0 +1,51 @@ +run "defaults_to_auth_key_type" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = strcontains(coder_script.github_upload_public_key.script, "KEY_TYPE='auth'") + error_message = "key_type should default to auth" + } +} + +run "passes_signing_key_type" { + command = plan + + variables { + agent_id = "test-agent-id" + key_type = "signing" + } + + assert { + condition = strcontains(coder_script.github_upload_public_key.script, "KEY_TYPE='signing'") + error_message = "key_type signing should be passed to the script" + } +} + +run "passes_both_key_type" { + command = plan + + variables { + agent_id = "test-agent-id" + key_type = "both" + } + + assert { + condition = strcontains(coder_script.github_upload_public_key.script, "KEY_TYPE='both'") + error_message = "key_type both should be passed to the script" + } +} + +run "rejects_invalid_key_type" { + command = plan + + variables { + agent_id = "test-agent-id" + key_type = "invalid" + } + + expect_failures = [var.key_type] +} diff --git a/registry/coder/modules/github-upload-public-key/run.sh b/registry/coder/modules/github-upload-public-key/run.sh index c80efb869..58f816d49 100644 --- a/registry/coder/modules/github-upload-public-key/run.sh +++ b/registry/coder/modules/github-upload-public-key/run.sh @@ -61,51 +61,78 @@ if [ -z "$PUBLIC_KEY" ]; then exit 1 fi -echo "Fetching public keys from GitHub..." -GITHUB_KEYS_RESPONSE=$( - curl -L -s \ - -w "\n%%{http_code}" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - $GITHUB_API_URL/user/keys -) -GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE") -GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE") +CODER_PUBLIC_KEY_NAME="$(printf '%s' '${CODER_PUBLIC_KEY_NAME}' | base64 -d)" +[ -n "$CODER_PUBLIC_KEY_NAME" ] || CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces" -if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then - echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!" - echo "$GITHUB_KEYS_RESPONSE_BODY" - exit 1 -fi +upload_key() { + local endpoint="$1" + local label="$2" -GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY") + echo "Fetching $label keys from GitHub..." + local per_page=100 page=1 + local keys_response keys_status keys_body match count + while true; do + keys_response=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/user/$endpoint?per_page=$per_page&page=$page" + ) + keys_status=$(tail -n1 <<< "$keys_response") + keys_body=$(sed \$d <<< "$keys_response") -if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then - echo "Your Coder public key is already on GitHub!" - exit 0 -fi + if [ "$keys_status" -ne 200 ]; then + echo "Failed to fetch GitHub $label keys with status code $keys_status!" + echo "$keys_body" + return 1 + fi -echo "Your Coder public key is not in GitHub. Adding it now..." -CODER_PUBLIC_KEY_NAME="$(printf '%s' '${CODER_PUBLIC_KEY_NAME}' | base64 -d)" -[ -n "$CODER_PUBLIC_KEY_NAME" ] || CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces" -UPLOAD_RESPONSE=$( - curl -L -s \ - -X POST \ - -w "\n%%{http_code}" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - $GITHUB_API_URL/user/keys \ - -d "$(jq -nc --arg title "$CODER_PUBLIC_KEY_NAME" --arg key "$PUBLIC_KEY" '{title: $title, key: $key}')" -) -UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE") -UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE") + match=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$keys_body") + if [ "$PUBLIC_KEY" = "$match" ]; then + echo "Your Coder public key is already a GitHub $label key!" + return 0 + fi -if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then - echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!" - echo "$UPLOAD_RESPONSE_BODY" - exit 1 + count=$(jq 'length' <<< "$keys_body") + if [ "$count" -lt "$per_page" ]; then + break + fi + page=$((page + 1)) + done + + echo "Your Coder public key is not a GitHub $label key yet. Adding it now..." + local upload_response + upload_response=$( + curl -L -s \ + -X POST \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/user/$endpoint" \ + -d "$(jq -nc --arg title "$CODER_PUBLIC_KEY_NAME" --arg key "$PUBLIC_KEY" '{title: $title, key: $key}')" + ) + local upload_status upload_body + upload_status=$(tail -n1 <<< "$upload_response") + upload_body=$(sed \$d <<< "$upload_response") + + if [ "$upload_status" -ne 201 ]; then + echo "Failed to upload Coder public SSH key as a GitHub $label key with status code $upload_status!" + echo "$upload_body" + return 1 + fi + + echo "Your Coder public key has been added as a GitHub $label key!" +} + +KEY_TYPE='${KEY_TYPE}' + +if [ "$KEY_TYPE" = "auth" ] || [ "$KEY_TYPE" = "both" ]; then + upload_key "keys" "authentication" || exit 1 fi -echo "Your Coder public key has been added to GitHub!" +if [ "$KEY_TYPE" = "signing" ] || [ "$KEY_TYPE" = "both" ]; then + upload_key "ssh_signing_keys" "signing" || exit 1 +fi