Skip to content

Commit

Permalink
Merge pull request #71 from vshn/feat/keycloak-23
Browse files Browse the repository at this point in the history
Support API changes in Keycloak 23
  • Loading branch information
HappyTetrahedron authored Apr 9, 2024
2 parents e43b0bf + 61d5401 commit 61bcaa2
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 4 deletions.
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
github.com/google/gnostic v0.6.9 // indirect
github.com/google/safetext v0.0.0-20230106111101-7156a760e523 // indirect
github.com/jarcoal/httpmock v1.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
)

Expand All @@ -40,7 +41,7 @@ require (
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/go-resty/resty/v2 v2.7.0
github.com/gobuffalo/flect v1.0.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
Expand All @@ -62,7 +63,7 @@ require (
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+h
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
Expand Down
30 changes: 30 additions & 0 deletions keycloak/ZZ_mock_gocloak_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 78 additions & 2 deletions keycloak/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package keycloak
import (
"context"
"fmt"
"strconv"
"strings"

"github.com/Nerzal/gocloak/v13"
"github.com/go-resty/resty/v2"
)

// Group is a representation of a group in keycloak
Expand Down Expand Up @@ -129,12 +131,16 @@ type GoCloak interface {
UpdateUser(ctx context.Context, accessToken, realm string, user gocloak.User) error
AddUserToGroup(ctx context.Context, token, realm, userID, groupID string) error
DeleteUserFromGroup(ctx context.Context, token, realm, userID, groupID string) error
GetServerInfo(ctx context.Context, accessToken string) (*gocloak.ServerInfoRepresentation, error)

GetRequestWithBearerAuth(ctx context.Context, token string) *resty.Request
}

// Client interacts with the Keycloak API
type Client struct {
Client GoCloak

Host string
Realm string
// LoginRealm is used for the client to authenticate against keycloak. If not set Realm is used.
LoginRealm string
Expand All @@ -152,6 +158,7 @@ func NewClient(host, realm, username, password string) Client {
return Client{
Client: gocloak.NewClient(host),
Realm: realm,
Host: strings.TrimRight(host, "/"),
Username: username,
Password: password,
}
Expand Down Expand Up @@ -272,8 +279,9 @@ func (c Client) DeleteGroup(ctx context.Context, path ...string) error {
return c.Client.DeleteGroup(ctx, token.AccessToken, c.Realm, *found.ID)
}

// ListGroups returns all Keycloak groups in the realm.
// This is potentially very expensive, as it needs to iterate over all groups to get their members.
// ListGroups returns all top-level Keycloak groups in the realm and their direct children.
// More deeply nested children are not returned.
// This is potentially very expensive, as it needs to iterate over all groups to get their members and sub groups.
func (c Client) ListGroups(ctx context.Context) ([]Group, error) {
token, err := c.login(ctx)
if err != nil {
Expand All @@ -286,6 +294,26 @@ func (c Client) ListGroups(ctx context.Context) ([]Group, error) {
return nil, err
}

serverInfo, err := c.Client.GetServerInfo(ctx, token.AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to fetch version information: %w", err)
}

majorVersion, err := strconv.Atoi(strings.Split(*serverInfo.SystemInfo.Version, ".")[0])
if err != nil {
return nil, fmt.Errorf("failed to parse version information: %w", err)
}

if majorVersion >= 23 {
for _, g := range groups {
subgroups, err := c.getChildGroups(ctx, token, *g.ID)
if err != nil {
return nil, fmt.Errorf("failed to fetch sub groups: %w", err)
}
g.SubGroups = &subgroups
}
}

rootGroups := c.filterTreeWithRoot(groups)
if rootGroups == nil {
return nil, fmt.Errorf("could not find root group %q", c.RootGroup)
Expand Down Expand Up @@ -358,6 +386,54 @@ func (c Client) getGroup(ctx context.Context, token *gocloak.JWT, toSearch Group
return find(g), nil
}

func (c Client) getChildGroups(ctx context.Context, token *gocloak.JWT, groupID string) ([]gocloak.Group, error) {
var result []*gocloak.Group
childGroupsUrl := strings.Join([]string{c.Host, "admin", "realms", c.Realm, "groups", groupID, "children"}, "/")
resp, err := c.Client.GetRequestWithBearerAuth(ctx, token.AccessToken).
SetResult(&result).
Get(childGroupsUrl)

if err != nil {
return nil, &gocloak.APIError{
Code: 0,
Message: "could not retrieve child groups",
Type: gocloak.ParseAPIErrType(err),
}
}

if resp == nil {
return nil, &gocloak.APIError{
Message: "empty response",
Type: gocloak.ParseAPIErrType(err),
}
}

if resp.IsError() {
var msg string

if e, ok := resp.Error().(*gocloak.HTTPErrorResponse); ok && e.NotEmpty() {
msg = fmt.Sprintf("%s: %s", resp.Status(), e)
} else {
msg = resp.Status()
}

return nil, &gocloak.APIError{
Code: resp.StatusCode(),
Message: msg,
Type: gocloak.ParseAPIErrType(err),
}
}

groupList := make([]gocloak.Group, len(result))

for i := 0; i < len(result); i++ {
groupList[i] = *result[i]

}

return groupList, nil
}

func (c Client) getGroupAndMembers(ctx context.Context, token *gocloak.JWT, toFind Group) (*gocloak.Group, []*gocloak.User, error) {
group, err := c.getGroup(ctx, token, toFind)
if err != nil || group == nil {
Expand Down
70 changes: 70 additions & 0 deletions keycloak/client_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

gocloak "github.com/Nerzal/gocloak/v13"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand All @@ -22,8 +23,12 @@ func TestListGroups_simple(t *testing.T) {
mKeycloak := NewMockGoCloak(ctrl)
c := Client{
Client: mKeycloak,
Host: "https://example.com",
Realm: "myrealm",
}

mockGetServerInfo(mKeycloak, "22.0.0")

gs := []*gocloak.Group{
newGocloakGroup("Foo Inc.", "foo-id", "foo-gmbh"),
newGocloakGroup("Bar Inc.", "bar-id", "bar-gmbh"),
Expand Down Expand Up @@ -64,6 +69,63 @@ func TestListGroups_simple(t *testing.T) {
assert.Equal(t, "user-2", res[2].Members[1].Username)
}

func TestListGroups_simple_keycloak23(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

rst := setupHttpMock()
defer httpmock.DeactivateAndReset()

mKeycloak := NewMockGoCloak(ctrl)
c := Client{
Client: mKeycloak,
Host: "https://example.com",
Realm: "myrealm",
}

subGroups := &[]gocloak.Group{*newGocloakGroup("Parent GmbH", "qux-id", "parent-gmbh", "qux-team")}
mockGetServerInfo(mKeycloak, "23.0.0")
setupChildGroupResponse(c, "foo-id", make([]gocloak.Group, 0))
setupChildGroupResponse(c, "bar-id", make([]gocloak.Group, 0))
setupChildGroupResponse(c, "parent-id", *subGroups)

gs := []*gocloak.Group{
newGocloakGroup("Foo Inc.", "foo-id", "foo-gmbh"),
newGocloakGroup("Bar Inc.", "bar-id", "bar-gmbh"),
newGocloakGroup("", "parent-id", "parent-gmbh"),
}
mockLogin(mKeycloak, c)
mockListGroups(mKeycloak, c, gs)
mockKeycloakSubgroups(mKeycloak, rst, 3)
for i, id := range []string{"foo-id", "bar-id", "parent-id", "qux-id"} {
us := []*gocloak.User{}
for j := 0; j < i; j++ {
us = append(us, &gocloak.User{
ID: gocloak.StringP(fmt.Sprintf("id-%d", i)),
Username: gocloak.StringP(fmt.Sprintf("user-%d", i)),
})
}
mockGetGroupMembers(mKeycloak, c, id, us)
}

res, err := c.ListGroups(context.TODO())
require.NoError(t, err)

assert.Len(t, res, 4)
assert.Equal(t, "/foo-gmbh", res[0].Path())
assert.Equal(t, "/bar-gmbh", res[1].Path())
assert.Equal(t, "/parent-gmbh", res[2].Path())
assert.Equal(t, "/parent-gmbh/qux-team", res[3].Path())

assert.Len(t, res[0].Members, 0)
assert.Len(t, res[1].Members, 1)
assert.Len(t, res[2].Members, 2)
assert.Len(t, res[3].Members, 3)

assert.Equal(t, "user-1", res[1].Members[0].Username)
assert.Equal(t, "user-2", res[2].Members[1].Username)
}

func TestListGroups_RootGroup(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
Expand All @@ -72,8 +134,11 @@ func TestListGroups_RootGroup(t *testing.T) {
c := Client{
Client: mKeycloak,
RootGroup: "root-group",
Host: "https://example.com",
Realm: "myrealm",
}

mockGetServerInfo(mKeycloak, "22.0.0")
gs := []*gocloak.Group{
newGocloakGroup("Foo Inc.", "foo-id", "foo-gmbh"),
func() *gocloak.Group {
Expand Down Expand Up @@ -109,8 +174,11 @@ func TestListGroups_RootGroup_no_groups_under_root(t *testing.T) {
c := Client{
Client: mKeycloak,
RootGroup: "root-group",
Host: "https://example.com",
Realm: "myrealm",
}

mockGetServerInfo(mKeycloak, "22.0.0")
gs := []*gocloak.Group{
newGocloakGroup("Foo Inc.", "foo-id", "foo-gmbh"),
newGocloakGroup("", "root-group-id", "root-group"),
Expand All @@ -133,6 +201,8 @@ func TestListGroups_RootGroup_RootNotFound(t *testing.T) {
RootGroup: "root-group",
}

mockGetServerInfo(mKeycloak, "22.0.0")

gs := []*gocloak.Group{
newGocloakGroup("Foo Inc.", "foo-id", "foo-gmbh"),
}
Expand Down
2 changes: 2 additions & 0 deletions keycloak/client_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestLogin(t *testing.T) {
Return(nil).
AnyTimes()

mockGetServerInfo(mKeycloak, "22.0.0")
mockListGroups(mKeycloak, c, []*gocloak.Group{})

_, err := c.ListGroups(context.Background())
Expand Down Expand Up @@ -64,6 +65,7 @@ func TestLogin_WithLoginRealm(t *testing.T) {
Return(nil).
AnyTimes()

mockGetServerInfo(mKeycloak, "22.0.0")
mockListGroups(mKeycloak, c, []*gocloak.Group{})

_, err := c.ListGroups(context.Background())
Expand Down
Loading

0 comments on commit 61bcaa2

Please sign in to comment.