From 036dadef137109cd19345f4266122900905b7323 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 23 Apr 2026 12:04:21 -0700 Subject: [PATCH 1/2] feat(terraform): add reusable module for deploying the broker Lambda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships a first-party Terraform module under `terraform/` that provisions the published `github-token-broker` release as an AWS Lambda, including least-privilege IAM, a managed CloudWatch log group, and an optional Function URL (AWS_IAM auth only). Three ways to source the zip via the `lambda_artifact` input: - `release_version` — `null_resource` + `local-exec` runs `gh release download` on the apply host and verifies the zip against `checksums.txt` before Terraform consumes it. - `lambda_zip_path` — pre-downloaded zip for air-gapped flows. - `lambda_source_s3` — S3 bucket/key when the zip is staged out-of-band. Inline SHA256 verification is defense-in-depth; `gh attestation verify` remains the canonical supply-chain check and is documented prominently in `terraform/README.md`. Also amends the release pipeline to upload `dist/checksums.txt` as a release asset (prerequisite for inline verification), extends dependabot to the terraform ecosystem, and adds a CI workflow with fmt, matrix validate, tflint (AWS ruleset), trivy config scan, and terraform-docs sync check — all SHA-pinned with top-level `permissions: {}`. Examples: `basic`, `function-url` (with principal-scoped invoker permission), and `with-ssm-bootstrap` (gated SSM parameter creation for first-time setup). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 10 + .github/workflows/reusable-release.yml | 7 +- .github/workflows/terraform.yml | 134 +++++++++++ README.md | 9 +- terraform/.tflint.hcl | 10 + terraform/README.md | 167 ++++++++++++++ terraform/examples/basic/README.md | 27 +++ terraform/examples/basic/main.tf | 26 +++ terraform/examples/basic/outputs.tf | 9 + .../examples/basic/terraform.tfvars.example | 4 + terraform/examples/basic/variables.tf | 19 ++ terraform/examples/function-url/README.md | 20 ++ terraform/examples/function-url/main.tf | 36 +++ terraform/examples/function-url/outputs.tf | 9 + .../function-url/terraform.tfvars.example | 5 + terraform/examples/function-url/variables.tf | 24 ++ .../examples/with-ssm-bootstrap/README.md | 23 ++ terraform/examples/with-ssm-bootstrap/main.tf | 68 ++++++ .../examples/with-ssm-bootstrap/outputs.tf | 9 + .../terraform.tfvars.example | 17 ++ .../examples/with-ssm-bootstrap/variables.tf | 66 ++++++ terraform/iam.tf | 49 ++++ terraform/locals.tf | 61 +++++ terraform/main.tf | 82 +++++++ terraform/outputs.tf | 39 ++++ terraform/variables.tf | 211 ++++++++++++++++++ terraform/versions.tf | 14 ++ 27 files changed, 1151 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/terraform.yml create mode 100644 terraform/.tflint.hcl create mode 100644 terraform/README.md create mode 100644 terraform/examples/basic/README.md create mode 100644 terraform/examples/basic/main.tf create mode 100644 terraform/examples/basic/outputs.tf create mode 100644 terraform/examples/basic/terraform.tfvars.example create mode 100644 terraform/examples/basic/variables.tf create mode 100644 terraform/examples/function-url/README.md create mode 100644 terraform/examples/function-url/main.tf create mode 100644 terraform/examples/function-url/outputs.tf create mode 100644 terraform/examples/function-url/terraform.tfvars.example create mode 100644 terraform/examples/function-url/variables.tf create mode 100644 terraform/examples/with-ssm-bootstrap/README.md create mode 100644 terraform/examples/with-ssm-bootstrap/main.tf create mode 100644 terraform/examples/with-ssm-bootstrap/outputs.tf create mode 100644 terraform/examples/with-ssm-bootstrap/terraform.tfvars.example create mode 100644 terraform/examples/with-ssm-bootstrap/variables.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0ab1238..7692cdc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,3 +30,13 @@ updates: labels: - dependencies - documentation + + - package-ecosystem: terraform + directory: /terraform + schedule: + interval: weekly + commit-message: + prefix: "chore(deps)" + labels: + - dependencies + - terraform diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index e134038..1bb1110 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -81,11 +81,14 @@ jobs: predicate-type: https://spdx.dev/Document predicate-path: dist/github-token-broker.zip.spdx.json - - name: Upload Lambda zip to draft release + - name: Upload release assets to draft release shell: bash env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} run: | set -euo pipefail - gh release upload "${TAG}" dist/github-token-broker.zip --clobber + gh release upload "${TAG}" \ + dist/github-token-broker.zip \ + dist/checksums.txt \ + --clobber diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..bcdb05c --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,134 @@ +name: Terraform + +on: + pull_request: + branches: + - master + - main + paths: + - 'terraform/**' + - '.github/workflows/terraform.yml' + push: + branches: + - master + - main + paths: + - 'terraform/**' + - '.github/workflows/terraform.yml' + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Terraform + uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0 + with: + terraform_wrapper: false + + - name: terraform fmt + working-directory: terraform + run: terraform fmt -check -recursive -diff + + validate: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + directory: + - terraform + - terraform/examples/basic + - terraform/examples/function-url + - terraform/examples/with-ssm-bootstrap + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Terraform + uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0 + with: + terraform_wrapper: false + + - name: terraform init + working-directory: ${{ matrix.directory }} + run: terraform init -backend=false -input=false + + - name: terraform validate + working-directory: ${{ matrix.directory }} + run: terraform validate -no-color + + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up tflint + uses: terraform-linters/setup-tflint@b480b8fcdaa6f2c577f8e4fa799e89e756bb7c93 # v6.2.2 + with: + tflint_version: v0.62.0 + + - name: tflint --init + working-directory: terraform + run: tflint --init + + - name: tflint + working-directory: terraform + run: tflint --recursive + + security: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Trivy misconfiguration scan + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 + with: + scan-type: config + scan-ref: terraform + severity: HIGH,CRITICAL + exit-code: '1' + ignore-unfixed: false + + docs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Check terraform-docs is in sync + uses: terraform-docs/gh-actions@6de6da0cefcc6b4b7a5cbea4d79d97060733093c # v1.4.1 + with: + working-dir: terraform + output-file: README.md + output-method: inject + fail-on-diff: true diff --git a/README.md b/README.md index 4a7586f..ca767b1 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,14 @@ The token is intentionally logged nowhere. Callers should treat it as a secret a ## Verification -Releases ship only the Lambda zip. Build provenance and an SBOM are persisted to GitHub's Attestations API; verify them with [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) rather than downloading signature or SBOM files from the release page. +Releases ship the Lambda zip alongside a `checksums.txt` (SHA256). Build provenance and an SBOM are persisted to GitHub's Attestations API; verify them with [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) rather than downloading signature or SBOM files from the release page. ```sh TAG=v1.0.0 -gh release download "$TAG" -R meigma/github-token-broker -p 'github-token-broker.zip' +gh release download "$TAG" -R meigma/github-token-broker \ + -p 'github-token-broker.zip' -p 'checksums.txt' + +sha256sum --check checksums.txt gh release verify "$TAG" -R meigma/github-token-broker gh release verify-asset "$TAG" ./github-token-broker.zip -R meigma/github-token-broker @@ -87,6 +90,8 @@ gh attestation verify ./github-token-broker.zip \ --deny-self-hosted-runners ``` +The `sha256sum` check is defense-in-depth against a corrupted download; `gh attestation verify` is the canonical supply-chain check. `checksums.txt` itself is bound to the provenance attestation, so anchor trust in the attestation rather than the file alone. + The attestation call above validates the SLSA build provenance by default. To validate the SBOM attestation specifically, add a predicate filter: ```sh diff --git a/terraform/.tflint.hcl b/terraform/.tflint.hcl new file mode 100644 index 0000000..a1ff2df --- /dev/null +++ b/terraform/.tflint.hcl @@ -0,0 +1,10 @@ +plugin "terraform" { + enabled = true + preset = "recommended" +} + +plugin "aws" { + enabled = true + version = "0.47.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..5e2f7e4 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,167 @@ +# github-token-broker — Terraform module + +Deploys [`github-token-broker`](https://github.com/meigma/github-token-broker) as an AWS Lambda, sourced from a published GitHub Release asset. + +The module is opinionated in what matters for supply-chain integrity (SHA256 verification on download, least-privilege IAM, `AWS_IAM`-only Function URL) and configurable everywhere else (memory, timeout, tags, log retention, permissions set, SSM parameter paths, KMS). + +## Usage + +```hcl +module "broker" { + source = "github.com/meigma/github-token-broker//terraform?ref=v1.0.0" + + function_name = "github-token-broker" + repository_owner = "example-org" + repository_name = "example-repo" + + lambda_artifact = { + release_version = "v1.0.0" + } +} +``` + +Three ways to source the Lambda zip via `lambda_artifact`: + +| Field | When to use | +| --- | --- | +| `release_version = "v1.0.0"` | Normal case. The module runs `gh release download` on the `terraform apply` host, verifies the zip against `checksums.txt`, and caches the zip under `.terraform/github-token-broker///`. Requires `gh` and `sha256sum` on PATH. | +| `lambda_zip_path = "/path/to/github-token-broker.zip"` | Air-gapped or pre-downloaded workflows where `gh` is unavailable at apply time. Verify the zip out-of-band with `gh attestation verify` before using this path. | +| `lambda_source_s3 = { bucket = "...", key = "..." }` | When the zip is already staged to S3 (e.g. by CI). The module references S3 directly; no local download. | + +Exactly one of the three must be set; a validation rule enforces this. + +See [`examples/basic`](./examples/basic) for the smallest viable config, [`examples/function-url`](./examples/function-url) for a Function URL deployment, and [`examples/with-ssm-bootstrap`](./examples/with-ssm-bootstrap) for first-time SSM parameter creation. + +## Verification + +Inline SHA256 verification happens on every apply that downloads the release asset. `checksums.txt` is signed as part of the SLSA build provenance attestation, so the trust anchor is the attestation, not the checksum file on its own. + +Run `gh attestation verify` before deploying a new version pin to confirm the asset's provenance: + +```sh +TAG=v1.0.0 +gh release download "$TAG" -R meigma/github-token-broker -p github-token-broker.zip +gh attestation verify github-token-broker.zip \ + --repo meigma/github-token-broker \ + --signer-workflow meigma/github-token-broker/.github/workflows/reusable-release.yml \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +``` + +The module does not invoke `gh attestation verify` itself — Terraform has no ergonomic way to run it inline. Treat it as a pre-deployment check in your CI or your human review loop. + +## Sandbox validation + +End-to-end validation requires an AWS account and a GitHub App with an installation covering `repository_owner/repository_name`. A minimal procedure: + +1. Create a GitHub App with `contents: read` (or whatever `permissions` you pass), install it on the target repo, and record the client ID, installation ID, and private key PEM. +2. Put the three values in SSM: + ```sh + aws ssm put-parameter --name /github-token-broker/app/client-id --type String --value "Iv23li..." + aws ssm put-parameter --name /github-token-broker/app/installation-id --type String --value "12345678" + aws ssm put-parameter --name /github-token-broker/app/private-key-pem --type SecureString --value "$(cat key.pem)" + ``` +3. Apply `examples/basic`: + ```sh + cd examples/basic + cp terraform.tfvars.example terraform.tfvars + # edit values + terraform init && terraform apply + ``` +4. Invoke the Lambda: + ```sh + aws lambda invoke --function-name github-token-broker --payload '{}' \ + --cli-binary-format raw-in-base64-out /tmp/out.json + jq . /tmp/out.json + ``` +5. Confirm the response contains `token`, `expires_at`, `repositories`, and `permissions`. Use the token against the GitHub API to confirm it works: + ```sh + TOKEN=$(jq -r .token /tmp/out.json) + gh api "repos//" -H "Authorization: token $TOKEN" | jq .name + ``` + +## Security notes + +- IAM policy grants only `ssm:GetParameters` on the three configured paths, `kms:Decrypt` on the explicit CMK ARN when provided, and `logs:CreateLogStream`/`logs:PutLogEvents` on the module-managed log group. No wildcards on sensitive actions. +- Function URLs are always `AWS_IAM`-authenticated. The module will not create a `NONE`-auth URL. +- The Lambda rejects non-empty invocation payloads, so the deployed `permissions` set is the upper bound — callers cannot request more. +- `AWS_REGION` is provided by the Lambda runtime automatically; the module does not set it explicitly. +- `CHANGELOG.md` and the release page are the source of truth for what's in a given `release_version`. The module performs SHA256 verification against `checksums.txt`, not signature verification — use `gh attestation verify` for supply-chain assurance. + +## Requirements on the apply host (default mode) + +- `gh` (≥ 2.40) — authenticated against the target release repository. +- `sha256sum` — present on most Linux distros and macOS with coreutils. The `null_resource` aborts early if either binary is missing. + +Switch to `lambda_zip_path` or `lambda_source_s3` if the apply host cannot satisfy these. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.6 | +| [aws](#requirement\_aws) | >= 5.0, < 7.0 | +| [null](#requirement\_null) | >= 3.2 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.42.0 | +| [null](#provider\_null) | 3.2.4 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_lambda_function.broker](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function_url.broker](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function_url) | resource | +| [null_resource.fetch_release](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | +| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [architecture](#input\_architecture) | Lambda architecture. arm64 matches the published release zip. | `string` | `"arm64"` | no | +| [enable\_function\_url](#input\_enable\_function\_url) | Create a Lambda Function URL with AWS\_IAM auth. Never creates a NONE-auth URL. | `bool` | `false` | no | +| [function\_name](#input\_function\_name) | Name of the Lambda function. | `string` | n/a | yes | +| [github\_api\_base\_url](#input\_github\_api\_base\_url) | GitHub API base URL. Override for GitHub Enterprise Server. | `string` | `"https://api.github.com"` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS key ARN used by SSM to encrypt the private key parameter. Set only when the customer uses a CMK instead of the AWS-managed key. Null disables kms:Decrypt in the role policy. | `string` | `null` | no | +| [lambda\_artifact](#input\_lambda\_artifact) | Source of the Lambda zip. Exactly one of the three fields must be set:

