Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/scaleway-auth-check.yml
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 }}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
Comment thread
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 }}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
secret-key: ${{ secrets.SCW_SECRET_KEY }}
Comment thread
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
1 change: 1 addition & 0 deletions .github/workflows/terraform-lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
- state-backend
- cluster/local
- cluster/scaleway
- github-ci
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The `.terraform.lock.hcl` in each root must cover **both** `darwin_arm64` (local
terraform -chdir=state-backend providers lock -platform=darwin_arm64 -platform=linux_amd64
terraform -chdir=cluster/local providers lock -platform=darwin_arm64 -platform=linux_amd64
terraform -chdir=cluster/scaleway providers lock -platform=darwin_arm64 -platform=linux_amd64
terraform -chdir=github-ci providers lock -platform=darwin_arm64 -platform=linux_amd64
```

Commit the updated lock files alongside the version change.
Expand Down Expand Up @@ -64,6 +65,10 @@ Terraform here is only a **one-time bootstrapper** — everything after ArgoCD i

Same bootstrap pattern as `local/`, plus the Kapsule cluster + node pool (`DEV1-M`, min=0/max=3) in one consolidated module. Writes the kubeconfig to `~/.kube/scaleway-homelab.yaml`. Scaleway credentials are read from the `scw` CLI config (`~/.config/scw/config.yaml`), not from tfvars. Still early — intentionally undocumented in the commands above for now.

### `github-ci/`

Standalone root (not under `cluster/` — provisions no cluster) that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `github-ci/README.md`.

## Conventions

**Branches**: `<type>/<description>` — lowercase, hyphens only. Types: `feature/`, `bugfix/`, `hotfix/`, `ci/`, `chore/`.
Expand Down
70 changes: 70 additions & 0 deletions github-ci/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 132 additions & 0 deletions github-ci/README.md
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.
72 changes: 72 additions & 0 deletions github-ci/main.tf
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
}
11 changes: 11 additions & 0 deletions github-ci/outputs.tf
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
}
Loading
Loading