Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395package spindle
import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time"
"tangled.org/core/api/tangled" "tangled.org/core/idresolver" "tangled.org/core/spindle/config" "tangled.org/core/spindle/db" "tangled.org/core/spindle/models" "tangled.org/core/spindle/observability" "tangled.org/core/spindle/oidc" "tangled.org/core/notifier" "tangled.org/core/spindle/secrets")
const ( oidcTestIssuerURL = "https://tokens.spindle.tangled.sh" oidcTestAudience = "//iam.googleapis.com/projects/tangled-prod/locations/global/workloadIdentityPools/ci/providers/spindle" oidcTestSha = "1111111111111111111111111111111111111111" oidcTestRepoDid = "did:plc:boltless" oidcTestActor = "did:plc:pusher")
func oidcTestIssuer(t *testing.T) *oidc.Issuer { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } der, err := x509.MarshalECPrivateKey(key) if err != nil { t.Fatal(err) } path := filepath.Join(t.TempDir(), "oidc-key.pem") block := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) if err := os.WriteFile(path, block, 0o600); err != nil { t.Fatal(err) } issuer, err := oidc.New(oidc.Options{Issuer: oidcTestIssuerURL, KeyPath: path}) if err != nil { t.Fatal(err) } return issuer}
func oidcTestPushJob(workflowYaml string) *db.JobRow { repoDid, actor := oidcTestRepoDid, oidcTestActor return &db.JobRow{ Id: 42, PipelineId: models.PipelineId("3mrkp6iz6os2o"), RepoDid: oidcTestRepoDid, Tpl: tangled.Pipeline{ TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ Kind: "push", Actor: &actor, Push: &tangled.Pipeline_PushTriggerData{Ref: "refs/heads/main", NewSha: oidcTestSha, OldSha: strings.Repeat("0", 40)}, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "knot.test", Did: "did:plc:owner", RepoDid: &repoDid, DefaultBranch: "main", }, }, Workflows: []*tangled.Pipeline_Workflow{{Name: "test", Engine: "nixery", Raw: workflowYaml}}, }, }}
func oidcTestSpindle(issuer *oidc.Issuer) *Spindle { return &Spindle{oidc: issuer, l: slog.New(slog.NewTextHandler(io.Discard, nil))}}
func decodeJWTClaims(t *testing.T, token string) map[string]any { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("token has %d parts, want 3", len(parts)) } raw, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatal(err) } claims := map[string]any{} if err := json.Unmarshal(raw, &claims); err != nil { t.Fatal(err) } return claims}
func TestRunIDTokenMintsFromTheRunRecord(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t))
job := oidcTestPushJob(`on: pushenv: TANGLED_REF: refs/heads/evil TANGLED_REPO_REPO_DID: did:plc:forged TANGLED_ID_TOKEN: smuggled`) token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } if token == "" { t.Fatal("a trusted in-repo run got no ID token") }
claims := decodeJWTClaims(t, token) want := map[string]any{ "iss": oidcTestIssuerURL, "aud": oidcTestAudience, "sub": oidcTestRepoDid + ":refs/heads/main", "repository": oidcTestRepoDid, "ref": "refs/heads/main", "actor": oidcTestActor, "sha": oidcTestSha, } for name, want := range want { if got := claims[name]; got != want { t.Errorf("%s claim = %v, want %v (claims come from the run record, not the workflow definition)", name, got, want) } } for _, name := range []string{"iat", "nbf", "exp", "jti"} { if claims[name] == nil { t.Errorf("%s claim is missing", name) } } if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 900 { t.Errorf("lifetime = %v, want a run-scoped 900s", exp-iat) }}
func TestRunIDTokenNeedsATrustedSource(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) sourceDid := "did:plc:elsewhere"
// the shape resolveCheckout produces when a manual trigger names somebody // else's repository: the checkout, and every source-side env var, is the // other repo fork := oidcTestPushJob("") fork.SourceRepo = &tangled.Pipeline_TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"}
if token, err := mintFrom(t, s, fork, false); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for an untrusted run", token, err) }
// the same record on a trusted run mints, so the assertions above pin the // gate and not a broken fixture if token, err := mintFrom(t, s, fork, true); err != nil || token == "" { t.Fatalf("token = %q err = %v, want a token for the same record when the source is trusted", token, err) }}
func TestRunIDTokenClaimsTheRunRepoNotTheSourceRepo(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) sourceDid := "did:plc:source"
job := oidcTestPushJob("") job.Tpl.TriggerMetadata.SourceRepo = &sourceDid job.Tpl.TriggerMetadata.Repo.RepoDid = &sourceDid
token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } if got := decodeJWTClaims(t, token)["repository"]; got != oidcTestRepoDid { t.Fatalf("repository claim = %v, want the run's own repository %q", got, oidcTestRepoDid) }}
func TestRunIDTokenDropsAClientSuppliedRef(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t)) actor, ref := oidcTestActor, "refs/heads/main" repoDid := oidcTestRepoDid job := &db.JobRow{ Id: 9, PipelineId: models.PipelineId("3mrkp6iz6os2o"), RepoDid: oidcTestRepoDid, Tpl: tangled.Pipeline{ TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ Kind: "manual", Actor: &actor, Repo: &tangled.Pipeline_TriggerRepo{RepoDid: &repoDid, Did: "did:plc:owner", Knot: "knot.test"}, Manual: &tangled.Pipeline_ManualTriggerData{Sha: oidcTestSha, Ref: &ref}, }, }, }
token, err := mintFrom(t, s, job, true) if err != nil { t.Fatal(err) } claims := decodeJWTClaims(t, token) if claims["ref"] != "" { t.Fatalf("ref claim = %v, want none: a manual ref is the caller's own claim", claims["ref"]) } if claims["sha"] != oidcTestSha { t.Fatalf("sha claim = %v, want the commit the run targets", claims["sha"]) }}
func TestRunIDTokenSkipsRunsWithoutAnIdentity(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t))
recordingless := oidcTestPushJob("") recordingless.Tpl.TriggerMetadata = nil if token, err := mintFrom(t, s, recordingless, true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for a run without a record", token, err) }
commitless := oidcTestPushJob("") commitless.Tpl.TriggerMetadata.Push.NewSha = "" if token, err := mintFrom(t, s, commitless, true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token for a run without a commit", token, err) }
unconfigured := oidcTestSpindle(nil) if token, err := mintFrom(t, unconfigured, oidcTestPushJob(""), true); err != nil || token != "" { t.Fatalf("token = %q err = %v, want no token without a configured issuer", token, err) }}
// an identity the issuer refuses to sign must leave the run alone: no token,// no error. A push to a legal-but-long refname lands here.func TestIDTokenMinterWithholdsAnUnsignableRun(t *testing.T) { s := oidcTestSpindle(oidcTestIssuer(t))
job := oidcTestPushJob("") job.Tpl.TriggerMetadata.Push.Ref = "refs/heads/" + strings.Repeat("nested/", 80) + "main"
minter := s.idTokenMinter(job, true) if minter == nil { t.Fatal("a trusted in-repo run with an unsignable ref should still try to mint") } token, err := minter(10*time.Minute, oidcTestAudience) if err != nil { t.Fatalf("err = %v, want the run to proceed without a token", err) } if token != "" { t.Fatalf("token = %q, want none for a ref the issuer will not sign", token) }}
type recordingStep struct{}
func (recordingStep) Name() string { return "record" }func (recordingStep) Command() string { return "true" }func (recordingStep) Kind() models.StepKind { return models.StepKindUser}
type recordingEngine struct { seen map[string]string}
func (e *recordingEngine) InitWorkflow(twf tangled.Pipeline_Workflow, _ tangled.Pipeline) (*models.Workflow, error) { return &models.Workflow{Name: twf.Name, Steps: []models.Step{recordingStep{}}}, nil}
func (e *recordingEngine) SetupWorkflow(context.Context, models.WorkflowId, *models.Workflow, models.WorkflowLogger) error { return nil}
func (e *recordingEngine) WorkflowTimeout() time.Duration { return time.Minute }
func (e *recordingEngine) DestroyWorkflow(context.Context, models.WorkflowId) error { return nil }
func (e *recordingEngine) RunStep(_ context.Context, wid models.WorkflowId, w *models.Workflow, _ int, _ []secrets.UnlockedSecret, _ models.WorkflowLogger) error { if e.seen == nil { e.seen = map[string]string{} } e.seen[wid.Name] = w.Audience return nil}
func TestRunJobCarriesTheDeclaredAudienceToTheEngine(t *testing.T) { dbDir := t.TempDir() ctx := context.Background() d, err := db.Make(ctx, filepath.Join(dbDir, "spindle.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() })
logDir := t.TempDir() cfg := &config.Config{} cfg.Server.LogDir = logDir
rec := &recordingEngine{} s := &Spindle{ l: slog.New(slog.NewTextHandler(io.Discard, nil)), metrics: observability.NewMetrics(), db: d, n: ¬ifier.Notifier{}, cfg: cfg, engs: map[string]models.Engine{"record": rec}, }
sourceDid := "did:plc:elsewhere" audience := oidcTestAudience job := oidcTestPushJob("") job.SourceRepo = &tangled.Pipeline_TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"} job.Tpl.Workflows[0].Engine = "record" job.Tpl.Workflows[0].Audience = &audience
s.runJob(ctx, job)
if got := rec.seen["test"]; got != audience { t.Fatalf("engine saw audience %q, want the workflow record's %q", got, audience) }}
// mintFrom runs the minter a workflow would call, and reports a missing minter// as an empty token so the trust-gate assertions read the same either way.func mintFrom(t *testing.T, s *Spindle, job *db.JobRow, trusted bool) (string, error) { t.Helper() minter := s.idTokenMinter(job, trusted) if minter == nil { return "", nil } return minter(10*time.Minute, oidcTestAudience)}
func TestRouterServesTheIssuerSurface(t *testing.T) { s := &Spindle{ cfg: &config.Config{Role: config.RoleMill}, l: slog.New(slog.NewTextHandler(io.Discard, nil)), metrics: observability.NewMetrics(), res: idresolver.NewMockResolver(idresolver.MockDirectory{}), oidc: oidcTestIssuer(t), } router := s.Router()
fetch := func(path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec }
for _, path := range []string{"/token", "/jwks/", "/.well-known/other"} { if rec := fetch(path); rec.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404: the issuer serves nothing but the discovery document and the key set", path, rec.Code) } }
var doc struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` Algs []string `json:"id_token_signing_alg_values_supported"` } rec := fetch(oidc.ConfigurationPath) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", oidc.ConfigurationPath, rec.Code) } if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatal(err) } if doc.Issuer != oidcTestIssuerURL || len(doc.Algs) != 1 || doc.Algs[0] != "ES256" { t.Errorf("discovery document = %+v, want issuer %q signing with ES256", doc, oidcTestIssuerURL) }
rec = fetch(oidc.JWKSPath) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", oidc.JWKSPath, rec.Code) } var set struct { Keys []struct { KeyID string `json:"kid"` Alg string `json:"alg"` } `json:"keys"` } if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil { t.Fatal(err) } if len(set.Keys) != 1 || set.Keys[0].Alg != "ES256" { t.Fatalf("key set = %+v, want one ES256 key", set.Keys) }}