- `release_version`: a tag published on `release_repository` (e.g. "v1.0.0").
The module downloads `github-token-broker.zip` and `checksums.txt` via the
`gh` CLI on the machine running `terraform apply`, verifies the zip's
SHA256 against `checksums.txt`, and points the Lambda at the cached copy.
- `lambda_zip_path`: absolute path to a pre-downloaded zip. Used for
air-gapped workflows where `gh` is unavailable at apply time.
- `lambda_source_s3`: S3 bucket/key holding the zip. Used when the zip is
staged to S3 out-of-band (e.g. by CI).

Inline SHA256 verification is defense-in-depth against a corrupted
download. It is NOT a replacement for `gh attestation verify`, which is
the canonical supply-chain check. See `terraform/README.md` for guidance. |
object({
release_version = optional(string)
lambda_zip_path = optional(string)
lambda_source_s3 = optional(object({
bucket = string
key = string
}))
})
| n/a | yes | +| [log\_level](#input\_log\_level) | Slog level. One of debug, info, warn, error. | `string` | `"info"` | no | +| [log\_retention\_days](#input\_log\_retention\_days) | CloudWatch Logs retention in days. | `number` | `30` | no | +| [memory\_size](#input\_memory\_size) | Lambda memory in MB. | `number` | `128` | no | +| [permissions](#input\_permissions) | Repository permissions requested on each minted token. Serialized to GITHUB\_TOKEN\_BROKER\_PERMISSIONS as JSON. | `map(string)` |
{
"contents": "read"
}
| no | +| [release\_repository](#input\_release\_repository) | GitHub repository to pull the release asset from when lambda\_artifact.release\_version is set. Defaults to the upstream repo. | `string` | `"meigma/github-token-broker"` | no | +| [repository\_name](#input\_repository\_name) | GitHub repository the broker issues tokens for. | `string` | n/a | yes | +| [repository\_owner](#input\_repository\_owner) | GitHub owner of the repository the broker issues tokens for. | `string` | n/a | yes | +| [ssm\_parameter\_paths](#input\_ssm\_parameter\_paths) | SSM parameter paths holding the GitHub App credentials. All paths must be absolute. |
object({
client_id = string
installation_id = string
private_key = string
})
|
{
"client_id": "/github-token-broker/app/client-id",
"installation_id": "/github-token-broker/app/installation-id",
"private_key": "/github-token-broker/app/private-key-pem"
}
| no | +| [tags](#input\_tags) | Tags applied to all resources created by this module. | `map(string)` | `{}` | no | +| [timeout](#input\_timeout) | Lambda execution timeout in seconds. | `number` | `10` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [deployed\_version](#output\_deployed\_version) | Release version actually deployed, or null when the module was pointed at a local zip or S3 source. | +| [function\_arn](#output\_function\_arn) | ARN of the Lambda function. | +| [function\_invoke\_arn](#output\_function\_invoke\_arn) | Invoke ARN, suitable for API Gateway or EventBridge integrations. | +| [function\_name](#output\_function\_name) | Name of the Lambda function. | +| [function\_url](#output\_function\_url) | Function URL when enable\_function\_url is true; null otherwise. | +| [log\_group\_name](#output\_log\_group\_name) | Name of the CloudWatch Log Group backing Lambda logs. | +| [role\_arn](#output\_role\_arn) | ARN of the Lambda execution role. | +| [role\_name](#output\_role\_name) | Name of the Lambda execution role. | + diff --git a/terraform/examples/basic/README.md b/terraform/examples/basic/README.md new file mode 100644 index 0000000..1c69600 --- /dev/null +++ b/terraform/examples/basic/README.md @@ -0,0 +1,27 @@ +# Basic example + +Smallest invocation of the module. Assumes the three SSM parameters already exist at the default paths under `/github-token-broker/app/`: + +- `/github-token-broker/app/client-id` +- `/github-token-broker/app/installation-id` +- `/github-token-broker/app/private-key-pem` (SecureString) + +## Usage + +```sh +cp terraform.tfvars.example terraform.tfvars +# edit terraform.tfvars with your values +tofu init # or: terraform init +tofu apply +``` + +The `gh` CLI must be installed and authenticated on the machine running `apply`; the module uses it to download the release asset. + +After apply, invoke the function: + +```sh +aws lambda invoke --function-name github-token-broker --payload '{}' --cli-binary-format raw-in-base64-out /tmp/out.json +cat /tmp/out.json +``` + +A healthy response is a JSON object with `token`, `expires_at`, `repositories`, and `permissions`. diff --git a/terraform/examples/basic/main.tf b/terraform/examples/basic/main.tf new file mode 100644 index 0000000..63d2858 --- /dev/null +++ b/terraform/examples/basic/main.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + } +} + +provider "aws" { + region = var.region +} + +module "broker" { + source = "../.." + + function_name = "github-token-broker" + repository_owner = var.repository_owner + repository_name = var.repository_name + + lambda_artifact = { + release_version = var.release_version + } +} diff --git a/terraform/examples/basic/outputs.tf b/terraform/examples/basic/outputs.tf new file mode 100644 index 0000000..de3bf02 --- /dev/null +++ b/terraform/examples/basic/outputs.tf @@ -0,0 +1,9 @@ +output "function_name" { + description = "Name of the deployed Lambda function." + value = module.broker.function_name +} + +output "function_arn" { + description = "ARN of the deployed Lambda function." + value = module.broker.function_arn +} diff --git a/terraform/examples/basic/terraform.tfvars.example b/terraform/examples/basic/terraform.tfvars.example new file mode 100644 index 0000000..af6fa98 --- /dev/null +++ b/terraform/examples/basic/terraform.tfvars.example @@ -0,0 +1,4 @@ +region = "us-east-1" +repository_owner = "example-org" +repository_name = "example-repo" +release_version = "v1.0.0" diff --git a/terraform/examples/basic/variables.tf b/terraform/examples/basic/variables.tf new file mode 100644 index 0000000..8201e2d --- /dev/null +++ b/terraform/examples/basic/variables.tf @@ -0,0 +1,19 @@ +variable "region" { + description = "AWS region." + type = string +} + +variable "repository_owner" { + description = "GitHub owner the broker issues tokens for." + type = string +} + +variable "repository_name" { + description = "GitHub repository the broker issues tokens for." + type = string +} + +variable "release_version" { + description = "Upstream release tag to deploy (e.g. v1.0.0)." + type = string +} diff --git a/terraform/examples/function-url/README.md b/terraform/examples/function-url/README.md new file mode 100644 index 0000000..c6ca784 --- /dev/null +++ b/terraform/examples/function-url/README.md @@ -0,0 +1,20 @@ +# Function URL example + +Provisions the broker with a Lambda Function URL protected by `AWS_IAM` authorization and an explicit `aws_lambda_permission` scoping which principal can call it. The module never creates `NONE`-auth URLs. + +## Usage + +```sh +cp terraform.tfvars.example terraform.tfvars +# edit terraform.tfvars — invoker_principal_arn must be the IAM role/user that will call the URL +tofu init +tofu apply +``` + +Callers must sign requests with SigV4; a typical caller uses the AWS SDK's Lambda URL signer or `awscurl`. + +```sh +awscurl --service lambda --region "$REGION" "$(tofu output -raw function_url)" +``` + +If you see `Forbidden`, confirm your caller identity matches `invoker_principal_arn` and that `aws_lambda_permission` propagated (a short eventual-consistency window is normal after first apply). diff --git a/terraform/examples/function-url/main.tf b/terraform/examples/function-url/main.tf new file mode 100644 index 0000000..c8d8e33 --- /dev/null +++ b/terraform/examples/function-url/main.tf @@ -0,0 +1,36 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + } +} + +provider "aws" { + region = var.region +} + +module "broker" { + source = "../.." + + function_name = "github-token-broker" + repository_owner = var.repository_owner + repository_name = var.repository_name + + lambda_artifact = { + release_version = var.release_version + } + + enable_function_url = true +} + +resource "aws_lambda_permission" "invoke_url" { + statement_id = "AllowInvokeFromCallerPrincipal" + action = "lambda:InvokeFunctionUrl" + function_name = module.broker.function_name + principal = var.invoker_principal_arn + function_url_auth_type = "AWS_IAM" +} diff --git a/terraform/examples/function-url/outputs.tf b/terraform/examples/function-url/outputs.tf new file mode 100644 index 0000000..186a192 --- /dev/null +++ b/terraform/examples/function-url/outputs.tf @@ -0,0 +1,9 @@ +output "function_url" { + description = "HTTPS endpoint for the Lambda Function URL." + value = module.broker.function_url +} + +output "function_arn" { + description = "ARN of the Lambda function." + value = module.broker.function_arn +} diff --git a/terraform/examples/function-url/terraform.tfvars.example b/terraform/examples/function-url/terraform.tfvars.example new file mode 100644 index 0000000..01d5073 --- /dev/null +++ b/terraform/examples/function-url/terraform.tfvars.example @@ -0,0 +1,5 @@ +region = "us-east-1" +repository_owner = "example-org" +repository_name = "example-repo" +release_version = "v1.0.0" +invoker_principal_arn = "arn:aws:iam::123456789012:role/invoker" diff --git a/terraform/examples/function-url/variables.tf b/terraform/examples/function-url/variables.tf new file mode 100644 index 0000000..525eeab --- /dev/null +++ b/terraform/examples/function-url/variables.tf @@ -0,0 +1,24 @@ +variable "region" { + description = "AWS region." + type = string +} + +variable "repository_owner" { + description = "GitHub owner the broker issues tokens for." + type = string +} + +variable "repository_name" { + description = "GitHub repository the broker issues tokens for." + type = string +} + +variable "release_version" { + description = "Upstream release tag to deploy (e.g. v1.0.0)." + type = string +} + +variable "invoker_principal_arn" { + description = "IAM principal ARN allowed to invoke the Function URL." + type = string +} diff --git a/terraform/examples/with-ssm-bootstrap/README.md b/terraform/examples/with-ssm-bootstrap/README.md new file mode 100644 index 0000000..26a6c70 --- /dev/null +++ b/terraform/examples/with-ssm-bootstrap/README.md @@ -0,0 +1,23 @@ +# Bootstrap SSM parameters alongside the broker + +First-time-setup example that can (optionally) create the three SSM parameters in the same apply as the Lambda. Split out into its own example so production users don't accidentally manage sensitive values through Terraform state. + +## Why this is separate + +The GitHub App private key is a secret. When `create_ssm_parameters = true`, the PEM value is passed through `aws_ssm_parameter` and ends up **in plaintext inside Terraform state**. That is acceptable for a first-time bootstrap in a non-production account, but it is **not** where production secrets should live. In production: + +- Create the parameters out-of-band (AWS Console, `aws ssm put-parameter`, SOPS, a secret-manager pipeline, etc.). +- Leave `create_ssm_parameters = false` (the default). +- Use `examples/basic/` to provision the Lambda against the pre-existing parameters. + +## Usage (first-time bootstrap) + +```sh +cp terraform.tfvars.example terraform.tfvars +# edit terraform.tfvars: set create_ssm_parameters = true and populate +# github_app_* values, ideally from an environment-injected source. +tofu init +tofu apply +``` + +After the first apply, remove the sensitive values from `terraform.tfvars`, set `create_ssm_parameters = false`, and import or re-home the parameters to an out-of-state workflow. diff --git a/terraform/examples/with-ssm-bootstrap/main.tf b/terraform/examples/with-ssm-bootstrap/main.tf new file mode 100644 index 0000000..6755a12 --- /dev/null +++ b/terraform/examples/with-ssm-bootstrap/main.tf @@ -0,0 +1,68 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + } +} + +provider "aws" { + region = var.region +} + +locals { + ssm_parameter_paths = { + client_id = "/github-token-broker/app/client-id" + installation_id = "/github-token-broker/app/installation-id" + private_key = "/github-token-broker/app/private-key-pem" + } +} + +resource "aws_ssm_parameter" "client_id" { + count = var.create_ssm_parameters ? 1 : 0 + + name = local.ssm_parameter_paths.client_id + type = "String" + value = var.github_app_client_id +} + +resource "aws_ssm_parameter" "installation_id" { + count = var.create_ssm_parameters ? 1 : 0 + + name = local.ssm_parameter_paths.installation_id + type = "String" + value = var.github_app_installation_id +} + +resource "aws_ssm_parameter" "private_key" { + count = var.create_ssm_parameters ? 1 : 0 + + name = local.ssm_parameter_paths.private_key + type = "SecureString" + value = var.github_app_private_key_pem + key_id = var.kms_key_id +} + +module "broker" { + source = "../.." + + function_name = "github-token-broker" + repository_owner = var.repository_owner + repository_name = var.repository_name + + lambda_artifact = { + release_version = var.release_version + } + + ssm_parameter_paths = local.ssm_parameter_paths + kms_key_arn = var.kms_key_arn + + depends_on = [ + aws_ssm_parameter.client_id, + aws_ssm_parameter.installation_id, + aws_ssm_parameter.private_key, + ] +} diff --git a/terraform/examples/with-ssm-bootstrap/outputs.tf b/terraform/examples/with-ssm-bootstrap/outputs.tf new file mode 100644 index 0000000..3f6231b --- /dev/null +++ b/terraform/examples/with-ssm-bootstrap/outputs.tf @@ -0,0 +1,9 @@ +output "function_arn" { + description = "ARN of the deployed Lambda function." + value = module.broker.function_arn +} + +output "function_name" { + description = "Name of the deployed Lambda function." + value = module.broker.function_name +} diff --git a/terraform/examples/with-ssm-bootstrap/terraform.tfvars.example b/terraform/examples/with-ssm-bootstrap/terraform.tfvars.example new file mode 100644 index 0000000..982ee0e --- /dev/null +++ b/terraform/examples/with-ssm-bootstrap/terraform.tfvars.example @@ -0,0 +1,17 @@ +region = "us-east-1" +repository_owner = "example-org" +repository_name = "example-repo" +release_version = "v1.0.0" + +# Leave create_ssm_parameters=false in production. Enable only for +# first-time bootstrap in a non-production account. +create_ssm_parameters = false +# github_app_client_id = "Iv23li..." +# github_app_installation_id = "12345678" +# github_app_private_key_pem = </dev/null 2>&1; then + echo "error: gh CLI is required to fetch the release asset. Install it or pre-download and pass lambda_zip_path." >&2 + exit 1 + fi + + if ! command -v sha256sum >/dev/null 2>&1; then + echo "error: sha256sum is required to verify the release asset." >&2 + exit 1 + fi + + gh release download "$version" \ + --repo "$repo" \ + --pattern github-token-broker.zip \ + --pattern checksums.txt \ + --dir "$dir" \ + --clobber + + (cd "$dir" && sha256sum --check --status checksums.txt) + EOT + } +} + +resource "aws_cloudwatch_log_group" "lambda" { + name = local.log_group_name + retention_in_days = var.log_retention_days == 0 ? null : var.log_retention_days + tags = local.module_tags +} + +resource "aws_lambda_function" "broker" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + runtime = "provided.al2023" + handler = "bootstrap" + architectures = [var.architecture] + memory_size = var.memory_size + timeout = var.timeout + + filename = local.lambda_filename + source_code_hash = local.lambda_source_code_hash + + s3_bucket = local.use_s3_source ? var.lambda_artifact.lambda_source_s3.bucket : null + s3_key = local.use_s3_source ? var.lambda_artifact.lambda_source_s3.key : null + + environment { + variables = local.environment + } + + tags = local.module_tags + + depends_on = [ + null_resource.fetch_release, + aws_cloudwatch_log_group.lambda, + aws_iam_role_policy.lambda, + ] +} + +resource "aws_lambda_function_url" "broker" { + count = var.enable_function_url ? 1 : 0 + + function_name = aws_lambda_function.broker.function_name + authorization_type = "AWS_IAM" +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..536ae8a --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,39 @@ +output "function_arn" { + description = "ARN of the Lambda function." + value = aws_lambda_function.broker.arn +} + +output "function_name" { + description = "Name of the Lambda function." + value = aws_lambda_function.broker.function_name +} + +output "function_invoke_arn" { + description = "Invoke ARN, suitable for API Gateway or EventBridge integrations." + value = aws_lambda_function.broker.invoke_arn +} + +output "function_url" { + description = "Function URL when enable_function_url is true; null otherwise." + value = try(aws_lambda_function_url.broker[0].function_url, null) +} + +output "role_arn" { + description = "ARN of the Lambda execution role." + value = aws_iam_role.lambda.arn +} + +output "role_name" { + description = "Name of the Lambda execution role." + value = aws_iam_role.lambda.name +} + +output "log_group_name" { + description = "Name of the CloudWatch Log Group backing Lambda logs." + value = aws_cloudwatch_log_group.lambda.name +} + +output "deployed_version" { + description = "Release version actually deployed, or null when the module was pointed at a local zip or S3 source." + value = try(var.lambda_artifact.release_version, null) +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..b4b35f4 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,211 @@ +variable "function_name" { + description = "Name of the Lambda function." + type = string + + validation { + condition = length(var.function_name) > 0 && length(var.function_name) <= 64 + error_message = "function_name must be between 1 and 64 characters." + } +} + +variable "repository_owner" { + description = "GitHub owner of the repository the broker issues tokens for." + type = string + + validation { + condition = length(trimspace(var.repository_owner)) > 0 + error_message = "repository_owner must be non-empty." + } +} + +variable "repository_name" { + description = "GitHub repository the broker issues tokens for." + type = string + + validation { + condition = length(trimspace(var.repository_name)) > 0 + error_message = "repository_name must be non-empty." + } +} + +variable "lambda_artifact" { + description = <<-EOT + Source of the Lambda zip. Exactly one of the three fields must be set: + + - `release_version`: a tag published on `release_repository` (e.g. "v1.0.0"). + The module downloads `github-token-broker.zip` and `checksums.txt` via the + `gh` CLI on the machine running `terraform apply`, verifies the zip's + SHA256 against `checksums.txt`, and points the Lambda at the cached copy. + - `lambda_zip_path`: absolute path to a pre-downloaded zip. Used for + air-gapped workflows where `gh` is unavailable at apply time. + - `lambda_source_s3`: S3 bucket/key holding the zip. Used when the zip is + staged to S3 out-of-band (e.g. by CI). + + Inline SHA256 verification is defense-in-depth against a corrupted + download. It is NOT a replacement for `gh attestation verify`, which is + the canonical supply-chain check. See `terraform/README.md` for guidance. + EOT + + type = object({ + release_version = optional(string) + lambda_zip_path = optional(string) + lambda_source_s3 = optional(object({ + bucket = string + key = string + })) + }) + + validation { + condition = length(compact([ + try(var.lambda_artifact.release_version, null), + try(var.lambda_artifact.lambda_zip_path, null), + try(var.lambda_artifact.lambda_source_s3 == null ? null : "s3", null), + ])) == 1 + error_message = "lambda_artifact must set exactly one of release_version, lambda_zip_path, or lambda_source_s3." + } + + validation { + condition = ( + try(var.lambda_artifact.release_version, null) == null || + can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.-]+)?$", var.lambda_artifact.release_version)) + ) + error_message = "lambda_artifact.release_version must be a semver tag such as \"v1.0.0\" or \"1.2.3-rc1\"." + } + + validation { + condition = ( + try(var.lambda_artifact.lambda_zip_path, null) == null || + length(trimspace(var.lambda_artifact.lambda_zip_path)) > 0 + ) + error_message = "lambda_artifact.lambda_zip_path must be non-empty when set." + } +} + +variable "release_repository" { + description = "GitHub repository to pull the release asset from when lambda_artifact.release_version is set. Defaults to the upstream repo." + type = string + default = "meigma/github-token-broker" +} + +variable "permissions" { + description = "Repository permissions requested on each minted token. Serialized to GITHUB_TOKEN_BROKER_PERMISSIONS as JSON." + type = map(string) + default = { contents = "read" } + + validation { + condition = length(var.permissions) > 0 + error_message = "permissions must request at least one permission." + } + + validation { + condition = alltrue([ + for k, v in var.permissions : length(trimspace(k)) > 0 && length(trimspace(v)) > 0 + ]) + error_message = "permissions entries must have non-empty keys and values." + } +} + +variable "ssm_parameter_paths" { + description = "SSM parameter paths holding the GitHub App credentials. All paths must be absolute." + type = object({ + client_id = string + installation_id = string + private_key = string + }) + default = { + client_id = "/github-token-broker/app/client-id" + installation_id = "/github-token-broker/app/installation-id" + private_key = "/github-token-broker/app/private-key-pem" + } + + validation { + condition = alltrue([ + startswith(var.ssm_parameter_paths.client_id, "/"), + startswith(var.ssm_parameter_paths.installation_id, "/"), + startswith(var.ssm_parameter_paths.private_key, "/"), + ]) + error_message = "ssm_parameter_paths entries must be absolute (start with /)." + } +} + +variable "github_api_base_url" { + description = "GitHub API base URL. Override for GitHub Enterprise Server." + type = string + default = "https://api.github.com" +} + +variable "log_level" { + description = "Slog level. One of debug, info, warn, error." + type = string + default = "info" + + validation { + condition = contains(["debug", "info", "warn", "error"], var.log_level) + error_message = "log_level must be one of debug, info, warn, error." + } +} + +variable "architecture" { + description = "Lambda architecture. arm64 matches the published release zip." + type = string + default = "arm64" + + validation { + condition = contains(["arm64", "x86_64"], var.architecture) + error_message = "architecture must be arm64 or x86_64." + } +} + +variable "memory_size" { + description = "Lambda memory in MB." + type = number + default = 128 + + validation { + condition = var.memory_size >= 128 && var.memory_size <= 10240 + error_message = "memory_size must be between 128 and 10240 MB." + } +} + +variable "timeout" { + description = "Lambda execution timeout in seconds." + type = number + default = 10 + + validation { + condition = var.timeout >= 1 && var.timeout <= 900 + error_message = "timeout must be between 1 and 900 seconds." + } +} + +variable "log_retention_days" { + description = "CloudWatch Logs retention in days." + type = number + default = 30 + + validation { + condition = contains( + [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 2192, 2557, 2922, 3288, 3653, 0], + var.log_retention_days, + ) + error_message = "log_retention_days must be one of the values accepted by CloudWatch Logs, or 0 for never expire." + } +} + +variable "tags" { + description = "Tags applied to all resources created by this module." + type = map(string) + default = {} +} + +variable "enable_function_url" { + description = "Create a Lambda Function URL with AWS_IAM auth. Never creates a NONE-auth URL." + type = bool + default = false +} + +variable "kms_key_arn" { + description = "KMS key ARN used by SSM to encrypt the private key parameter. Set only when the customer uses a CMK instead of the AWS-managed key. Null disables kms:Decrypt in the role policy." + type = string + default = null +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..dce41f5 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.2" + } + } +} From 407c106f0b203cabd4543bc7c1da5ae0872681a5 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 23 Apr 2026 12:06:25 -0700 Subject: [PATCH 2/2] docs(terraform): regenerate README with v0.20.0 to match CI The terraform-docs CI action (v1.4.1) bundles terraform-docs v0.20.0; v0.20.0 emits the version constraint in the Providers table, while v0.21.0 emits the resolved pinned version. Regenerated locally with v0.20.0 so the docs sync check passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- terraform/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/README.md b/terraform/README.md index 5e2f7e4..4bddba2 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -108,8 +108,8 @@ Switch to `lambda_zip_path` or `lambda_source_s3` if the apply host cannot satis | Name | Version | |------|---------| -| [aws](#provider\_aws) | 6.42.0 | -| [null](#provider\_null) | 3.2.4 | +| [aws](#provider\_aws) | >= 5.0, < 7.0 | +| [null](#provider\_null) | >= 3.2 | ## Modules