Skip to content

Commit 72db63e

Browse files
authored
Merge pull request #75 from github/nodeselector-sso-fallback-anonymous-auth
Fall back to anonymous REST when SSO blocks actions/* resolution
2 parents 5d9a23f + 84ea812 commit 72db63e

10 files changed

Lines changed: 668 additions & 4 deletions

File tree

internal/ghapi/anon_fallback.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package ghapi
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"sync"
12+
)
13+
14+
// anonProbeCache caches per-owner results of anonymous access probes.
15+
// true = anonymous access confirmed working, false = not accessible.
16+
var anonProbeCache sync.Map // map[string]bool
17+
18+
// SSOFallbackEligible reports whether the given owner's repos can be
19+
// accessed anonymously when SSO blocks authenticated access. On first
20+
// call for an owner, it probes the GitHub API with an unauthenticated
21+
// request to determine accessibility, then caches the result.
22+
func (c *Client) SSOFallbackEligible(ctx context.Context, owner string) bool {
23+
key := c.anonBase() + "/" + owner
24+
if v, ok := anonProbeCache.Load(key); ok {
25+
return v.(bool)
26+
}
27+
28+
// Probe: unauthenticated HEAD to /orgs/{owner} — 200 means the org
29+
// is publicly visible and its public repos are anonymously accessible.
30+
probeURL := fmt.Sprintf("%s/orgs/%s", c.anonBase(), url.PathEscape(owner))
31+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, probeURL, nil)
32+
if err != nil {
33+
// Construction error — don't cache, let next call retry.
34+
return false
35+
}
36+
req.Header.Set("Accept", "application/vnd.github.v3+json")
37+
38+
resp, err := c.anonClient().Do(req)
39+
if err != nil {
40+
// Transport error (network, context canceled) — don't cache.
41+
return false
42+
}
43+
resp.Body.Close()
44+
45+
eligible := resp.StatusCode == http.StatusOK
46+
anonProbeCache.Store(key, eligible)
47+
return eligible
48+
}
49+
50+
// IsSAMLEnforcement reports whether err represents a SAML/SSO enforcement
51+
// block. It matches both REST 403s (api.HTTPError) and plain errors whose
52+
// message indicates SAML enforcement (e.g. from the GraphQL resolution path).
53+
func IsSAMLEnforcement(err error) bool {
54+
if err == nil {
55+
return false
56+
}
57+
msg := err.Error()
58+
if strings.Contains(msg, "SAML enforcement") || strings.Contains(msg, "SAML SSO") {
59+
// For HTTPErrors, further verify it's a 403.
60+
if code, ok := StatusCode(err); ok {
61+
return code == http.StatusForbidden
62+
}
63+
// Plain errors from GraphQL SAML detection: trust the message.
64+
return true
65+
}
66+
return false
67+
}
68+
69+
// anonBase returns the base URL for anonymous REST calls.
70+
// For stub-server hostnames (containing a port), it uses the hostname
71+
// directly without prepending "api.".
72+
func (c *Client) anonBase() string {
73+
if c.anonBaseURL != "" {
74+
return c.anonBaseURL
75+
}
76+
host := c.Hostname
77+
if host == "" {
78+
host = "github.com"
79+
}
80+
// Stub servers use IP:port — don't prepend "api." for those.
81+
if strings.Contains(host, ":") {
82+
return fmt.Sprintf("https://%s", host)
83+
}
84+
return fmt.Sprintf("https://api.%s", host)
85+
}
86+
87+
// anonClient returns the HTTP client for anonymous requests.
88+
func (c *Client) anonClient() *http.Client {
89+
if c.anonHTTP != nil {
90+
return c.anonHTTP
91+
}
92+
return http.DefaultClient
93+
}
94+
95+
// anonGet performs an unauthenticated GET and decodes JSON into dest.
96+
func (c *Client) anonGet(ctx context.Context, path string, dest any) error {
97+
u := c.anonBase() + "/" + path
98+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
99+
if err != nil {
100+
return err
101+
}
102+
req.Header.Set("Accept", "application/vnd.github.v3+json")
103+
104+
resp, err := c.anonClient().Do(req)
105+
if err != nil {
106+
return err
107+
}
108+
defer resp.Body.Close()
109+
110+
if resp.StatusCode != http.StatusOK {
111+
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, path)
112+
}
113+
return json.NewDecoder(resp.Body).Decode(dest)
114+
}
115+
116+
// anonListBranches fetches branches for a public repo without authentication.
117+
func (c *Client) anonListBranches(ctx context.Context, owner, repo string) ([]BranchHead, error) {
118+
var all []BranchHead
119+
for page := 1; page <= 3; page++ {
120+
path := fmt.Sprintf("repos/%s/%s/branches?per_page=100&page=%d",
121+
url.PathEscape(owner), url.PathEscape(repo), page)
122+
var resp []struct {
123+
Name string `json:"name"`
124+
Commit struct {
125+
SHA string `json:"sha"`
126+
} `json:"commit"`
127+
Protected bool `json:"protected"`
128+
}
129+
if err := c.anonGet(ctx, path, &resp); err != nil {
130+
return nil, fmt.Errorf("anonymous fallback listing branches for %s/%s: %w", owner, repo, err)
131+
}
132+
for _, b := range resp {
133+
all = append(all, BranchHead{Name: b.Name, SHA: b.Commit.SHA, Protected: b.Protected})
134+
}
135+
if len(resp) < 100 {
136+
break
137+
}
138+
}
139+
return all, nil
140+
}
141+
142+
// anonListTags fetches tags for a public repo without authentication.
143+
func (c *Client) anonListTags(ctx context.Context, owner, repo string) ([]TagEntry, error) {
144+
path := fmt.Sprintf("repos/%s/%s/tags?per_page=100",
145+
url.PathEscape(owner), url.PathEscape(repo))
146+
var resp []struct {
147+
Name string `json:"name"`
148+
Commit struct {
149+
SHA string `json:"sha"`
150+
} `json:"commit"`
151+
}
152+
if err := c.anonGet(ctx, path, &resp); err != nil {
153+
return nil, fmt.Errorf("anonymous fallback listing tags for %s/%s: %w", owner, repo, err)
154+
}
155+
tags := make([]TagEntry, 0, len(resp))
156+
for _, t := range resp {
157+
tags = append(tags, TagEntry{Name: t.Name, SHA: t.Commit.SHA})
158+
}
159+
return tags, nil
160+
}
161+
162+
// anonPeelTagObject determines whether sha is an annotated tag and, if so,
163+
// peels it to the underlying commit using unauthenticated REST.
164+
func (c *Client) anonPeelTagObject(ctx context.Context, owner, repo, sha string) (PeelTagObjectResult, error) {
165+
// First, determine the object type via GET /repos/{owner}/{repo}/git/tags/{sha}.
166+
// If it's not a tag (404), try the commit endpoint to confirm it's a commit.
167+
tagPath := fmt.Sprintf("repos/%s/%s/git/tags/%s",
168+
url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
169+
var tagResp struct {
170+
Object struct {
171+
Type string `json:"type"`
172+
SHA string `json:"sha"`
173+
} `json:"object"`
174+
}
175+
if err := c.anonGet(ctx, tagPath, &tagResp); err == nil {
176+
// It's an annotated tag — peel to the commit it points to.
177+
result := PeelTagObjectResult{Typename: "Tag"}
178+
if tagResp.Object.Type == "commit" {
179+
result.CommitOID = tagResp.Object.SHA
180+
}
181+
return result, nil
182+
}
183+
184+
// Not a tag object — check if it's a commit directly.
185+
commitPath := fmt.Sprintf("repos/%s/%s/git/commits/%s",
186+
url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
187+
var commitResp struct {
188+
SHA string `json:"sha"`
189+
}
190+
if err := c.anonGet(ctx, commitPath, &commitResp); err == nil {
191+
return PeelTagObjectResult{Typename: "Commit", CommitOID: commitResp.SHA}, nil
192+
}
193+
194+
// Can't determine type — return zero result like the GraphQL fallback.
195+
return PeelTagObjectResult{}, nil
196+
}
197+
198+
// anonCompareCommits reports whether sha is an ancestor of branchHeadSHA
199+
// using unauthenticated REST.
200+
func (c *Client) anonCompareCommits(ctx context.Context, owner, repo, sha, branchHeadSHA string) (bool, error) {
201+
path := fmt.Sprintf("repos/%s/%s/compare/%s...%s",
202+
url.PathEscape(owner), url.PathEscape(repo),
203+
url.PathEscape(sha), url.PathEscape(branchHeadSHA))
204+
var resp compareResponse
205+
if err := c.anonGet(ctx, path, &resp); err != nil {
206+
return false, err
207+
}
208+
return strings.EqualFold(resp.MergeBaseCommit.SHA, sha), nil
209+
}
210+
211+
// resolveAnonymous fetches the commit SHA and action.yml content for a
212+
// single ref using unauthenticated REST calls. This only works for public
213+
// repos and is used as a fallback when SSO blocks the authenticated path.
214+
func (c *Client) resolveAnonymous(ctx context.Context, ref ActionFileRequest) ActionFileResult {
215+
result := ActionFileResult{
216+
Owner: ref.Owner,
217+
Repo: ref.Repo,
218+
Path: ref.Path,
219+
Ref: ref.Ref,
220+
}
221+
222+
base := c.anonBase()
223+
224+
// Resolve ref → commit SHA via the commits endpoint.
225+
commitURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s",
226+
base,
227+
url.PathEscape(ref.Owner),
228+
url.PathEscape(ref.Repo),
229+
url.PathEscape(ref.Ref),
230+
)
231+
sha, err := c.anonGetCommitSHA(ctx, commitURL)
232+
if err != nil {
233+
result.Err = fmt.Errorf("anonymous fallback: %w", err)
234+
return result
235+
}
236+
result.CommitOID = sha
237+
238+
// Fetch action.yml (try .yml first, then .yaml).
239+
ymlPath := "action.yml"
240+
yamlPath := "action.yaml"
241+
if ref.Path != "" {
242+
ymlPath = ref.Path + "/action.yml"
243+
yamlPath = ref.Path + "/action.yaml"
244+
}
245+
246+
content, err := c.anonGetFileContent(ctx, base, ref.Owner, ref.Repo, sha, ymlPath)
247+
if err != nil {
248+
// Try .yaml extension.
249+
content, err = c.anonGetFileContent(ctx, base, ref.Owner, ref.Repo, sha, yamlPath)
250+
if err != nil {
251+
// Not fatal — some actions don't have action.yml (reusable workflows).
252+
return result
253+
}
254+
}
255+
result.ActionYML = content
256+
return result
257+
}
258+
259+
// anonGetCommitSHA fetches the commit SHA for a ref without authentication.
260+
func (c *Client) anonGetCommitSHA(ctx context.Context, url string) (string, error) {
261+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
262+
if err != nil {
263+
return "", err
264+
}
265+
req.Header.Set("Accept", "application/vnd.github.v3+json")
266+
267+
resp, err := c.anonClient().Do(req)
268+
if err != nil {
269+
return "", err
270+
}
271+
defer resp.Body.Close()
272+
273+
if resp.StatusCode != http.StatusOK {
274+
return "", fmt.Errorf("HTTP %d resolving commit", resp.StatusCode)
275+
}
276+
277+
var commit struct {
278+
SHA string `json:"sha"`
279+
}
280+
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
281+
return "", fmt.Errorf("decoding commit response: %w", err)
282+
}
283+
if commit.SHA == "" {
284+
return "", fmt.Errorf("empty SHA in response")
285+
}
286+
return commit.SHA, nil
287+
}
288+
289+
// anonGetFileContent fetches a file's content from a public repo without auth.
290+
func (c *Client) anonGetFileContent(ctx context.Context, base, owner, repo, ref, path string) (string, error) {
291+
// Escape each segment of the file path individually to preserve slashes.
292+
escapedPath := escapeContentPath(path)
293+
u := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s",
294+
base,
295+
url.PathEscape(owner),
296+
url.PathEscape(repo),
297+
escapedPath,
298+
url.QueryEscape(ref),
299+
)
300+
301+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
302+
if err != nil {
303+
return "", err
304+
}
305+
req.Header.Set("Accept", "application/vnd.github.v3.raw")
306+
307+
resp, err := c.anonClient().Do(req)
308+
if err != nil {
309+
return "", err
310+
}
311+
defer resp.Body.Close()
312+
313+
if resp.StatusCode != http.StatusOK {
314+
return "", fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, path)
315+
}
316+
317+
body, err := io.ReadAll(resp.Body)
318+
if err != nil {
319+
return "", fmt.Errorf("reading %s: %w", path, err)
320+
}
321+
return string(body), nil
322+
}
323+
324+
// escapeContentPath URL-escapes each segment of a slash-delimited file path,
325+
// preserving the slash separators.
326+
func escapeContentPath(p string) string {
327+
segments := strings.Split(p, "/")
328+
for i, s := range segments {
329+
segments[i] = url.PathEscape(s)
330+
}
331+
return strings.Join(segments, "/")
332+
}

0 commit comments

Comments
 (0)