Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ zot can be configured to use the above providers with:
}
```


To login with either provider use http://127.0.0.1:8080/zot/auth/login?provider=\<provider\>&callback_ui=/home
for example to login with github use http://127.0.0.1:8080/zot/auth/login?provider=github&callback_ui=/home

Expand All @@ -441,6 +442,29 @@ for example github callback url would be http://127.0.0.1:8080/zot/auth/callback

If network policy doesn't allow inbound connections, this callback wont work!

#### GitHub Teams in Access Control

When authenticating with the GitHub provider, if you include the `read:org` scope, zot will fetch both the user's Organization memberships and their Team memberships.
Team memberships are formatted as `<organization>/<team-slug>` and added to the user's groups. You can use these in your access control policies. For example, if a user belongs to the `Infra` team in the `myorg` organization, the group name will be `myorg/infra`.
Comment thread
rchincha marked this conversation as resolved.
Group strings preserve GitHub-provided `login`/`slug` casing (no lowercasing is applied), so policy group values must match that exact casing.

```json
{
"accessControl": {
"repositories": {
"myorg/infrastructure/**": {
"policies": [
{
"groups": ["myorg/infra"],
"actions": ["read", "create", "update", "delete"]
}
]
}
}
}
}
```

dex is an identity service that uses OpenID Connect to drive authentication for other apps https://github.com/dexidp/dex
To setup dex service see https://dexidp.io/docs/getting-started/

Expand Down
85 changes: 78 additions & 7 deletions pkg/api/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -1076,18 +1076,89 @@ func GetGithubUserInfo(ctx context.Context, client *github.Client, log log.Logge
}
}

orgs, _, err := client.Organizations.List(ctx, "", nil)
if err != nil {
log.Error().Msg("failed to set user record for empty email value")
log.Debug().Int("emailCount", len(userEmails)).Bool("hasPrimaryEmail", primaryEmail != "").
Msg("fetched github user emails")
Comment thread
rchincha marked this conversation as resolved.

return "", []string{}, err
groups := []string{}

orgsOpt := &github.ListOptions{PerPage: 100}
for {
orgs, resp, err := client.Organizations.List(ctx, "", orgsOpt)
if err != nil {
var ghErr *github.ErrorResponse

if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusForbidden {
log.Warn().Err(err).Msg("skipping github orgs: read:org scope not granted or access denied")

break
}

log.Error().Err(err).Msg("failed to fetch github organizations")

return "", []string{}, err
}

for _, org := range orgs {
if org.Login != nil {
groups = append(groups, *org.Login)
}
}
Comment thread
rchincha marked this conversation as resolved.

log.Debug().Int("orgsInPage", len(orgs)).Int("nextPage", func() int {
if resp == nil {
return 0
}

return resp.NextPage
}()).Msg("processed github organization page")

if resp == nil || resp.NextPage == 0 {
break
}

orgsOpt.Page = resp.NextPage
}

groups := []string{}
for _, org := range orgs {
groups = append(groups, *org.Login)
teamsOpt := &github.ListOptions{PerPage: 100}
for {
teams, resp, err := client.Teams.ListUserTeams(ctx, teamsOpt)
if err != nil {
var ghErr *github.ErrorResponse

if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusForbidden {
log.Warn().Err(err).Msg("skipping github teams: read:org scope not granted or access denied")

break
}

log.Error().Err(err).Msg("failed to fetch user teams")

return "", []string{}, err
}

for _, team := range teams {
if team.Organization != nil && team.Organization.Login != nil && team.Slug != nil {
groups = append(groups, fmt.Sprintf("%s/%s", *team.Organization.Login, *team.Slug))
}
}
Comment thread
rchincha marked this conversation as resolved.
Comment thread
rchincha marked this conversation as resolved.

log.Debug().Int("teamsInPage", len(teams)).Int("nextPage", func() int {
if resp == nil {
return 0
}

return resp.NextPage
}()).Msg("processed github team page")

if resp == nil || resp.NextPage == 0 {
break
}

teamsOpt.Page = resp.NextPage
}

log.Debug().Int("totalGroups", len(groups)).Msg("computed github groups for user")

return primaryEmail, groups, nil
}

Expand Down
171 changes: 164 additions & 7 deletions pkg/api/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13007,26 +13007,129 @@ func TestGetGithubUserInfo(t *testing.T) {
},
},
),
mock.WithRequestMatch(
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
[]github.Organization{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

page := r.URL.Query().Get("page")
if page == "" || page == "1" {
w.Header().Set("Link", `<https://api.github.com/user/orgs?page=2>; rel="next"`)
_, _ = w.Write([]byte(`[{"login": "MyOrg"}]`))

