-
Notifications
You must be signed in to change notification settings - Fork 0
feat: authenticate GitHub Actions to Scaleway via a scoped IAM key #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c1cdd2f
feat(github-ci): scoped Scaleway IAM key for GitHub Actions
9b8fdef
ci(github-ci): trigger auth check on PRs + scope to scaleway environment
4103af0
fix(github-ci): set required API key expiry via time_rotating
3237d83
fix(github-ci): create the Infisical CI folder before writing secrets
d047c79
fix(github-ci): pass org/project ID to the scw auth check
4a6ba34
ci(github-ci): drop workflow_dispatch + move org/project IDs to repo …
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| name: Scaleway Auth Check | ||
|
|
||
| # Validates the github-ci/ Scaleway IAM key end to end: authenticate with the | ||
| # scoped API key from GitHub secrets and list the fr-par Object Storage buckets. | ||
| # This is a smoke test, not part of any pipeline yet. | ||
| # | ||
| # Runs on PRs that touch the CI identity so it can be validated before merge. | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - '.github/workflows/scaleway-auth-check.yml' | ||
| - 'github-ci/**' | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| list-buckets: | ||
| runs-on: ubuntu-latest | ||
| # Reference a dedicated environment so secret usage is scoped to it | ||
| # (satisfies zizmor's secrets-outside-env audit, and gives a place to attach | ||
| # protection rules later). Auto-created on first run with no protection. | ||
| environment: scaleway | ||
| steps: | ||
| # Fail fast and clearly if the secrets aren't set, instead of letting the | ||
| # CLI emit a confusing auth error (or worse, appear to pass). | ||
| - name: Assert credentials are present | ||
| env: | ||
| SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} | ||
| SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| run: | | ||
| if [ -z "$SCW_ACCESS_KEY" ] || [ -z "$SCW_SECRET_KEY" ]; then | ||
| echo "::error::SCW_ACCESS_KEY and/or SCW_SECRET_KEY are not set. Run 'gh secret set' (see github-ci/README.md)." | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: List fr-par Object Storage buckets | ||
| uses: scaleway/action-scw@2e34a1eb35cf3cac627f24643a101fea269cbd83 # v0.0.3 | ||
| with: | ||
| version: v2.41.0 | ||
| access-key: ${{ secrets.SCW_ACCESS_KEY }} | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| secret-key: ${{ secrets.SCW_SECRET_KEY }} | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| # Public identifiers (not credentials) the scw CLI wants even for a | ||
| # project-scoped key. Repo variables, not secrets. | ||
| default-organization-id: ${{ vars.SCW_DEFAULT_ORGANIZATION_ID }} | ||
| default-project-id: ${{ vars.SCW_DEFAULT_PROJECT_ID }} | ||
| args: object bucket list region=fr-par | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # github-ci | ||
|
|
||
| A standalone Terraform root that provisions the **Scaleway identity GitHub | ||
| Actions uses to authenticate to Scaleway**. First real consumer: a smoke-test | ||
| workflow that lists Object Storage buckets; the Terraform CI/CD pipeline itself | ||
| is a separate, later concern. | ||
|
|
||
| This is **not** under `cluster/` — it provisions no cluster. It's a CI-platform | ||
| concern, kept as its own root so its state and blast radius stay small. | ||
|
|
||
| ## Why a static key and not OIDC | ||
|
|
||
| The ideal flow would be **keyless GitHub-OIDC → Scaleway** (GitHub mints a | ||
| short-lived OIDC token, Scaleway trades it for temporary credentials, no | ||
| long-lived secret). **This is not possible today**: Scaleway IAM is not an OIDC | ||
| relying party — the IAM API exposes only API keys, SSH keys, SAML SSO, SCIM and | ||
| an internal user-session JWT. The feature request for it is still open: | ||
|
|
||
| - https://feature-request.scaleway.com/posts/761/oidc-provider-for-external-ci-cd | ||
|
|
||
| So we use Scaleway's supported pattern — a dedicated, least-privilege **API | ||
| key** — and mitigate the long-lived-secret risk with: | ||
|
|
||
| - **Least privilege** — `ObjectStorageReadOnly`, scoped to a single project. | ||
| - **A dedicated, independently-revocable identity** — its own IAM application, so | ||
| it can be rotated/revoked without touching anything else. | ||
|
|
||
| Revisit OIDC if/when Scaleway ships it (see the link above). | ||
|
|
||
| ## What it creates | ||
|
|
||
| - `scaleway_iam_application.github_ci` — the CI identity. | ||
| - `scaleway_iam_policy.github_ci` — `permission_set_names = ["ObjectStorageReadOnly"]`, | ||
| scoped to `var.project_id` (and **no** broader set). | ||
| - `scaleway_iam_api_key.github_ci` — the API key for that application, with | ||
| `default_project_id` baked in so `scw object bucket list` resolves the right | ||
| scope without the workflow passing a project ID. The org enforces an expiry on | ||
| every key, so `time_rotating.api_key` drives `expires_at` (default 365 days, | ||
| `var.api_key_rotation_days`) and rotates the key on the next apply after it | ||
| lapses — see [Rotation / revocation](#rotation--revocation). | ||
| - `infisical_secret.scw_access_key` / `infisical_secret.scw_secret_key` — the key | ||
| written into Infisical (env `staging`, folder `/ci` by default). The secret half | ||
| is Terraform-`sensitive`; it's never printed or committed (state-only, per the | ||
| repo's bootstrap model). | ||
|
|
||
| ## Credentials | ||
|
|
||
| Same as the other roots: | ||
|
|
||
| - **Scaleway** provider reads creds + default region/project from the **scw CLI | ||
| config** (`~/.config/scw/config.yaml`). | ||
| - **Infisical** provider authenticates via a **universal-auth machine identity**; | ||
| its `client_id` / `client_secret` come from `*.auto.tfvars` (per-developer, | ||
| gitignored — see `nico.auto.tfvars`). | ||
| - The **S3 state backend** authenticates with AWS-style env vars derived from the | ||
| scw config; `mise.toml`'s `[env]` block injects them automatically under mise. | ||
|
|
||
| ## Apply | ||
|
|
||
| ```bash | ||
| mise run github-ci-plan # terraform init && plan — review first | ||
| mise run github-ci-apply # terraform apply (billable: creates an IAM key) | ||
| ``` | ||
|
|
||
| > Never `terraform apply`/`destroy` here without explicit approval. | ||
|
|
||
| After apply, the access key is an output and both halves are in Infisical. | ||
|
|
||
| ## Wiring the GitHub secrets (manual) | ||
|
|
||
| Automating the Infisical → GitHub push is deferred (it'd mean adding a GitHub | ||
| token to this bootstrap). For now, set the two repo secrets by hand. Read the | ||
| values straight out of the Terraform state/output and Infisical — **don't paste | ||
| them into your shell history or echo them**: | ||
|
|
||
| ```bash | ||
| # SCW_ACCESS_KEY is a public identifier, exposed as a Terraform output: | ||
| gh secret set SCW_ACCESS_KEY \ | ||
| --repo IntegratedDynamic/infrastructure \ | ||
| --body "$(terraform -chdir=github-ci output -raw access_key)" | ||
|
|
||
| # SCW_SECRET_KEY is sensitive — pipe it from the API key resource without printing: | ||
| gh secret set SCW_SECRET_KEY \ | ||
| --repo IntegratedDynamic/infrastructure \ | ||
| --body "$(terraform -chdir=github-ci state show -no-color scaleway_iam_api_key.github_ci \ | ||
| | awk '/secret_key/ {print $3; exit}' | tr -d '\"')" | ||
| ``` | ||
|
|
||
| (Or copy the secret from Infisical → `staging` → `/ci` → `SCW_SECRET_KEY` and | ||
| `gh secret set SCW_SECRET_KEY --repo IntegratedDynamic/infrastructure` reading | ||
| from stdin.) | ||
|
|
||
| ## Verify end to end | ||
|
|
||
| The smoke-test workflow (`.github/workflows/scaleway-auth-check.yml`) runs | ||
| `scw object bucket list region=fr-par` against the key. It triggers on any PR | ||
| that touches `github-ci/**` or the workflow itself, so you can validate before | ||
| merge. | ||
|
|
||
| It needs two **repo variables** (public identifiers, not secrets — the scw CLI | ||
| wants them even for a project-scoped key). Set once: | ||
|
|
||
| ```bash | ||
| gh variable set SCW_DEFAULT_ORGANIZATION_ID --repo IntegratedDynamic/infrastructure --body "<org-id>" | ||
| gh variable set SCW_DEFAULT_PROJECT_ID --repo IntegratedDynamic/infrastructure --body "<project-id>" | ||
| ``` | ||
|
|
||
| So the validation order is: `apply` → `gh secret set` (above) → push the branch / | ||
| re-run the PR check. The job runs in the `scaleway` GitHub Environment (so its | ||
| secret usage is scoped — secrets can be set at repo or environment level; the | ||
| repo-level commands above work either way). It fails clearly if the secrets are | ||
| missing, so a green run means real authentication succeeded. | ||
|
|
||
| ## Rotation / revocation | ||
|
|
||
| The API key lives entirely in this root's state. | ||
|
|
||
| - **Automatic** — `time_rotating.api_key` expires the key after | ||
| `var.api_key_rotation_days` (default 365). Once that window lapses, the next | ||
| `terraform apply` rolls the expiry forward, which (since `expires_at` is | ||
| ForceNew) creates fresh key material. | ||
| - **On demand** — force it early with: | ||
|
|
||
| ```bash | ||
| terraform -chdir=github-ci apply -replace=scaleway_iam_api_key.github_ci | ||
| ``` | ||
|
|
||
| Either way the key material changes, so **re-run the `gh secret set` steps above** | ||
| afterwards (the Infisical copies update automatically; the GitHub secrets don't). | ||
|
|
||
| To kill access entirely, destroy the application (revokes the key) — but mind | ||
| that any workflow depending on it will start failing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # Dedicated, least-privilege identity for GitHub Actions to authenticate to | ||
| # Scaleway. The keyless GitHub-OIDC -> Scaleway flow isn't possible yet (Scaleway | ||
| # IAM is not an OIDC relying party — see README), so we use Scaleway's supported | ||
| # pattern: a scoped, independently-revocable API key consumed from GH secrets. | ||
|
|
||
| resource "scaleway_iam_application" "github_ci" { | ||
| name = "github-ci" | ||
| description = "GitHub Actions CI for the IntegratedDynamic/infrastructure repo (managed by terraform: github-ci/)." | ||
| } | ||
|
|
||
| # Least privilege: read-only Object Storage, scoped to a single project. The | ||
| # future Terraform-CI identity gets its own broader policy (out of scope here). | ||
| resource "scaleway_iam_policy" "github_ci" { | ||
| name = "github-ci-object-storage-ro" | ||
| description = "Read-only Object Storage for the GitHub Actions CI application, project-scoped." | ||
| application_id = scaleway_iam_application.github_ci.id | ||
|
|
||
| rule { | ||
| project_ids = [var.project_id] | ||
| permission_set_names = ["ObjectStorageReadOnly"] | ||
| } | ||
| } | ||
|
|
||
| # The org enforces an expiry on every API key, and `expires_at` is ForceNew, so | ||
| # the key inherently rotates when the expiry moves. time_rotating makes that | ||
| # concrete and self-renewing: the timestamp holds steady until the window | ||
| # elapses, then the next apply pushes it forward and rotates the key (re-run | ||
| # `gh secret set` afterwards — see README). | ||
| resource "time_rotating" "api_key" { | ||
| rotation_days = var.api_key_rotation_days | ||
| } | ||
|
|
||
| resource "scaleway_iam_api_key" "github_ci" { | ||
| application_id = scaleway_iam_application.github_ci.id | ||
| description = "Consumed from GitHub Actions secrets (SCW_ACCESS_KEY / SCW_SECRET_KEY)." | ||
|
|
||
| # Bakes the project into the key so `scw object bucket list` resolves the right | ||
| # scope without the workflow passing a project ID. | ||
| default_project_id = var.project_id | ||
|
|
||
| expires_at = time_rotating.api_key.rotation_rfc3339 | ||
| } | ||
|
|
||
| # ── Write the key into Infisical ──────────────────────────────────────────── | ||
| # GitHub secrets themselves are still set manually via `gh secret set` (see | ||
| # README) — automating that push is deferred to avoid a GitHub token here. | ||
|
|
||
| # infisical_secret does not create missing folders, so the CI folder must exist | ||
| # first. var.infisical_folder_path is "/<name>"; create that name under root. | ||
| resource "infisical_secret_folder" "ci" { | ||
| project_id = var.infisical_workspace_id | ||
| environment_slug = var.infisical_env_slug | ||
| folder_path = "/" | ||
| name = trimprefix(var.infisical_folder_path, "/") | ||
| description = "CI secrets for GitHub Actions (managed by terraform: github-ci/)." | ||
| } | ||
|
|
||
| resource "infisical_secret" "scw_access_key" { | ||
| name = "SCW_ACCESS_KEY" | ||
| value = scaleway_iam_api_key.github_ci.access_key | ||
| env_slug = var.infisical_env_slug | ||
| workspace_id = var.infisical_workspace_id | ||
| folder_path = infisical_secret_folder.ci.path | ||
| } | ||
|
|
||
| resource "infisical_secret" "scw_secret_key" { | ||
| name = "SCW_SECRET_KEY" | ||
| value = scaleway_iam_api_key.github_ci.secret_key | ||
| env_slug = var.infisical_env_slug | ||
| workspace_id = var.infisical_workspace_id | ||
| folder_path = infisical_secret_folder.ci.path | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| output "application_id" { | ||
| description = "IAM application ID backing the GitHub Actions CI identity." | ||
| value = scaleway_iam_application.github_ci.id | ||
| } | ||
|
|
||
| # The access key is a public identifier (like an AWS access key ID), so it's safe | ||
| # to surface. The secret half is never output — read it from Infisical or state. | ||
| output "access_key" { | ||
| description = "SCW_ACCESS_KEY for the CI identity (public identifier)." | ||
| value = scaleway_iam_api_key.github_ci.access_key | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.