Skip to content
Draft
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
40 changes: 35 additions & 5 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ jobs:
- test: TestPluginConfig
system_plugins: sftp
# Installs no cloud plugin: the oidc-credential fixtures (a stub
# broker and a resource plugin that exchanges the token it is
# handed) are built from this repo and staged by `make test-e2e`
# itself. It does need AWS credentials — the broker reads its
# signing key from Secrets Manager and the plugin calls
# sts:AssumeRoleWithWebIdentity against the standing issuer.
# broker, a resource plugin that exchanges the token it is handed,
# and a stub hosted auth plugin) are built from this repo and staged
# by `make test-e2e` itself. It does need AWS credentials, and the
# `e2e-test` shared-config profile written above: `formae connect`
# provisions the role with it, the broker reads its signing key from
# Secrets Manager, and the plugin calls sts:AssumeRoleWithWebIdentity
# against the standing issuer.
- test: TestOidcCredential

steps:
Expand Down Expand Up @@ -219,10 +221,19 @@ jobs:
output-credentials: true

- name: Add profile credentials to ~/.aws/credentials
# The session token is not optional: these are temporary credentials
# from an assumed role, and the key pair alone is refused with
# InvalidClientTokenId. It went unnoticed while nothing read the
# profile as a profile — the action also exports the three values as
# job env vars, the SDK's default chain prefers those over shared
# config, and the access test below passes no --profile. `formae
# connect --profile-aws` forces the shared profile, so it is the first
# thing here that authenticates through this file.
run: |
mkdir -p ~/.aws
aws configure set aws_access_key_id ${{ steps.creds.outputs.aws-access-key-id }} --profile e2e-test
aws configure set aws_secret_access_key ${{ steps.creds.outputs.aws-secret-access-key }} --profile e2e-test
aws configure set aws_session_token ${{ steps.creds.outputs.aws-session-token }} --profile e2e-test
aws configure set region us-west-2 --profile e2e-test

- name: Test AWS Access
Expand All @@ -238,6 +249,18 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

# Only the oidc-credential entry federates into GCP, and only it pays
# for the sign-in. Gated on the secret as well as the test, so a fork
# without credentials reports the GCP half as skipped rather than red.
- name: Configure GCP Credentials
if: ${{ matrix.test == 'TestOidcCredential' && env.GCP_WORKLOAD_IDENTITY_PROVIDER != '' }}
env:
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

- name: Install system plugins via orbital
if: ${{ matrix.system_plugins }}
run: |
Expand Down Expand Up @@ -327,6 +350,13 @@ jobs:
env:
AWS_PROFILE: e2e-test
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# The project the oidc-credential suite federates into. Its own
# project, not the one the GCP plugin's suites use: provx fixes the
# workload identity pool and provider ids per project and refuses to
# converge a provider trusting a different issuer, so a project
# already carrying the production-issuer connection cannot carry the
# e2e's as well. Absent, the GCP half skips.
E2E_GCP_PROJECT: ${{ secrets.GCP_PROJECT_ID }}
# Unique per run+attempt so fixtures can name globally-scoped cloud
# resources (e.g. CloudFront KeyValueStore/Function, which aws-nuke
# does not clean) without colliding across reruns or concurrent jobs.
Expand Down
23 changes: 17 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -267,20 +267,23 @@ test-e2e: build stage-oidc-fixtures
E2E_FORMAE_BINARY=$(CURDIR)/dist/e2e/bin/formae \
E2E_OIDC_PLUGIN_DIR=$(OIDC_STAGE_DIR) \
E2E_OIDC_PLUGIN_DIR_NO_BROKER=$(OIDC_STAGE_DIR_NO_BROKER) \
E2E_OIDC_AUTH_PLUGIN_DIR=$(OIDC_STAGE_DIR_AUTH) \
go test -C ./tests/e2e/go -tags=e2e -timeout 30m -v ./... $(E2E_RUN_FLAGS)

# The hermetic oidc-credential fixtures: a stub credential broker and a resource
# plugin that echoes the token the broker mints. Staged into two plugin trees
# the e2e agent can be pointed at — one where the broker sits beside the echo
# plugin, one where the echo plugin is alone — in the layout plugin discovery
# expects (<dir>/<name>/v<version>/<name> beside the manifest).
# The oidc-credential fixtures: a stub credential broker, a resource plugin that
# echoes the token the broker mints, and a stub hosted auth plugin. Staged into
# three plugin trees — one where the broker sits beside the echo plugin, one
# where the echo plugin is alone, and one holding only the auth plugin, which the
# CLI reads rather than the agent — in the layout plugin discovery expects
# (<dir>/<name>/v<version>/<name> beside the manifest).
OIDC_FIXTURE_DIR := $(CURDIR)/tests/e2e/go/fixtures
OIDC_STAGE_DIR := $(CURDIR)/dist/e2e/oidc-plugins
OIDC_STAGE_DIR_NO_BROKER := $(CURDIR)/dist/e2e/oidc-plugins-no-broker
OIDC_STAGE_DIR_AUTH := $(CURDIR)/dist/e2e/oidc-auth-plugin

