Stitch any CI into Tangled
82
fork

Configure Feed

Select the types of activity you want to include in your feed.

knot: authorize pipeline triggers against persisted spindle state #10

open opened by mitchellh.com targeting main from push-lkulmpoostzo

Previously knot.go executed every sh.tangled.pipeline event the moment it arrived, ignoring the spindle_members and repos tables that jetstream.go has been mirroring from the AT Proto firehose.

The knot consumer now consults store.AuthorizePipelineActor before dispatching a trigger. The check has two gates: the triggers repo must have published a sh.tangled.repo record naming us as its spindle on the knot the event arrived from, and the publisher of that repo record must be either the spindle owner or a subject the owner vouched for via sh.tangled.spindle.member.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:onu3oqfahfubgbetlr4giknc/sh.tangled.repo.pull/3mktx5sgn7w22
+483 -19
Diff #0
+66 -2
knot.go
··· 88 88 // over the decoded record and lets the provider publish status 89 89 // records back through its own broker connection. 90 90 provider Provider 91 + 92 + // store, hostname, and ownerDID are the inputs to the 93 + // pre-dispatch authorization check. We hold them on the 94 + // consumer (instead of threading them through every call) so 95 + // process(), which runs once per inbound knot event on a 96 + // worker goroutine, can ask `is this trigger allowed?` against 97 + // the persisted Tangled membership state without the rest of 98 + // tack having to know it cares. 99 + store *store 100 + hostname string 101 + ownerDID string 91 102 } 92 103 93 104 // Compile-time interface conformance check. ··· 123 134 return nil, fmt.Errorf("open knot cursor store: %w", err) 124 135 } 125 136 126 - kc := &knotConsumer{log: logger, cursors: cursorStore, provider: provider} 137 + kc := &knotConsumer{ 138 + log: logger, 139 + cursors: cursorStore, 140 + provider: provider, 141 + store: st, 142 + hostname: cfg.Hostname, 143 + ownerDID: cfg.OwnerDID, 144 + } 127 145 128 146 ccfg := eventconsumer.NewConsumerConfig() 129 147 ccfg.Logger = logger ··· 260 278 "workflows", len(p.Workflows), 261 279 ) 262 280 281 + // Authorization gate. The knot's /events stream will hand 282 + // us every pipeline record it publishes, including ones 283 + // for repos that never opted into us. Being subscribed 284 + // to a knot says nothing about which repos on it we 285 + // actually serve. Without this check, any DID that can 286 + // publish a sh.tangled.pipeline record on a knot we 287 + // already watch could trigger CI on our spindle. 288 + // 289 + // We consult the persisted state mirrored from the AT 290 + // Proto firehose: the trigger's repo must have declared 291 + // us as its spindle on this knot, and its publisher DID 292 + // must be the spindle owner or a member they vouched for 293 + // via sh.tangled.spindle.member. See 294 + // store.AuthorizePipelineActor for the precise rules. 295 + ok, reason, err := k.store.AuthorizePipelineActor( 296 + ctx, k.hostname, src.Key(), k.ownerDID, 297 + repoDid, repoName, 298 + ) 299 + if err != nil { 300 + // SQL/IO failure (not a denial). Log and refuse to 301 + // spawn rather than fail-open: a transient store 302 + // error during shutdown shouldn't be allowed to 303 + // punch a hole in the security gate. 304 + k.log.Error("authorize pipeline trigger", 305 + "err", err, 306 + "knot", src.Key(), 307 + "rkey", msg.Rkey, 308 + "repo_did", repoDid, 309 + "repo", repoName, 310 + ) 311 + return err 312 + } 313 + if !ok { 314 + k.log.Warn("rejecting unauthorized pipeline trigger", 315 + "knot", src.Key(), 316 + "rkey", msg.Rkey, 317 + "repo_did", repoDid, 318 + "repo", repoName, 319 + "reason", reason, 320 + ) 321 + return nil 322 + } 323 + 263 324 // Hand the trigger to whichever Provider was configured. 264 325 // Spawn is non-blocking — it fans out into provider-owned 265 326 // goroutines so this worker can move on to the next event. 266 327 // The provider keeps ctx around for shutdown coordination. 267 - k.provider.Spawn(ctx, src.Key(), msg.Rkey, p.TriggerMetadata, p.Workflows) 328 + // repoDid is the actor: the publisher of the originating 329 + // sh.tangled.repo record, which AuthorizePipelineActor 330 + // just confirmed is allowed to spawn work here. 331 + k.provider.Spawn(ctx, src.Key(), msg.Rkey, repoDid, p.TriggerMetadata, p.Workflows) 268 332 269 333 default: 270 334 // Knots may publish other record types over the same stream; we
+181
knot_test.go
··· 1 + package main 2 + 3 + // Tests for the knotConsumer.process() authorization gate. The knot 4 + // /events stream hands us every pipeline record published on a knot 5 + // we're subscribed to, but only those matching the persisted Tangled 6 + // state should reach a Provider. This file pins both halves: a 7 + // trigger that satisfies AuthorizePipelineActor must reach Spawn, 8 + // and one that doesn't must be silently dropped. 9 + 10 + import ( 11 + "context" 12 + "encoding/json" 13 + "log/slog" 14 + "net/url" 15 + "testing" 16 + 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/eventconsumer" 19 + ) 20 + 21 + // fakeSource is a minimal eventconsumer.Source: process() only ever 22 + // reads Source.Key(), so a string-keyed stub keeps tests independent 23 + // of the eventconsumer package's URL plumbing. 24 + type fakeSource struct{ key string } 25 + 26 + func (f fakeSource) Key() string { return f.key } 27 + func (f fakeSource) Url(int64, bool) (*url.URL, error) { return nil, nil } 28 + 29 + // newTestKnotConsumer wires a knotConsumer against the provided 30 + // store/provider with the fixed (hostname, owner) used across the 31 + // tests below. cursors and the underlying *eventconsumer.Consumer 32 + // are intentionally left nil; process() never touches them. 33 + func newTestKnotConsumer(st *store, provider Provider) *knotConsumer { 34 + return &knotConsumer{ 35 + log: slog.Default(), 36 + provider: provider, 37 + store: st, 38 + hostname: "spindle.example", 39 + ownerDID: "did:plc:owner", 40 + } 41 + } 42 + 43 + // pipelineMessage builds an eventconsumer.Message for a pipeline 44 + // trigger pointing at (repoOwn, repoName) on the named knot. Output 45 + // matches what eventconsumer feeds process() at runtime. 46 + func pipelineMessage(t *testing.T, repoOwn, repoName string) eventconsumer.Message { 47 + t.Helper() 48 + repo := repoName 49 + rec := tangled.Pipeline{ 50 + LexiconTypeID: tangled.PipelineNSID, 51 + TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ 52 + Kind: "push", 53 + Repo: &tangled.Pipeline_TriggerRepo{ 54 + Did: repoOwn, 55 + Repo: &repo, 56 + }, 57 + }, 58 + Workflows: []*tangled.Pipeline_Workflow{ 59 + {Name: "ci.yml", Raw: "tack:\n fake: {}\n"}, 60 + }, 61 + } 62 + body, err := json.Marshal(rec) 63 + if err != nil { 64 + t.Fatalf("marshal pipeline: %v", err) 65 + } 66 + return eventconsumer.Message{ 67 + Rkey: "pl-1", 68 + Nsid: tangled.PipelineNSID, 69 + EventJson: body, 70 + } 71 + } 72 + 73 + // TestKnotProcessAuthorized confirms a trigger whose repo opted into 74 + // us on the right knot AND whose actor is the spindle owner reaches 75 + // Spawn with the actor DID populated. 76 + func TestKnotProcessAuthorized(t *testing.T) { 77 + st := newTestStore(t) 78 + ctx := context.Background() 79 + 80 + // The repo owner is the spindle owner, so no spindle.member 81 + // row is needed. Repo claims us as its spindle on the right 82 + // knot, so both gates should pass. 83 + if err := st.UpsertRepo(ctx, 84 + "did:plc:owner", "rk1", 85 + "knot.example", "myrepo", 86 + "spindle.example", "", "t", 87 + ); err != nil { 88 + t.Fatal(err) 89 + } 90 + 91 + stub := &stubProvider{} 92 + kc := newTestKnotConsumer(st, stub) 93 + 94 + if err := kc.process(ctx, 95 + fakeSource{key: "knot.example"}, 96 + pipelineMessage(t, "did:plc:owner", "myrepo"), 97 + ); err != nil { 98 + t.Fatalf("process: %v", err) 99 + } 100 + 101 + if got, want := stub.names(), []string{"ci.yml"}; !equalStrings(got, want) { 102 + t.Fatalf("Spawn workflows = %v; want %v", got, want) 103 + } 104 + } 105 + 106 + // TestKnotProcessRejectsUnauthorized covers the bug this commit 107 + // fixes: a knot we're already subscribed to publishes a pipeline 108 + // trigger for a repo that never opted into us. The store has no 109 + // matching repos row, so AuthorizePipelineActor must deny and 110 + // Spawn must NOT be invoked. 111 + func TestKnotProcessRejectsUnauthorized(t *testing.T) { 112 + st := newTestStore(t) 113 + 114 + // Note: NO repos row inserted. The knot may publish whatever 115 + // pipeline records it likes, but absent a sh.tangled.repo 116 + // declaring us the spindle, we must drop the event. 117 + stub := &stubProvider{} 118 + kc := newTestKnotConsumer(st, stub) 119 + 120 + if err := kc.process(context.Background(), 121 + fakeSource{key: "knot.example"}, 122 + pipelineMessage(t, "did:plc:rando", "evilrepo"), 123 + ); err != nil { 124 + t.Fatalf("process: %v", err) 125 + } 126 + 127 + if got := stub.names(); len(got) != 0 { 128 + t.Fatalf("Spawn called for unauthorized trigger: %v", got) 129 + } 130 + } 131 + 132 + // TestKnotProcessRejectsNonMember confirms that even when a repo 133 + // declares us as its spindle, a trigger from a publisher who is not 134 + // the spindle owner and has no owner-vouched membership is dropped. 135 + // This is the gate that makes spindle_members load-bearing. 136 + func TestKnotProcessRejectsNonMember(t *testing.T) { 137 + st := newTestStore(t) 138 + ctx := context.Background() 139 + 140 + // Repo claim is fine, but the owner of that repo isn't us, 141 + // and no membership grant has been published. 142 + if err := st.UpsertRepo(ctx, 143 + "did:plc:alice", "rk1", 144 + "knot.example", "myrepo", 145 + "spindle.example", "", "t", 146 + ); err != nil { 147 + t.Fatal(err) 148 + } 149 + 150 + stub := &stubProvider{} 151 + kc := newTestKnotConsumer(st, stub) 152 + 153 + if err := kc.process(ctx, 154 + fakeSource{key: "knot.example"}, 155 + pipelineMessage(t, "did:plc:alice", "myrepo"), 156 + ); err != nil { 157 + t.Fatalf("process: %v", err) 158 + } 159 + 160 + if got := stub.names(); len(got) != 0 { 161 + t.Fatalf("Spawn called for non-member: %v", got) 162 + } 163 + 164 + // Now grant alice membership and re-run; this time it must 165 + // pass. Verifies the gate is actually consulting the live 166 + // store rather than caching a denial. 167 + if err := st.UpsertSpindleMember(ctx, 168 + "did:plc:owner", "mk1", "spindle.example", "did:plc:alice", "t", 169 + ); err != nil { 170 + t.Fatal(err) 171 + } 172 + if err := kc.process(ctx, 173 + fakeSource{key: "knot.example"}, 174 + pipelineMessage(t, "did:plc:alice", "myrepo"), 175 + ); err != nil { 176 + t.Fatalf("process (after grant): %v", err) 177 + } 178 + if got, want := stub.names(), []string{"ci.yml"}; !equalStrings(got, want) { 179 + t.Fatalf("post-grant Spawn = %v; want %v", got, want) 180 + } 181 + }
+15 -7
provider.go
··· 73 73 // knot is the knot hostname the trigger arrived on; it's the 74 74 // authority half of the pipeline ATURI that pipeline.status 75 75 // records reference. pipelineRkey is the trigger record's rkey 76 - // on that knot. trigger is the decoded record's TriggerMetadata 77 - // (may be nil — the lexicon doesn't enforce its presence) and 78 - // carries the commit/branch/PR data a real CI provider needs to 79 - // kick off a build. workflows is the unmodified slice from the 80 - // decoded sh.tangled.pipeline record; implementations should 81 - // tolerate nil entries and zero-length names defensively, since 82 - // the lexicon doesn't enforce either. 76 + // on that knot. actor is the DID that the knot consumer has 77 + // already authorized to spawn this work; concretely, the 78 + // publisher of the originating sh.tangled.repo record (i.e. 79 + // the repo owner). It is guaranteed non-empty: the consumer 80 + // never invokes Spawn for an unauthorized trigger, so providers 81 + // can treat actor as "the identity to attribute this run to" 82 + // without re-checking authorization. trigger is the decoded 83 + // record's TriggerMetadata (may be nil; the lexicon doesn't 84 + // enforce its presence) and carries the commit/branch/PR data 85 + // a real CI provider needs to kick off a build. workflows is 86 + // the unmodified slice from the decoded sh.tangled.pipeline 87 + // record; implementations should tolerate nil entries and 88 + // zero-length names defensively, since the lexicon doesn't 89 + // enforce either. 83 90 Spawn( 84 91 ctx context.Context, 85 92 knot string, 86 93 pipelineRkey string, 94 + actor string, 87 95 trigger *tangled.Pipeline_TriggerMetadata, 88 96 workflows []*tangled.Pipeline_Workflow, 89 97 )
+9 -1
provider_buildkite.go
··· 188 188 ctx context.Context, 189 189 knot string, 190 190 pipelineRkey string, 191 + actor string, 191 192 trigger *tangled.Pipeline_TriggerMetadata, 192 193 workflows []*tangled.Pipeline_Workflow, 193 194 ) { ··· 203 204 continue 204 205 } 205 206 wf := wf 206 - go p.spawnWorkflow(ctx, knot, pipelineRkey, trigger, wf) 207 + go p.spawnWorkflow(ctx, knot, pipelineRkey, actor, trigger, wf) 207 208 } 208 209 } 209 210 ··· 212 213 // nothing in tack consumes the result, and a failed Spawn just 213 214 // surfaces as the absence of any status update for the affected 214 215 // workflow. 216 + // 217 + // actor is the DID the knot consumer authorized to spawn this work 218 + // (the publishing repo owner). It's surfaced in the per-workflow 219 + // logger so operator-side audits can join Buildkite logs back to the 220 + // triggering Tangled identity even before the build itself is up. 215 221 func (p *buildkiteProvider) spawnWorkflow( 216 222 ctx context.Context, 217 223 knot string, 218 224 pipelineRkey string, 225 + actor string, 219 226 trigger *tangled.Pipeline_TriggerMetadata, 220 227 wf *tangled.Pipeline_Workflow, 221 228 ) { ··· 223 230 "knot", knot, 224 231 "pipeline_rkey", pipelineRkey, 225 232 "workflow", wf.Name, 233 + "actor", actor, 226 234 ) 227 235 228 236 cfg, err := parseWorkflowConfig(wf.Raw)
+5 -5
provider_buildkite_test.go
··· 77 77 {Name: "test.yml", Raw: "tack:\n buildkite:\n pipeline: mypipe\n"}, 78 78 } 79 79 80 - p.Spawn(context.Background(), "knot.example.com", "rkey-1", trigger, workflows) 80 + p.Spawn(context.Background(), "knot.example.com", "rkey-1", "did:plc:actor", trigger, workflows) 81 81 82 82 // Spawn fans out into goroutines; wait briefly for the side 83 83 // effects to land. The store row is the load-bearing artifact ··· 137 137 }) 138 138 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", bk) 139 139 140 - p.Spawn(context.Background(), "knot.example.com", "rkey-1", 140 + p.Spawn(context.Background(), "knot.example.com", "rkey-1", "did:plc:actor", 141 141 &tangled.Pipeline_TriggerMetadata{Manual: &tangled.Pipeline_ManualTriggerData{}}, 142 142 []*tangled.Pipeline_Workflow{{Name: "test.yml", 143 143 Raw: "tack:\n buildkite:\n pipeline: mypipe\n"}}, ··· 189 189 }, 190 190 } 191 191 192 - p.Spawn(context.Background(), "knot.example.com", "rkey-x", trigger, 192 + p.Spawn(context.Background(), "knot.example.com", "rkey-x", "did:plc:actor", trigger, 193 193 []*tangled.Pipeline_Workflow{{Name: "ci.yml", Raw: raw}}) 194 194 195 195 select { ··· 272 272 }) 273 273 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 274 274 275 - p.Spawn(context.Background(), "knot.example.com", "rkey-r", 275 + p.Spawn(context.Background(), "knot.example.com", "rkey-r", "did:plc:actor", 276 276 &tangled.Pipeline_TriggerMetadata{ 277 277 Push: &tangled.Pipeline_PushTriggerData{ 278 278 NewSha: "abc", Ref: "refs/heads/main", ··· 357 357 }) 358 358 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 359 359 360 - p.Spawn(context.Background(), "knot.example.com", "rkey-z", 360 + p.Spawn(context.Background(), "knot.example.com", "rkey-z", "did:plc:actor", 361 361 &tangled.Pipeline_TriggerMetadata{ 362 362 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"}, 363 363 },
+1
provider_fake.go
··· 81 81 ctx context.Context, 82 82 knot string, 83 83 pipelineRkey string, 84 + _ string, 84 85 _ *tangled.Pipeline_TriggerMetadata, 85 86 workflows []*tangled.Pipeline_Workflow, 86 87 ) {
+4 -1
provider_router.go
··· 58 58 ctx context.Context, 59 59 knot string, 60 60 pipelineRkey string, 61 + actor string, 61 62 trigger *tangled.Pipeline_TriggerMetadata, 62 63 workflows []*tangled.Pipeline_Workflow, 63 64 ) { ··· 80 81 } 81 82 // Hand the workflow off as a single-element slice so the 82 83 // downstream provider's existing Spawn loop runs unchanged. 83 - p.Spawn(ctx, knot, pipelineRkey, trigger, 84 + // actor passes through verbatim; the router doesn't 85 + // authorize, it just dispatches. 86 + p.Spawn(ctx, knot, pipelineRkey, actor, trigger, 84 87 []*tangled.Pipeline_Workflow{wf}, 85 88 ) 86 89 }
+4 -3
provider_router_test.go
··· 38 38 _ context.Context, 39 39 _ string, 40 40 _ string, 41 + _ string, 41 42 _ *tangled.Pipeline_TriggerMetadata, 42 43 workflows []*tangled.Pipeline_Workflow, 43 44 ) { ··· 97 98 func TestProviderRouterSpawnRoutesByYAMLKey(t *testing.T) { 98 99 r, a, b := newRouterTest() 99 100 100 - r.Spawn(context.Background(), "knot", "rkey", nil, 101 + r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 101 102 []*tangled.Pipeline_Workflow{ 102 103 {Name: "wf-a.yml", Raw: "tack:\n a: {}\n"}, 103 104 {Name: "wf-b.yml", Raw: "tack:\n b: {}\n"}, ··· 120 121 121 122 // `b` is listed first under `tack:` so it should claim the 122 123 // workflow even though both keys are registered. 123 - r.Spawn(context.Background(), "knot", "rkey", nil, 124 + r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 124 125 []*tangled.Pipeline_Workflow{ 125 126 {Name: "both.yml", Raw: "tack:\n b: {}\n a: {}\n"}, 126 127 }, ··· 140 141 func TestProviderRouterSpawnSkipsUnroutable(t *testing.T) { 141 142 r, a, b := newRouterTest() 142 143 143 - r.Spawn(context.Background(), "knot", "rkey", nil, 144 + r.Spawn(context.Background(), "knot", "rkey", "did:plc:actor", nil, 144 145 []*tangled.Pipeline_Workflow{ 145 146 // No `tack:` key at all. 146 147 {Name: "bare.yml", Raw: "steps: []\n"},
+75
store.go
··· 219 219 return knot, spindle, nil 220 220 } 221 221 222 + // AuthorizePipelineActor reports whether a pipeline trigger that 223 + // arrived on knot for repo (repoOwnerDID, repoName) should be allowed 224 + // to spawn work on this spindle. Both halves of the answer are 225 + // derived from the persisted Tangled state in spindle_members and 226 + // repos: the network's own authorization records, mirrored here from 227 + // the firehose by jetstream.go. 228 + // 229 + // The check is two independent gates; both must hold: 230 + // 231 + // 1. **Repo claim**: a sh.tangled.repo record exists naming 232 + // hostname as its CI spindle AND knot as its host knot, 233 + // published by repoOwnerDID with the matching name. Without this 234 + // a knot we already happen to be subscribed to (because *some* 235 + // repo on it points at us) could otherwise smuggle in pipeline 236 + // triggers for unrelated repos that never opted into us. 237 + // 238 + // 2. **Actor membership**: repoOwnerDID is either the spindle owner 239 + // itself, or has been authorized via a sh.tangled.spindle.member 240 + // record published by the spindle owner. The publisher (`did`) 241 + // of that membership grant must equal ownerDID. Anyone can 242 + // publish a member record naming anyone, so trusting unsigned 243 + // grants would let any DID grant itself access. We do NOT match 244 + // against the record's `instance` column because the upstream 245 + // ecosystem stores it inconsistently (URL vs hostname), and a 246 + // tack instance only ever speaks for a single hostname anyway. 247 + // 248 + // reason is a short, log-friendly description of why authorization 249 + // failed when ok is false; it is empty when ok is true. Errors 250 + // reported here are SQL/IO failures, never policy denials; those 251 + // surface as ok=false with a populated reason. 252 + func (s *store) AuthorizePipelineActor( 253 + ctx context.Context, 254 + hostname, knot, ownerDID, repoOwnerDID, repoName string, 255 + ) (ok bool, reason string, err error) { 256 + // We can't authorize an unknown actor or an unidentified repo: 257 + // every gate below joins on these. Bail before touching SQLite 258 + // so a malformed trigger logs cleanly instead of triggering a 259 + // "no rows" miss that could be confused with a real denial. 260 + if repoOwnerDID == "" { 261 + return false, "trigger has no repo did", nil 262 + } 263 + if repoName == "" { 264 + return false, "trigger has no repo name", nil 265 + } 266 + 267 + // Gate 1: this specific repo opted into us on this specific knot. 268 + var n int 269 + if err := s.db.QueryRowContext(ctx, 270 + `SELECT COUNT(*) FROM repos 271 + WHERE did = ? AND name = ? AND knot = ? AND spindle = ?`, 272 + repoOwnerDID, repoName, knot, hostname, 273 + ).Scan(&n); err != nil { 274 + return false, "", fmt.Errorf("count repo claim: %w", err) 275 + } 276 + if n == 0 { 277 + return false, "no repo record claims this spindle on this knot", nil 278 + } 279 + 280 + // Gate 2: actor is the spindle owner, or vouched for by them. 281 + if repoOwnerDID == ownerDID { 282 + return true, "", nil 283 + } 284 + if err := s.db.QueryRowContext(ctx, 285 + `SELECT COUNT(*) FROM spindle_members 286 + WHERE did = ? AND subject = ?`, 287 + ownerDID, repoOwnerDID, 288 + ).Scan(&n); err != nil { 289 + return false, "", fmt.Errorf("count membership: %w", err) 290 + } 291 + if n == 0 { 292 + return false, "actor is not a spindle member", nil 293 + } 294 + return true, "", nil 295 + } 296 + 222 297 // IsKnotWanted reports whether any repo currently stored still names the 223 298 // given hostname as its spindle and the given knot as its host. After a 224 299 // repo update or delete this is the question we ask to decide whether
+123
store_test.go
··· 378 378 } 379 379 } 380 380 381 + // TestAuthorizePipelineActor exercises every gate of the 382 + // authorization helper end-to-end. The case table covers the matrix 383 + // of {repo claim present, member grant present, actor is owner} and 384 + // pins the negative-path failure reasons, which the knot consumer 385 + // surfaces verbatim into its denial logs. 386 + func TestAuthorizePipelineActor(t *testing.T) { 387 + const ( 388 + hostname = "spindle.example" 389 + knot = "knot.example" 390 + owner = "did:plc:owner" 391 + repoOwn = "did:plc:alice" 392 + repoName = "myrepo" 393 + ) 394 + ctx := context.Background() 395 + 396 + t.Run("missing repo did", func(t *testing.T) { 397 + s := newTestStore(t) 398 + ok, reason, err := s.AuthorizePipelineActor(ctx, 399 + hostname, knot, owner, "", repoName) 400 + if err != nil || ok { 401 + t.Fatalf("got ok=%v err=%v; want ok=false err=nil", ok, err) 402 + } 403 + if reason == "" { 404 + t.Fatal("expected non-empty reason") 405 + } 406 + }) 407 + 408 + t.Run("repo not on this spindle/knot", func(t *testing.T) { 409 + s := newTestStore(t) 410 + // Repo exists but with the wrong knot: the trigger 411 + // arrived on knot.example but the repo opted into a 412 + // different host. Must be rejected. 413 + if err := s.UpsertRepo(ctx, repoOwn, "rk1", 414 + "other.knot", repoName, hostname, "", "t"); err != nil { 415 + t.Fatal(err) 416 + } 417 + ok, reason, err := s.AuthorizePipelineActor(ctx, 418 + hostname, knot, owner, repoOwn, repoName) 419 + if err != nil || ok { 420 + t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason) 421 + } 422 + }) 423 + 424 + t.Run("repo on wrong spindle", func(t *testing.T) { 425 + s := newTestStore(t) 426 + // Right knot, but spindle is someone else. 427 + if err := s.UpsertRepo(ctx, repoOwn, "rk1", 428 + knot, repoName, "other.spindle", "", "t"); err != nil { 429 + t.Fatal(err) 430 + } 431 + ok, _, err := s.AuthorizePipelineActor(ctx, 432 + hostname, knot, owner, repoOwn, repoName) 433 + if err != nil || ok { 434 + t.Fatalf("got ok=%v err=%v", ok, err) 435 + } 436 + }) 437 + 438 + t.Run("repo claims us but actor not a member", func(t *testing.T) { 439 + s := newTestStore(t) 440 + if err := s.UpsertRepo(ctx, repoOwn, "rk1", 441 + knot, repoName, hostname, "", "t"); err != nil { 442 + t.Fatal(err) 443 + } 444 + ok, reason, err := s.AuthorizePipelineActor(ctx, 445 + hostname, knot, owner, repoOwn, repoName) 446 + if err != nil || ok { 447 + t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason) 448 + } 449 + }) 450 + 451 + t.Run("membership granted by non-owner is ignored", func(t *testing.T) { 452 + s := newTestStore(t) 453 + if err := s.UpsertRepo(ctx, repoOwn, "rk1", 454 + knot, repoName, hostname, "", "t"); err != nil { 455 + t.Fatal(err) 456 + } 457 + // A member record published by anyone other than the 458 + // spindle owner must NOT count, otherwise self-grants 459 + // would bypass authorization entirely. 460 + if err := s.UpsertSpindleMember(ctx, 461 + "did:plc:rando", "mk1", hostname, repoOwn, "t"); err != nil { 462 + t.Fatal(err) 463 + } 464 + ok, _, err := s.AuthorizePipelineActor(ctx, 465 + hostname, knot, owner, repoOwn, repoName) 466 + if err != nil || ok { 467 + t.Fatalf("got ok=%v err=%v", ok, err) 468 + } 469 + }) 470 + 471 + t.Run("owner-granted member is allowed", func(t *testing.T) { 472 + s := newTestStore(t) 473 + if err := s.UpsertRepo(ctx, repoOwn, "rk1", 474 + knot, repoName, hostname, "", "t"); err != nil { 475 + t.Fatal(err) 476 + } 477 + if err := s.UpsertSpindleMember(ctx, 478 + owner, "mk1", hostname, repoOwn, "t"); err != nil { 479 + t.Fatal(err) 480 + } 481 + ok, _, err := s.AuthorizePipelineActor(ctx, 482 + hostname, knot, owner, repoOwn, repoName) 483 + if err != nil || !ok { 484 + t.Fatalf("got ok=%v err=%v; want ok=true", ok, err) 485 + } 486 + }) 487 + 488 + t.Run("spindle owner triggers their own repo", func(t *testing.T) { 489 + s := newTestStore(t) 490 + // Owner needs no membership row to act on a repo they 491 + // themselves published. Just the repo-claim gate. 492 + if err := s.UpsertRepo(ctx, owner, "rk1", 493 + knot, repoName, hostname, "", "t"); err != nil { 494 + t.Fatal(err) 495 + } 496 + ok, _, err := s.AuthorizePipelineActor(ctx, 497 + hostname, knot, owner, owner, repoName) 498 + if err != nil || !ok { 499 + t.Fatalf("got ok=%v err=%v; want ok=true", ok, err) 500 + } 501 + }) 502 + } 503 + 381 504 // countRows is a small SELECT COUNT(*) helper used by lifecycle tests 382 505 // to verify deletes actually removed the row. Table name is interpolated 383 506 // directly because callers pass a constant from the schema, not user

History

1 round 0 comments
sign up or login to add to the discussion
mitchellh.com submitted #0
1 commit
expand
knot: authorize pipeline triggers against persisted spindle state
merge conflicts detected
expand
  • knot.go:88
  • provider.go:73
  • provider_buildkite.go:188
  • provider_buildkite_test.go:77
  • provider_fake.go:81
  • provider_router.go:58
  • provider_router_test.go:38
  • store.go:219
  • store_test.go:378
expand 0 comments