Merge pull request 'feat(executor): print the GITHUB_TOKEN permissions GitHub grants' (#6) from feat/token-permissions-display into main
4 files · +284 -49
M internal/executor/corpus_test.go +94 -12
| @@ -20,26 +20,48 @@ import ( | |||
| 20 | 20 | // conclusions and their logs with the recordings made on github.com. They need | |
| 21 | 21 | // a checkout of ermite-conformance (the corpus and recordings directories), | |
| 22 | 22 | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | |
| 23 | - | // they skip without it. | |
| 23 | + | // they skip without it (and fail when ERMITE_CONFORMANCE_DIR is wrong). | |
| 24 | 24 | ||
| 25 | 25 | func conformanceDir(t *testing.T) string { | |
| 26 | 26 | t.Helper() | |
| 27 | - | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | |
| 28 | - | wd, _ := os.Getwd() | |
| 29 | - | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |
| 30 | - | cands = append(cands, | |
| 31 | - | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | |
| 32 | - | filepath.Join(d, "ermite-conformance")) | |
| 27 | + | return conformanceDirFor(t, "") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // conformanceDirFor is conformanceDir for a checkout that has the recording of | |
| 31 | + | // scenario (any checkout with a corpus when scenario is empty). The checkout is | |
| 32 | + | // ERMITE_CONFORMANCE_DIR when set (the test fails if it lacks the corpus or the | |
| 33 | + | // recording: a wrong path must not look like a pass), else the main checkout | |
| 34 | + | // ermite-conformance next to this repository (skipped when it has none). | |
| 35 | + | func conformanceDirFor(t *testing.T, scenario string) string { | |
| 36 | + | t.Helper() | |
| 37 | + | has := func(dir string) (corpus, rec bool) { | |
| 38 | + | if st, err := os.Stat(filepath.Join(dir, "corpus")); err == nil && st.IsDir() { | |
| 39 | + | corpus = true | |
| 40 | + | } | |
| 41 | + | if scenario == "" { | |
| 42 | + | return corpus, true | |
| 43 | + | } | |
| 44 | + | _, err := os.Stat(filepath.Join(dir, "recordings", scenario+".json")) | |
| 45 | + | return corpus, err == nil | |
| 33 | 46 | } | |
| 34 | - | for _, c := range cands { | |
| 35 | - | if c == "" { | |
| 36 | - | continue | |
| 47 | + | if dir := os.Getenv("ERMITE_CONFORMANCE_DIR"); dir != "" { | |
| 48 | + | corpus, rec := has(dir) | |
| 49 | + | if !corpus { | |
| 50 | + | t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no corpus directory", dir) | |
| 51 | + | } | |
| 52 | + | if !rec { | |
| 53 | + | t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no recordings/%s.json", dir, scenario) | |
| 37 | 54 | } | |
| 38 | - | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | |
| 55 | + | return dir | |
| 56 | + | } | |
| 57 | + | wd, _ := os.Getwd() | |
| 58 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |
| 59 | + | c := filepath.Join(d, "ermite-conformance") | |
| 60 | + | if corpus, rec := has(c); corpus && rec { | |
| 39 | 61 | return c | |
| 40 | 62 | } | |
| 41 | 63 | } | |
| 42 | - | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | |
| 64 | + | t.Skip("ermite-conformance checkout not found next to this repository (set ERMITE_CONFORMANCE_DIR)") | |
| 43 | 65 | return "" | |
| 44 | 66 | } | |
| 45 | 67 | ||
| @@ -163,6 +185,7 @@ func readFile(t *testing.T, p string) string { | |||
| 163 | 185 | type recording struct { | |
| 164 | 186 | Pushes []struct { | |
| 165 | 187 | Runs []struct { | |
| 188 | + | Workflow string `json:"workflow"` | |
| 166 | 189 | Conclusion string `json:"conclusion"` | |
| 167 | 190 | Jobs []struct { | |
| 168 | 191 | Name string `json:"name"` | |
| @@ -324,3 +347,62 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||
| 324 | 347 | t.Fatalf("%+v %v", res, rec.names()) | |
| 325 | 348 | } | |
| 326 | 349 | } | |
| 350 | + | ||
| 351 | + | // TestCorpusPermissionsDisplay runs every job of every workflow of the | |
| 352 | + | // permissions-display scenario and compares the GITHUB_TOKEN Permissions group | |
| 353 | + | // of Set up job with the recording. The scenario has one workflow file per | |
| 354 | + | // case, so compareWithRecording does not apply. Workflows GitHub rejects have | |
| 355 | + | // no jobs in the recording and are not run. | |
| 356 | + | func TestCorpusPermissionsDisplay(t *testing.T) { | |
| 357 | + | needTools(t, "bash") | |
| 358 | + | dir := conformanceDirFor(t, "permissions-display") | |
| 359 | + | var rec recording | |
| 360 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil { | |
| 361 | + | t.Fatal(err) | |
| 362 | + | } | |
| 363 | + | n := 0 | |
| 364 | + | for _, run := range rec.Pushes[0].Runs { | |
| 365 | + | if len(run.Jobs) == 0 { | |
| 366 | + | continue | |
| 367 | + | } | |
| 368 | + | file := run.Workflow | |
| 369 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file))) | |
| 370 | + | for _, jr := range run.Jobs { | |
| 371 | + | t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) { | |
| 372 | + | if len(jr.Steps) == 0 { | |
| 373 | + | t.Fatal("no steps in the recording") | |
| 374 | + | } | |
| 375 | + | want := permissionsGroup(jr.Steps[0].Log) | |
| 376 | + | if want == nil { | |
| 377 | + | t.Fatalf("no group in the recording: %v", jr.Steps[0].Log) | |
| 378 | + | } | |
| 379 | + | got, res := runLocal(t, yaml, jr.Name, nil) | |
| 380 | + | if res.Conclusion != ConclusionSuccess || len(got.steps) == 0 { | |
| 381 | + | t.Fatalf("job did not run: %+v %v", res, got.names()) | |
| 382 | + | } | |
| 383 | + | if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") { | |
| 384 | + | t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n")) | |
| 385 | + | } | |
| 386 | + | }) | |
| 387 | + | n++ | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | if n < 20 { | |
| 391 | + | t.Fatalf("only %d jobs in the recording", n) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group. | |
| 396 | + | func permissionsGroup(log []string) []string { | |
| 397 | + | for i, l := range log { | |
| 398 | + | if l != "##[group]GITHUB_TOKEN Permissions" { | |
| 399 | + | continue | |
| 400 | + | } | |
| 401 | + | for k := i + 1; k < len(log); k++ { | |
| 402 | + | if log[k] == "##[endgroup]" { | |
| 403 | + | return log[i+1 : k] | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | return nil | |
| 408 | + | } | |
| @@ -20,26 +20,48 @@ import ( | |||||
| 20 | // conclusions and their logs with the recordings made on github.com. They need | 20 | // conclusions and their logs with the recordings made on github.com. They need | ||
| 21 | // a checkout of ermite-conformance (the corpus and recordings directories), | 21 | // a checkout of ermite-conformance (the corpus and recordings directories), | ||
| 22 | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | 22 | // found through ERMITE_CONFORMANCE_DIR or as a sibling of this repository; | ||
| 23 | - | // they skip without it. | 23 | + | // they skip without it (and fail when ERMITE_CONFORMANCE_DIR is wrong). |
| 24 | 24 | ||||
| 25 | func conformanceDir(t *testing.T) string { | 25 | func conformanceDir(t *testing.T) string { | ||
| 26 | t.Helper() | 26 | t.Helper() | ||
| 27 | - | cands := []string{os.Getenv("ERMITE_CONFORMANCE_DIR")} | 27 | + | return conformanceDirFor(t, "") |
| 28 | - | wd, _ := os.Getwd() | 28 | + | } |
| 29 | - | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | 29 | + | |
| 30 | - | cands = append(cands, | 30 | + | // conformanceDirFor is conformanceDir for a checkout that has the recording of |
| 31 | - | filepath.Join(d, "ermite-conformance", ".worktrees", "corpus"), | 31 | + | // scenario (any checkout with a corpus when scenario is empty). The checkout is |
| 32 | - | filepath.Join(d, "ermite-conformance")) | 32 | + | // ERMITE_CONFORMANCE_DIR when set (the test fails if it lacks the corpus or the |
| 33 | + | // recording: a wrong path must not look like a pass), else the main checkout | |||
| 34 | + | // ermite-conformance next to this repository (skipped when it has none). | |||
| 35 | + | func conformanceDirFor(t *testing.T, scenario string) string { | |||
| 36 | + | t.Helper() | |||
| 37 | + | has := func(dir string) (corpus, rec bool) { | |||
| 38 | + | if st, err := os.Stat(filepath.Join(dir, "corpus")); err == nil && st.IsDir() { | |||
| 39 | + | corpus = true | |||
| 40 | + | } | |||
| 41 | + | if scenario == "" { | |||
| 42 | + | return corpus, true | |||
| 43 | + | } | |||
| 44 | + | _, err := os.Stat(filepath.Join(dir, "recordings", scenario+".json")) | |||
| 45 | + | return corpus, err == nil | |||
| 33 | } | 46 | } | ||
| 34 | - | for _, c := range cands { | 47 | + | if dir := os.Getenv("ERMITE_CONFORMANCE_DIR"); dir != "" { |
| 35 | - | if c == "" { | 48 | + | corpus, rec := has(dir) |
| 36 | - | continue | 49 | + | if !corpus { |
| 50 | + | t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no corpus directory", dir) | |||
| 51 | + | } | |||
| 52 | + | if !rec { | |||
| 53 | + | t.Fatalf("ERMITE_CONFORMANCE_DIR=%s has no recordings/%s.json", dir, scenario) | |||
| 37 | } | 54 | } | ||
| 38 | - | if st, err := os.Stat(filepath.Join(c, "corpus")); err == nil && st.IsDir() { | 55 | + | return dir |
| 56 | + | } | |||
| 57 | + | wd, _ := os.Getwd() | |||
| 58 | + | for d := wd; d != "/" && d != "."; d = filepath.Dir(d) { | |||
| 59 | + | c := filepath.Join(d, "ermite-conformance") | |||
| 60 | + | if corpus, rec := has(c); corpus && rec { | |||
| 39 | return c | 61 | return c | ||
| 40 | } | 62 | } | ||
| 41 | } | 63 | } | ||
| 42 | - | t.Skip("ermite-conformance checkout not found (set ERMITE_CONFORMANCE_DIR)") | 64 | + | t.Skip("ermite-conformance checkout not found next to this repository (set ERMITE_CONFORMANCE_DIR)") |
| 43 | return "" | 65 | return "" | ||
| 44 | } | 66 | } | ||
| 45 | 67 | ||||
| @@ -163,6 +185,7 @@ func readFile(t *testing.T, p string) string { | |||||
| 163 | type recording struct { | 185 | type recording struct { | ||
| 164 | Pushes []struct { | 186 | Pushes []struct { | ||
| 165 | Runs []struct { | 187 | Runs []struct { | ||
| 188 | + | Workflow string `json:"workflow"` | |||
| 166 | Conclusion string `json:"conclusion"` | 189 | Conclusion string `json:"conclusion"` | ||
| 167 | Jobs []struct { | 190 | Jobs []struct { | ||
| 168 | Name string `json:"name"` | 191 | Name string `json:"name"` | ||
| @@ -324,3 +347,62 @@ func TestCorpusStatusInEnvIsRejected(t *testing.T) { | |||||
| 324 | t.Fatalf("%+v %v", res, rec.names()) | 347 | t.Fatalf("%+v %v", res, rec.names()) | ||
| 325 | } | 348 | } | ||
| 326 | } | 349 | } | ||
| 350 | + | ||||
| 351 | + | // TestCorpusPermissionsDisplay runs every job of every workflow of the | |||
| 352 | + | // permissions-display scenario and compares the GITHUB_TOKEN Permissions group | |||
| 353 | + | // of Set up job with the recording. The scenario has one workflow file per | |||
| 354 | + | // case, so compareWithRecording does not apply. Workflows GitHub rejects have | |||
| 355 | + | // no jobs in the recording and are not run. | |||
| 356 | + | func TestCorpusPermissionsDisplay(t *testing.T) { | |||
| 357 | + | needTools(t, "bash") | |||
| 358 | + | dir := conformanceDirFor(t, "permissions-display") | |||
| 359 | + | var rec recording | |||
| 360 | + | if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, "recordings", "permissions-display.json"))), &rec); err != nil { | |||
| 361 | + | t.Fatal(err) | |||
| 362 | + | } | |||
| 363 | + | n := 0 | |||
| 364 | + | for _, run := range rec.Pushes[0].Runs { | |||
| 365 | + | if len(run.Jobs) == 0 { | |||
| 366 | + | continue | |||
| 367 | + | } | |||
| 368 | + | file := run.Workflow | |||
| 369 | + | yaml := readFile(t, filepath.Join(dir, "corpus", "permissions-display", "tree", filepath.FromSlash(file))) | |||
| 370 | + | for _, jr := range run.Jobs { | |||
| 371 | + | t.Run(strings.TrimSuffix(filepath.Base(file), ".yml")+"/"+jr.Name, func(t *testing.T) { | |||
| 372 | + | if len(jr.Steps) == 0 { | |||
| 373 | + | t.Fatal("no steps in the recording") | |||
| 374 | + | } | |||
| 375 | + | want := permissionsGroup(jr.Steps[0].Log) | |||
| 376 | + | if want == nil { | |||
| 377 | + | t.Fatalf("no group in the recording: %v", jr.Steps[0].Log) | |||
| 378 | + | } | |||
| 379 | + | got, res := runLocal(t, yaml, jr.Name, nil) | |||
| 380 | + | if res.Conclusion != ConclusionSuccess || len(got.steps) == 0 { | |||
| 381 | + | t.Fatalf("job did not run: %+v %v", res, got.names()) | |||
| 382 | + | } | |||
| 383 | + | if have := permissionsGroup(got.steps[0].Log); strings.Join(have, "\n") != strings.Join(want, "\n") { | |||
| 384 | + | t.Errorf("got\n%s\nwant\n%s", strings.Join(have, "\n"), strings.Join(want, "\n")) | |||
| 385 | + | } | |||
| 386 | + | }) | |||
| 387 | + | n++ | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | if n < 20 { | |||
| 391 | + | t.Fatalf("only %d jobs in the recording", n) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // permissionsGroup returns the lines of the GITHUB_TOKEN Permissions group. | |||
| 396 | + | func permissionsGroup(log []string) []string { | |||
| 397 | + | for i, l := range log { | |||
| 398 | + | if l != "##[group]GITHUB_TOKEN Permissions" { | |||
| 399 | + | continue | |||
| 400 | + | } | |||
| 401 | + | for k := i + 1; k < len(log); k++ { | |||
| 402 | + | if log[k] == "##[endgroup]" { | |||
| 403 | + | return log[i+1 : k] | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | return nil | |||
| 408 | + | } | |||
M internal/executor/job.go +9 -26
| @@ -580,37 +580,20 @@ const ( | |||
| 580 | 580 | defaultCacheMode = "write" | |
| 581 | 581 | ) | |
| 582 | 582 | ||
| 583 | - | // defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a | |
| 584 | - | // "permissions" key, as recorded (JobExtension.cs:157-166 prints the | |
| 585 | - | // permissions in the order of the server's JSON). | |
| 586 | - | var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |
| 587 | - | ||
| 588 | 583 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | |
| 589 | 584 | // and the cache mode. | |
| 590 | 585 | // | |
| 591 | - | // Without a "permissions" key the group is the recorded default. With one | |
| 592 | - | // (including {} and read-all/write-all) the group is omitted, on purpose: the | |
| 593 | - | // runner only prints what the server gives it (system.github.token.permissions, | |
| 594 | - | // a JSON object, JobExtension.cs:154-166), and the sources at hand do not say | |
| 595 | - | // what the server gives for those cases: the display names of the scopes | |
| 596 | - | // ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the | |
| 597 | - | // levels the server grants on a push (Metadata is always read; write-all and | |
| 598 | - | // id-token depend on the repository's policy, the Permissions class of the | |
| 599 | - | // runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and | |
| 600 | - | // Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only | |
| 601 | - | // the default is in the recordings (221 of 221 jobs; no corpus scenario sets | |
| 602 | - | // permissions). A group made up from the workflow would be a guess; add the | |
| 603 | - | // mapping once a recording of a scenario with permissions exists. The | |
| 604 | - | // complete fix is the server sending the effective permissions in the Job | |
| 605 | - | // message. | |
| 586 | + | // GitHub's runner prints what the server sends as | |
| 587 | + | // system.github.token.permissions (JobExtension.cs:154-166); ermite's Job | |
| 588 | + | // message does not carry it, so the group is worked out from the workflow | |
| 589 | + | // with the rules recorded on github.com (tokenPermissionLines). It is always | |
| 590 | + | // printed: Metadata is listed even for "permissions: {}". | |
| 606 | 591 | func (j *job) printTokenInfo() { | |
| 607 | - | if j.wf.Permissions == nil && j.jb.Permissions == nil { | |
| 608 | - | j.out(1, "##[group]GITHUB_TOKEN Permissions") | |
| 609 | - | for _, p := range defaultTokenPermissions { | |
| 610 | - | j.out(1, p[0]+": "+p[1]) | |
| 611 | - | } | |
| 612 | - | j.out(1, "##[endgroup]") | |
| 592 | + | j.out(1, "##[group]GITHUB_TOKEN Permissions") | |
| 593 | + | for _, p := range tokenPermissionLines(j.wf, j.jb) { | |
| 594 | + | j.out(1, p.name+": "+p.level) | |
| 613 | 595 | } | |
| 596 | + | j.out(1, "##[endgroup]") | |
| 614 | 597 | // secret_source is a github context key (JobExtension.cs:172); the context | |
| 615 | 598 | // of a job from ermite lacks it, so it is added with the recorded value. | |
| 616 | 599 | src := j.state.GitHub("secret_source") | |
| @@ -580,37 +580,20 @@ const ( | |||||
| 580 | defaultCacheMode = "write" | 580 | defaultCacheMode = "write" | ||
| 581 | ) | 581 | ) | ||
| 582 | 582 | ||||
| 583 | - | // defaultTokenPermissions is the GITHUB_TOKEN of a workflow without a | |||
| 584 | - | // "permissions" key, as recorded (JobExtension.cs:157-166 prints the | |||
| 585 | - | // permissions in the order of the server's JSON). | |||
| 586 | - | var defaultTokenPermissions = [][2]string{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |||
| 587 | - | ||||
| 588 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | 583 | // printTokenInfo prints the GITHUB_TOKEN permissions group, the secret source | ||
| 589 | // and the cache mode. | 584 | // and the cache mode. | ||
| 590 | // | 585 | // | ||
| 591 | - | // Without a "permissions" key the group is the recorded default. With one | 586 | + | // GitHub's runner prints what the server sends as |
| 592 | - | // (including {} and read-all/write-all) the group is omitted, on purpose: the | 587 | + | // system.github.token.permissions (JobExtension.cs:154-166); ermite's Job |
| 593 | - | // runner only prints what the server gives it (system.github.token.permissions, | 588 | + | // message does not carry it, so the group is worked out from the workflow |
| 594 | - | // a JSON object, JobExtension.cs:154-166), and the sources at hand do not say | 589 | + | // with the rules recorded on github.com (tokenPermissionLines). It is always |
| 595 | - | // what the server gives for those cases: the display names of the scopes | 590 | + | // printed: Metadata is listed even for "permissions: {}". |
| 596 | - | // ("Pull requests" or "PullRequests", "Id-token" or "IdToken"), their order, the | |||
| 597 | - | // levels the server grants on a push (Metadata is always read; write-all and | |||
| 598 | - | // id-token depend on the repository's policy, the Permissions class of the | |||
| 599 | - | // runner only models the workflow side, Sdk/WorkflowParser/Permissions.cs and | |||
| 600 | - | // Conversion/PermissionsHelper.cs) and whether "none" scopes are listed. Only | |||
| 601 | - | // the default is in the recordings (221 of 221 jobs; no corpus scenario sets | |||
| 602 | - | // permissions). A group made up from the workflow would be a guess; add the | |||
| 603 | - | // mapping once a recording of a scenario with permissions exists. The | |||
| 604 | - | // complete fix is the server sending the effective permissions in the Job | |||
| 605 | - | // message. | |||
| 606 | func (j *job) printTokenInfo() { | 591 | func (j *job) printTokenInfo() { | ||
| 607 | - | if j.wf.Permissions == nil && j.jb.Permissions == nil { | 592 | + | j.out(1, "##[group]GITHUB_TOKEN Permissions") |
| 608 | - | j.out(1, "##[group]GITHUB_TOKEN Permissions") | 593 | + | for _, p := range tokenPermissionLines(j.wf, j.jb) { |
| 609 | - | for _, p := range defaultTokenPermissions { | 594 | + | j.out(1, p.name+": "+p.level) |
| 610 | - | j.out(1, p[0]+": "+p[1]) | |||
| 611 | - | } | |||
| 612 | - | j.out(1, "##[endgroup]") | |||
| 613 | } | 595 | } | ||
| 596 | + | j.out(1, "##[endgroup]") | |||
| 614 | // secret_source is a github context key (JobExtension.cs:172); the context | 597 | // secret_source is a github context key (JobExtension.cs:172); the context | ||
| 615 | // of a job from ermite lacks it, so it is added with the recorded value. | 598 | // of a job from ermite lacks it, so it is added with the recorded value. | ||
| 616 | src := j.state.GitHub("secret_source") | 599 | src := j.state.GitHub("secret_source") | ||
M internal/executor/job_test.go +73 -11
| @@ -696,19 +696,81 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) { | |||
| 696 | 696 | } | |
| 697 | 697 | } | |
| 698 | 698 | ||
| 699 | - | // Permissions the workflow sets are not the default ones: no group is printed | |
| 700 | - | // rather than a wrong one. | |
| 701 | - | func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) { | |
| 699 | + | // The expected groups of the whole-token cases, as recorded on github.com | |
| 700 | + | // (recordings/permissions-display.json: p-read-all, p-write-all). | |
| 701 | + | const ( | |
| 702 | + | recordedReadAll = `Actions: read | |
| 703 | + | ArtifactMetadata: read | |
| 704 | + | Attestations: read | |
| 705 | + | Checks: read | |
| 706 | + | CodeQuality: read | |
| 707 | + | Contents: read | |
| 708 | + | Deployments: read | |
| 709 | + | Discussions: read | |
| 710 | + | Drives: read | |
| 711 | + | Issues: read | |
| 712 | + | Metadata: read | |
| 713 | + | Models: read | |
| 714 | + | Packages: read | |
| 715 | + | Pages: read | |
| 716 | + | PullRequests: read | |
| 717 | + | RepositoryProjects: read | |
| 718 | + | SecurityEvents: read | |
| 719 | + | Statuses: read | |
| 720 | + | VulnerabilityAlerts: read` | |
| 721 | + | recordedWriteAll = `Actions: write | |
| 722 | + | ArtifactMetadata: write | |
| 723 | + | Attestations: write | |
| 724 | + | Checks: write | |
| 725 | + | CodeQuality: write | |
| 726 | + | Contents: write | |
| 727 | + | CopilotRequests: write | |
| 728 | + | Deployments: write | |
| 729 | + | Discussions: write | |
| 730 | + | Drives: write | |
| 731 | + | Issues: write | |
| 732 | + | Metadata: read | |
| 733 | + | Models: read | |
| 734 | + | Packages: write | |
| 735 | + | Pages: write | |
| 736 | + | PullRequests: write | |
| 737 | + | RepositoryProjects: write | |
| 738 | + | SecurityEvents: write | |
| 739 | + | Statuses: write | |
| 740 | + | VulnerabilityAlerts: read` | |
| 741 | + | ) | |
| 742 | + | ||
| 743 | + | // The GITHUB_TOKEN Permissions group follows the effective permissions of the | |
| 744 | + | // job (the job's replace the workflow's), as recorded on github.com | |
| 745 | + | // (recordings/permissions-display.json). | |
| 746 | + | func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) { | |
| 702 | 747 | needTools(t, "bash") | |
| 703 | - | for name, yaml := range map[string]string{ | |
| 704 | - | "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 705 | - | "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n", | |
| 706 | - | "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 707 | - | "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n", | |
| 748 | + | steps := " steps:\n - run: echo hi\n" | |
| 749 | + | for _, tc := range []struct { | |
| 750 | + | name, yaml, want string | |
| 751 | + | }{ | |
| 752 | + | {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"}, | |
| 753 | + | {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"}, | |
| 754 | + | {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"}, | |
| 755 | + | {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps, | |
| 756 | + | "Contents: write\nMetadata: read\nPackages: read"}, | |
| 757 | + | {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps, | |
| 758 | + | "Checks: read\nMetadata: read"}, | |
| 759 | + | {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps, | |
| 760 | + | "Metadata: read\nPullRequests: read"}, | |
| 761 | + | {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, recordedReadAll}, | |
| 762 | + | {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps, | |
| 763 | + | "Contents: read\nMetadata: read"}, | |
| 764 | + | {"read-all", "permissions: read-all\n" + wfHead + steps, recordedReadAll}, | |
| 765 | + | {"write-all", "permissions: write-all\n" + wfHead + steps, recordedWriteAll}, | |
| 766 | + | {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps, | |
| 767 | + | "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"}, | |
| 708 | 768 | } { | |
| 709 | - | got := setupLog(t, yaml, "j", nil) | |
| 710 | - | if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") { | |
| 711 | - | t.Errorf("%s:\n%s", name, got) | |
| 769 | + | want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", tc.want, | |
| 770 | + | "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory", | |
| 771 | + | "Prepare all required actions", "Complete job name: j"}, "\n") | |
| 772 | + | if got := setupLog(t, tc.yaml, "j", nil); got != want { | |
| 773 | + | t.Errorf("%s:\ngot\n%s\nwant\n%s", tc.name, got, want) | |
| 712 | 774 | } | |
| 713 | 775 | } | |
| 714 | 776 | } | |
| @@ -696,19 +696,81 @@ func TestSetUpJobSelfHostedNamesTheRunner(t *testing.T) { | |||||
| 696 | } | 696 | } | ||
| 697 | } | 697 | } | ||
| 698 | 698 | ||||
| 699 | - | // Permissions the workflow sets are not the default ones: no group is printed | 699 | + | // The expected groups of the whole-token cases, as recorded on github.com |
| 700 | - | // rather than a wrong one. | 700 | + | // (recordings/permissions-display.json: p-read-all, p-write-all). |
| 701 | - | func TestSetUpJobWithPermissionsPrintsNoDefaultGroup(t *testing.T) { | 701 | + | const ( |
| 702 | + | recordedReadAll = `Actions: read | |||
| 703 | + | ArtifactMetadata: read | |||
| 704 | + | Attestations: read | |||
| 705 | + | Checks: read | |||
| 706 | + | CodeQuality: read | |||
| 707 | + | Contents: read | |||
| 708 | + | Deployments: read | |||
| 709 | + | Discussions: read | |||
| 710 | + | Drives: read | |||
| 711 | + | Issues: read | |||
| 712 | + | Metadata: read | |||
| 713 | + | Models: read | |||
| 714 | + | Packages: read | |||
| 715 | + | Pages: read | |||
| 716 | + | PullRequests: read | |||
| 717 | + | RepositoryProjects: read | |||
| 718 | + | SecurityEvents: read | |||
| 719 | + | Statuses: read | |||
| 720 | + | VulnerabilityAlerts: read` | |||
| 721 | + | recordedWriteAll = `Actions: write | |||
| 722 | + | ArtifactMetadata: write | |||
| 723 | + | Attestations: write | |||
| 724 | + | Checks: write | |||
| 725 | + | CodeQuality: write | |||
| 726 | + | Contents: write | |||
| 727 | + | CopilotRequests: write | |||
| 728 | + | Deployments: write | |||
| 729 | + | Discussions: write | |||
| 730 | + | Drives: write | |||
| 731 | + | Issues: write | |||
| 732 | + | Metadata: read | |||
| 733 | + | Models: read | |||
| 734 | + | Packages: write | |||
| 735 | + | Pages: write | |||
| 736 | + | PullRequests: write | |||
| 737 | + | RepositoryProjects: write | |||
| 738 | + | SecurityEvents: write | |||
| 739 | + | Statuses: write | |||
| 740 | + | VulnerabilityAlerts: read` | |||
| 741 | + | ) | |||
| 742 | + | ||||
| 743 | + | // The GITHUB_TOKEN Permissions group follows the effective permissions of the | |||
| 744 | + | // job (the job's replace the workflow's), as recorded on github.com | |||
| 745 | + | // (recordings/permissions-display.json). | |||
| 746 | + | func TestSetUpJobPrintsTheTokenPermissionsGitHubGrants(t *testing.T) { | |||
| 702 | needTools(t, "bash") | 747 | needTools(t, "bash") | ||
| 703 | - | for name, yaml := range map[string]string{ | 748 | + | steps := " steps:\n - run: echo hi\n" |
| 704 | - | "workflow": "permissions:\n contents: write\n" + wfHead + " steps:\n - run: echo hi\n", | 749 | + | for _, tc := range []struct { |
| 705 | - | "job": wfHead + " permissions: read-all\n steps:\n - run: echo hi\n", | 750 | + | name, yaml, want string |
| 706 | - | "empty": "permissions: {}\n" + wfHead + " steps:\n - run: echo hi\n", | 751 | + | }{ |
| 707 | - | "write": "permissions: write-all\n" + wfHead + " steps:\n - run: echo hi\n", | 752 | + | {"no key", wfHead + steps, "Contents: read\nMetadata: read\nPackages: read"}, |
| 753 | + | {"workflow {}", "permissions: {}\n" + wfHead + steps, "Metadata: read"}, | |||
| 754 | + | {"job {}", "permissions: write-all\n" + wfHead + " permissions: {}\n" + steps, "Metadata: read"}, | |||
| 755 | + | {"workflow map", "permissions:\n packages: read\n contents: write\n" + wfHead + steps, | |||
| 756 | + | "Contents: write\nMetadata: read\nPackages: read"}, | |||
| 757 | + | {"none and id-token are not listed", wfHead + " permissions:\n issues: none\n id-token: write\n checks: read\n" + steps, | |||
| 758 | + | "Checks: read\nMetadata: read"}, | |||
| 759 | + | {"job replaces workflow", "permissions:\n contents: write\n issues: write\n" + wfHead + " permissions:\n pull-requests: read\n" + steps, | |||
| 760 | + | "Metadata: read\nPullRequests: read"}, | |||
| 761 | + | {"job read-all over workflow map", "permissions:\n contents: write\n" + wfHead + " permissions: read-all\n" + steps, recordedReadAll}, | |||
| 762 | + | {"workflow write-all, job narrows", "permissions: write-all\n" + wfHead + " permissions:\n contents: read\n" + steps, | |||
| 763 | + | "Contents: read\nMetadata: read"}, | |||
| 764 | + | {"read-all", "permissions: read-all\n" + wfHead + steps, recordedReadAll}, | |||
| 765 | + | {"write-all", "permissions: write-all\n" + wfHead + steps, recordedWriteAll}, | |||
| 766 | + | {"metadata is not asked for", wfHead + " permissions:\n copilot-requests: write\n security-events: read\n pull-requests: write\n" + steps, | |||
| 767 | + | "CopilotRequests: write\nMetadata: read\nPullRequests: write\nSecurityEvents: read"}, | |||
| 708 | } { | 768 | } { | ||
| 709 | - | got := setupLog(t, yaml, "j", nil) | 769 | + | want := strings.Join([]string{"Current runner version: 'test'", "##[group]GITHUB_TOKEN Permissions", tc.want, |
| 710 | - | if strings.Contains(got, "GITHUB_TOKEN Permissions") || !strings.Contains(got, "Secret source: Actions\nCache mode: write") { | 770 | + | "##[endgroup]", "Secret source: Actions", "Cache mode: write", "Prepare workflow directory", |
| 711 | - | t.Errorf("%s:\n%s", name, got) | 771 | + | "Prepare all required actions", "Complete job name: j"}, "\n") |
| 772 | + | if got := setupLog(t, tc.yaml, "j", nil); got != want { | |||
| 773 | + | t.Errorf("%s:\ngot\n%s\nwant\n%s", tc.name, got, want) | |||
| 712 | } | 774 | } | ||
| 713 | } | 775 | } | ||
| 714 | } | 776 | } | ||
A internal/executor/token_permissions.go +108 -0
| @@ -0,0 +1,108 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "sort" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/workflow" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it. | |
| 12 | + | type tokenScope struct { | |
| 13 | + | // key is the name in a workflow's "permissions" mapping. | |
| 14 | + | key string | |
| 15 | + | // display is the name "Set up job" prints. | |
| 16 | + | display string | |
| 17 | + | // writeAll is the level "write-all" grants: GitHub keeps models and | |
| 18 | + | // vulnerability-alerts at read, whatever the workflow asks. | |
| 19 | + | writeAll string | |
| 20 | + | // readAll is the level "read-all" grants, "" when it grants nothing. | |
| 21 | + | readAll string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for | |
| 25 | + | // "read-all" and "write-all" (recordings/permissions-display.json: p-read-all, | |
| 26 | + | // p-write-all). "id-token" is not here: it is never listed, with any level | |
| 27 | + | // (p-job-mixed asks for id-token: write). "copilot-requests" is listed for | |
| 28 | + | // write-all only (read-all grants nothing for it, and "read" is rejected). | |
| 29 | + | // "metadata" is not a scope a workflow can set (rejected); it is always read. | |
| 30 | + | var tokenScopes = []tokenScope{ | |
| 31 | + | {"actions", "Actions", "write", "read"}, | |
| 32 | + | {"artifact-metadata", "ArtifactMetadata", "write", "read"}, | |
| 33 | + | {"attestations", "Attestations", "write", "read"}, | |
| 34 | + | {"checks", "Checks", "write", "read"}, | |
| 35 | + | {"code-quality", "CodeQuality", "write", "read"}, | |
| 36 | + | {"contents", "Contents", "write", "read"}, | |
| 37 | + | {"copilot-requests", "CopilotRequests", "write", ""}, | |
| 38 | + | {"deployments", "Deployments", "write", "read"}, | |
| 39 | + | {"discussions", "Discussions", "write", "read"}, | |
| 40 | + | {"drives", "Drives", "write", "read"}, | |
| 41 | + | {"issues", "Issues", "write", "read"}, | |
| 42 | + | {"models", "Models", "read", "read"}, | |
| 43 | + | {"packages", "Packages", "write", "read"}, | |
| 44 | + | {"pages", "Pages", "write", "read"}, | |
| 45 | + | {"pull-requests", "PullRequests", "write", "read"}, | |
| 46 | + | {"repository-projects", "RepositoryProjects", "write", "read"}, | |
| 47 | + | {"security-events", "SecurityEvents", "write", "read"}, | |
| 48 | + | {"statuses", "Statuses", "write", "read"}, | |
| 49 | + | {"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"}, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // permissionLine is one line of the group: "<Display>: <level>". | |
| 53 | + | type permissionLine struct{ name, level string } | |
| 54 | + | ||
| 55 | + | // tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions" | |
| 56 | + | // group for the effective permissions of a job, as GitHub prints them on a | |
| 57 | + | // push by a repository owner (the recorded case): | |
| 58 | + | // | |
| 59 | + | // - the permissions of the job replace those of the workflow (no merge); | |
| 60 | + | // without either key the repository default applies; | |
| 61 | + | // - lines are sorted by display name (ordinal), Metadata included; | |
| 62 | + | // - Metadata is always listed, as read, whatever the workflow says; | |
| 63 | + | // - a scope set to "none" or left out is not listed, nor is id-token; | |
| 64 | + | // - "permissions: {}" lists Metadata only; | |
| 65 | + | // - "read-all" lists every scope but copilot-requests as read; | |
| 66 | + | // - "write-all" lists every scope as write except models and | |
| 67 | + | // vulnerability-alerts (read); | |
| 68 | + | // - a mapping lists its scopes with the given level; a scope that GitHub | |
| 69 | + | // does not know is left out (it rejects the workflow before this point | |
| 70 | + | // for the invalid ones it was shown). | |
| 71 | + | // | |
| 72 | + | // Not recorded: a private repository, a fork pull request, an organisation | |
| 73 | + | // whose default token is restricted differently, a "pull_request_target" | |
| 74 | + | // event. They change what the server grants; the workflow side is the same. | |
| 75 | + | func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine { | |
| 76 | + | p := jb.Permissions | |
| 77 | + | if p == nil { | |
| 78 | + | p = wf.Permissions | |
| 79 | + | } | |
| 80 | + | if p == nil { | |
| 81 | + | return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |
| 82 | + | } | |
| 83 | + | lines := []permissionLine{{"Metadata", "read"}} | |
| 84 | + | switch p.All { | |
| 85 | + | case "read-all": | |
| 86 | + | for _, s := range tokenScopes { | |
| 87 | + | if s.readAll != "" { | |
| 88 | + | lines = append(lines, permissionLine{s.display, s.readAll}) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | case "write-all": | |
| 92 | + | for _, s := range tokenScopes { | |
| 93 | + | lines = append(lines, permissionLine{s.display, s.writeAll}) | |
| 94 | + | } | |
| 95 | + | default: | |
| 96 | + | level := map[string]string{} | |
| 97 | + | for _, sc := range p.Scopes { | |
| 98 | + | level[sc.Scope] = sc.Level | |
| 99 | + | } | |
| 100 | + | for _, s := range tokenScopes { | |
| 101 | + | if l := level[s.key]; l == "read" || l == "write" { | |
| 102 | + | lines = append(lines, permissionLine{s.display, l}) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) | |
| 107 | + | return lines | |
| 108 | + | } | |
| @@ -0,0 +1,108 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "sort" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite-actions/workflow" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // tokenScope is a scope of the GITHUB_TOKEN as GitHub's runner lists it. | |||
| 12 | + | type tokenScope struct { | |||
| 13 | + | // key is the name in a workflow's "permissions" mapping. | |||
| 14 | + | key string | |||
| 15 | + | // display is the name "Set up job" prints. | |||
| 16 | + | display string | |||
| 17 | + | // writeAll is the level "write-all" grants: GitHub keeps models and | |||
| 18 | + | // vulnerability-alerts at read, whatever the workflow asks. | |||
| 19 | + | writeAll string | |||
| 20 | + | // readAll is the level "read-all" grants, "" when it grants nothing. | |||
| 21 | + | readAll string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // tokenScopes are the scopes GitHub lists in "GITHUB_TOKEN Permissions" for | |||
| 25 | + | // "read-all" and "write-all" (recordings/permissions-display.json: p-read-all, | |||
| 26 | + | // p-write-all). "id-token" is not here: it is never listed, with any level | |||
| 27 | + | // (p-job-mixed asks for id-token: write). "copilot-requests" is listed for | |||
| 28 | + | // write-all only (read-all grants nothing for it, and "read" is rejected). | |||
| 29 | + | // "metadata" is not a scope a workflow can set (rejected); it is always read. | |||
| 30 | + | var tokenScopes = []tokenScope{ | |||
| 31 | + | {"actions", "Actions", "write", "read"}, | |||
| 32 | + | {"artifact-metadata", "ArtifactMetadata", "write", "read"}, | |||
| 33 | + | {"attestations", "Attestations", "write", "read"}, | |||
| 34 | + | {"checks", "Checks", "write", "read"}, | |||
| 35 | + | {"code-quality", "CodeQuality", "write", "read"}, | |||
| 36 | + | {"contents", "Contents", "write", "read"}, | |||
| 37 | + | {"copilot-requests", "CopilotRequests", "write", ""}, | |||
| 38 | + | {"deployments", "Deployments", "write", "read"}, | |||
| 39 | + | {"discussions", "Discussions", "write", "read"}, | |||
| 40 | + | {"drives", "Drives", "write", "read"}, | |||
| 41 | + | {"issues", "Issues", "write", "read"}, | |||
| 42 | + | {"models", "Models", "read", "read"}, | |||
| 43 | + | {"packages", "Packages", "write", "read"}, | |||
| 44 | + | {"pages", "Pages", "write", "read"}, | |||
| 45 | + | {"pull-requests", "PullRequests", "write", "read"}, | |||
| 46 | + | {"repository-projects", "RepositoryProjects", "write", "read"}, | |||
| 47 | + | {"security-events", "SecurityEvents", "write", "read"}, | |||
| 48 | + | {"statuses", "Statuses", "write", "read"}, | |||
| 49 | + | {"vulnerability-alerts", "VulnerabilityAlerts", "read", "read"}, | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // permissionLine is one line of the group: "<Display>: <level>". | |||
| 53 | + | type permissionLine struct{ name, level string } | |||
| 54 | + | ||||
| 55 | + | // tokenPermissionLines returns the lines of the "GITHUB_TOKEN Permissions" | |||
| 56 | + | // group for the effective permissions of a job, as GitHub prints them on a | |||
| 57 | + | // push by a repository owner (the recorded case): | |||
| 58 | + | // | |||
| 59 | + | // - the permissions of the job replace those of the workflow (no merge); | |||
| 60 | + | // without either key the repository default applies; | |||
| 61 | + | // - lines are sorted by display name (ordinal), Metadata included; | |||
| 62 | + | // - Metadata is always listed, as read, whatever the workflow says; | |||
| 63 | + | // - a scope set to "none" or left out is not listed, nor is id-token; | |||
| 64 | + | // - "permissions: {}" lists Metadata only; | |||
| 65 | + | // - "read-all" lists every scope but copilot-requests as read; | |||
| 66 | + | // - "write-all" lists every scope as write except models and | |||
| 67 | + | // vulnerability-alerts (read); | |||
| 68 | + | // - a mapping lists its scopes with the given level; a scope that GitHub | |||
| 69 | + | // does not know is left out (it rejects the workflow before this point | |||
| 70 | + | // for the invalid ones it was shown). | |||
| 71 | + | // | |||
| 72 | + | // Not recorded: a private repository, a fork pull request, an organisation | |||
| 73 | + | // whose default token is restricted differently, a "pull_request_target" | |||
| 74 | + | // event. They change what the server grants; the workflow side is the same. | |||
| 75 | + | func tokenPermissionLines(wf *workflow.Workflow, jb *workflow.Job) []permissionLine { | |||
| 76 | + | p := jb.Permissions | |||
| 77 | + | if p == nil { | |||
| 78 | + | p = wf.Permissions | |||
| 79 | + | } | |||
| 80 | + | if p == nil { | |||
| 81 | + | return []permissionLine{{"Contents", "read"}, {"Metadata", "read"}, {"Packages", "read"}} | |||
| 82 | + | } | |||
| 83 | + | lines := []permissionLine{{"Metadata", "read"}} | |||
| 84 | + | switch p.All { | |||
| 85 | + | case "read-all": | |||
| 86 | + | for _, s := range tokenScopes { | |||
| 87 | + | if s.readAll != "" { | |||
| 88 | + | lines = append(lines, permissionLine{s.display, s.readAll}) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | case "write-all": | |||
| 92 | + | for _, s := range tokenScopes { | |||
| 93 | + | lines = append(lines, permissionLine{s.display, s.writeAll}) | |||
| 94 | + | } | |||
| 95 | + | default: | |||
| 96 | + | level := map[string]string{} | |||
| 97 | + | for _, sc := range p.Scopes { | |||
| 98 | + | level[sc.Scope] = sc.Level | |||
| 99 | + | } | |||
| 100 | + | for _, s := range tokenScopes { | |||
| 101 | + | if l := level[s.key]; l == "read" || l == "write" { | |||
| 102 | + | lines = append(lines, permissionLine{s.display, l}) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | sort.Slice(lines, func(a, b int) bool { return lines[a].name < lines[b].name }) | |||
| 107 | + | return lines | |||
| 108 | + | } | |||