return
}

_, _ = w.Write([]byte(`[{"login": "AnotherOrg"}]`))
}),
),
mock.WithRequestMatchHandler(
mock.EndpointPattern{
Pattern: "/user/teams",
Method: "GET",
},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

page := r.URL.Query().Get("page")
if page == "" || page == "1" {
w.Header().Set("Link", `<https://api.github.com/user/teams?page=2>; rel="next"`)
_, _ = w.Write([]byte(`[{"slug": "infra", "organization": {"login": "MyOrg"}}]`))

return
}

_, _ = w.Write([]byte(`[{"slug": "platform", "organization": {"login": "MyOrg"}}]`))
}),
),
)

client := github.NewClient(mockedHTTPClient)

email, groups, err := api.GetGithubUserInfo(context.Background(), client, log.NewTestLogger())
So(err, ShouldBeNil)
So(email, ShouldEqual, "test@test")
So(groups, ShouldContain, "MyOrg")
So(groups, ShouldContain, "AnotherOrg")
So(groups, ShouldContain, "MyOrg/infra")
So(groups, ShouldContain, "MyOrg/platform")
})

Convey("github ListEmails internal server error", t, func() {
mockedHTTPClient := mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserEmails,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusInternalServerError,
"github error",
)
}),
),
)

client := github.NewClient(mockedHTTPClient)

_, _, err := api.GetGithubUserInfo(context.Background(), client, log.NewTestLogger())
So(err, ShouldNotBeNil)
})

Convey("github ListOrgs forbidden", t, func() {
mockedHTTPClient := mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserEmails,
[]github.UserEmail{
{
Login: new("testOrg"),
Email: new("test@test"),
Primary: new(true),
},
},
),
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusForbidden,
"github error",
)
}),
),
mock.WithRequestMatchHandler(
mock.EndpointPattern{
Pattern: "/user/teams",
Method: "GET",
},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"slug": "infra", "organization": {"login": "MyOrg"}}]`))
}),
),
)

client := github.NewClient(mockedHTTPClient)

_, _, err := api.GetGithubUserInfo(context.Background(), client, log.NewTestLogger())
email, groups, err := api.GetGithubUserInfo(context.Background(), client, log.NewTestLogger())
So(err, ShouldBeNil)
So(email, ShouldEqual, "test@test")
So(groups, ShouldNotContain, "MyOrg")
So(groups, ShouldContain, "MyOrg/infra")
})

Convey("github ListEmails error", t, func() {
Convey("github ListOrgs internal server error", t, func() {
mockedHTTPClient := mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.WithRequestMatch(
mock.GetUserEmails,
[]github.UserEmail{
{
Email: new("test@test"),
Primary: new(true),
},
},
),
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
Expand All @@ -13043,7 +13146,7 @@ func TestGetGithubUserInfo(t *testing.T) {
So(err, ShouldNotBeNil)
})

Convey("github ListEmails error", t, func() {
Convey("github ListUserTeams forbidden", t, func() {
mockedHTTPClient := mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserEmails,
Expand All @@ -13054,8 +13157,62 @@ func TestGetGithubUserInfo(t *testing.T) {
},
},
),
mock.WithRequestMatch(
mock.GetUserOrgs,
[]github.Organization{
{
Login: new("MyOrg"),
},
},
),
mock.WithRequestMatchHandler(
mock.EndpointPattern{
Pattern: "/user/teams",
Method: "GET",
},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusForbidden,
"github error",
)
}),
),
)

client := github.NewClient(mockedHTTPClient)

email, groups, err := api.GetGithubUserInfo(context.Background(), client, log.NewTestLogger())
So(err, ShouldBeNil)
So(email, ShouldEqual, "test@test")
So(groups, ShouldContain, "MyOrg")
So(groups, ShouldNotContain, "MyOrg/infra")
})

Convey("github ListUserTeams internal server error", t, func() {
mockedHTTPClient := mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserEmails,
[]github.UserEmail{
{
Email: new("test@test"),
Primary: new(true),
},
},
),
mock.WithRequestMatch(
mock.GetUserOrgs,
[]github.Organization{
{
Login: new("MyOrg"),
},
},
),
mock.WithRequestMatchHandler(
mock.EndpointPattern{
Pattern: "/user/teams",
Method: "GET",
},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
Expand Down
Loading