stage-oidc-fixtures:
@echo "Staging e2e oidc-credential fixtures..."
rm -rf $(OIDC_STAGE_DIR) $(OIDC_STAGE_DIR_NO_BROKER)
rm -rf $(OIDC_STAGE_DIR) $(OIDC_STAGE_DIR_NO_BROKER) $(OIDC_STAGE_DIR_AUTH)
mkdir -p $(OIDC_STAGE_DIR)/oidc-credential-stub/v0.0.1
go build -C $(OIDC_FIXTURE_DIR)/oidc-credential-stub \
-o $(OIDC_STAGE_DIR)/oidc-credential-stub/v0.0.1/oidc-credential-stub .
Expand All @@ -295,6 +298,14 @@ stage-oidc-fixtures:
$(OIDC_STAGE_DIR)/oidc-echo/v0.0.1/schema/pkl
mkdir -p $(OIDC_STAGE_DIR_NO_BROKER)
cp -R $(OIDC_STAGE_DIR)/oidc-echo $(OIDC_STAGE_DIR_NO_BROKER)/oidc-echo
# Installed under the name `oidc` rather than the fixture directory's name:
# the CLI resolves an auth plugin by the `type` its profile's auth block
# names, and the connect gate admits only that one.
mkdir -p $(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1
go build -C $(OIDC_FIXTURE_DIR)/oidc-auth-stub \
-o $(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1/oidc .
cp $(OIDC_FIXTURE_DIR)/oidc-auth-stub/formae-plugin.pkl \
$(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1/formae-plugin.pkl

## test-property: Run property tests (FullChaos 100 iterations, others 50)
test-property:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ require (
github.com/platform-engineering-labs/formae/pkg/plugin v0.0.0-00010101000000-000000000000
github.com/platform-engineering-labs/formae/tests/testcontrol v0.0.0-00010101000000-000000000000
github.com/platform-engineering-labs/jsonpatch v0.0.0-20260620044942-701436c7758c
github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b // re-pin to a real tag at release time
github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591 // re-pin to a real tag at release time
github.com/platform-engineering-labs/orbital v0.2.5
github.com/posthog/posthog-go v1.6.3
github.com/pressly/goose/v3 v3.26.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ github.com/platform-engineering-labs/oox/gcpname v0.0.0-20260825170105-3bd97cb18
github.com/platform-engineering-labs/oox/gcpname v0.0.0-20260825170105-3bd97cb18d15/go.mod h1:wytVDQEOgo/0PLmMj0nOnisBzzqwemanNy9gpueH9Ik=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b h1:Qh9/9eWqFTK8w/yYHooGVOuRU5ep17Ws7iRVJvthRDg=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b/go.mod h1:6dTkhIgTXyXHctVmK16kkM2qnfaX/FOT0aWA+9oeJtM=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591 h1:wr3lRnfa/LRXia1C/VYcqMU4Tkjo4dnSSD+EAiGgRkA=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591/go.mod h1:6dTkhIgTXyXHctVmK16kkM2qnfaX/FOT0aWA+9oeJtM=
github.com/platform-engineering-labs/orbital v0.2.5 h1:7rihasWnR68ORytqLubR5WMJchv/mxoWSCB7MqaB9tU=
github.com/platform-engineering-labs/orbital v0.2.5/go.mod h1:wwVPqOmW5RO78dDzL/G5CN1Ioao0P/aUsOZVrPVx64s=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/connect/gcpprovision.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ type gcpProvisioner interface {
}

// newGCPProvisioner is the seam tests substitute. Production constructs provx
// with the server-produced subject verbatim: connect has no naming knowledge
// of its own, and inventing a subject here would produce trust the issuer
// never mints for.
// with the server-produced subject and the pinned issuer verbatim: connect has
// no naming knowledge of its own, and inventing either here would produce
// trust the issuer never mints for.
var newGCPProvisioner = func(ctx context.Context, project, subject, issuer string) (gcpProvisioner, error) {
tenantID, installationID, err := splitSubject(subject)
if err != nil {
return nil, err
}
return provxgcp.New(ctx, slog.Default(), project, tenantID, installationID)
return provxgcp.New(ctx, slog.Default(), project, tenantID, installationID, issuer)
}

// provisionGCP converges the project's federation and reports what it created.
Expand Down
16 changes: 10 additions & 6 deletions tests/e2e/config/nuke-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,16 @@ presets:
# resource types are protected by default (allowlist semantics).
#
# The same allowlist is what keeps the standing oidc-credential fixtures
# alive: the static issuer bucket, its IAM OIDC provider, the role the
# e2e assumes, and the Secrets Manager secret holding the signing key are
# all named e2e-oidc-*, contain no "formae-e2e", and are therefore never
# removed. They are provisioned once, out of band, from the forma in
# tests/e2e/config/oidc-standing/. Do not turn this preset into a
# denylist without re-homing them first.
# alive: the static issuer bucket, its IAM OIDC provider, and the Secrets
# Manager secret holding the signing key are all named e2e-oidc-*, contain
# no "formae-e2e", and are therefore never removed. They are provisioned
# once, out of band, from the forma in tests/e2e/config/oidc-standing/. Do
# not turn this preset into a denylist without re-homing them first.
#
# The role that suite's token is exchanged for is NOT standing: `formae
# connect` provisions it per run as formae-e2e-oidc-connect-role, which is
# inside the allowlist and inside the leftover-role purge above, so a run
# that dies before its teardown is reclaimed rather than accumulating.
filters:
__global__:
- property: Name
Expand Down
49 changes: 15 additions & 34 deletions tests/e2e/config/oidc-standing/oidc_standing.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
* SPDX-License-Identifier: FSL-1.1-ALv2
*/

/// The standing resources the oidc-credential e2e exchanges a token against.
/// The standing resources the oidc-credential e2e mints and exchanges a token
/// against: the static issuer, its published keys, and the IAM identity
/// provider that trusts it.
///
/// The role the token is exchanged for is deliberately NOT here. `formae
/// connect` provisions it on every run, from the coordinates the e2e's stub
/// control plane produces, which is how the suite proves the connect path
/// instead of assuming its output. The identity provider stays standing
/// because it is account-global and shared, and because connect validates and
/// reuses an existing one rather than needing to create it.
///
/// These are NOT created by the test run: the e2e assumes they already exist
/// in the e2e account, and the aws-nuke pre-cleanup preserves them (their
Expand Down Expand Up @@ -33,7 +42,6 @@ import "@formae/formae.pkl"

import "@aws/aws.pkl"
import "@aws/iam/oidcprovider.pkl"
import "@aws/iam/role.pkl"
import "@aws/s3/bucket.pkl"
import "@aws/s3/bucketpolicy.pkl"
import "@aws/s3/object.pkl"
Expand All @@ -57,11 +65,12 @@ local issuerUrl = "https://\(issuerHost)"
/// Pinned in the JWKS and in the header of every minted token.
local keyId = "e2e-oidc-key-1"

/// The only subject the role trusts, and the only audience it accepts.
local subject = "e2e-oidc-subject"
/// The audience the provider accepts. The subject the role trusts is not
/// here: the role is provisioned per run by `formae connect`, from the subject
/// the e2e's stub control plane hands it, which is what makes the e2e prove
/// the connect path rather than assume it.
local audience = "sts.amazonaws.com"

local assumeRoleName = "e2e-oidc-assume-role"
local secretName = "e2e-oidc-signing-key"

/// SHA-1 fingerprint of Amazon Root CA 1, which terminates the chain for every
Expand Down Expand Up @@ -152,33 +161,6 @@ local issuerProvider = new oidcprovider.OIDCProvider {
thumbprintList = new Listing { issuerThumbprint }
}

/// The role the e2e assumes. It grants nothing: what the test proves is that
/// STS accepts the token and hands back credentials, not what they can do.
local assumeRole = new role.Role {
label = "e2e-oidc-assume-role"
roleName = assumeRoleName
description = "Assumed by the oidc-credential e2e via web identity"
maxSessionDuration = 3600
assumeRolePolicyDocument = new Dynamic {
["Version"] = "2012-10-17"
["Statement"] = new Listing {
new Dynamic {
["Effect"] = "Allow"
["Principal"] = new Dynamic {
["Federated"] = "arn:aws:iam::\(accountId):oidc-provider/\(issuerHost)"
}
["Action"] = "sts:AssumeRoleWithWebIdentity"
["Condition"] = new Dynamic {
["StringEquals"] = new Dynamic {
["\(issuerHost):sub"] = subject
["\(issuerHost):aud"] = audience
}
}
}
}
}
}

/// The private half, read from the agent's environment so it never lands in
/// the tree. Opaque and set-once: formae writes it at create and thereafter
/// neither reads it back nor diffs it.
Expand All @@ -192,7 +174,7 @@ local signingKey = new secret.Secret {
forma {
new formae.Stack {
label = "e2e-oidc-standing"
description = "Standing issuer, provider, role and key for the oidc-credential e2e"
description = "Standing issuer, provider and key for the oidc-credential e2e"
}

new formae.Target {
Expand All @@ -207,6 +189,5 @@ forma {
discoveryDocument
jwks
issuerProvider
assumeRole
signingKey
}
Loading
Loading