Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
48 changes: 41 additions & 7 deletions pkg/keycloakapi/keycloak_api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ type User struct {
LastName string `json:"lastName"`
}

// Internal DTO for Keycloak response.
type keycloakGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
SubGroups []keycloakGroup `json:"subGroups"`
}

// KeycloakAPIClient provides methods to interact with Keycloak's REST API.
type KeycloakAPIClient struct {
authClient *auth.KeycloakClient
Expand All @@ -41,14 +49,15 @@ func NewKeycloakAPIClient(authClient *auth.KeycloakClient) *KeycloakAPIClient {
}

// ListGroups retrieves all groups from Keycloak.
// ListGroups retrieves all groups from Keycloak and flattens groups + subgroups.
func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) {
token, err := kc.authClient.GetToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}

config := kc.authClient.Config()
url := fmt.Sprintf("%s/admin/realms/%s/groups", config.AuthURL, config.Realm)
url := fmt.Sprintf("%s/admin/realms/%s/groups?search=*", config.AuthURL, config.Realm)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
Expand All @@ -64,17 +73,22 @@ func (kc *KeycloakAPIClient) ListGroups(ctx context.Context) ([]Group, error) {
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to list users: %s: could not read response body: %w", resp.Status, err)
return nil, fmt.Errorf("failed to list groups: %s: could not read response body: %w", resp.Status, err)
}

return nil, fmt.Errorf("failed to list users: %s: %s", resp.Status, strings.TrimSpace(string(body)))
return nil, fmt.Errorf("failed to list groups: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}

var groups []Group
if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil {
var rootGroups []keycloakGroup
if err := json.NewDecoder(resp.Body).Decode(&rootGroups); err != nil {
return nil, fmt.Errorf("failed to decode groups response: %w", err)
}
return groups, nil

flattened := make([]Group, 0)
for _, g := range rootGroups {
flattened = append(flattened, flattenGroupTree(g)...)
}

return flattened, nil
}

// ListUsers retrieves all users from Keycloak.
Expand Down Expand Up @@ -113,3 +127,23 @@ func (kc *KeycloakAPIClient) ListUsers(ctx context.Context) ([]User, error) {
}
return users, nil
}

func flattenGroupTree(g keycloakGroup) []Group {
name := g.Path
if name == "" {
name = g.Name
}
Comment on lines +132 to +135

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reasoning for the special case? Is there a scenario where the path is empty (and the name is not)?

From looking at the test I would assume that path is at least / + <group name> for a root level group.


out := []Group{
{
ID: g.ID,
Name: name, // Use path as requested
},
}

for _, child := range g.SubGroups {
out = append(out, flattenGroupTree(child)...)
}

return out
}
57 changes: 57 additions & 0 deletions pkg/keycloakapi/keycloak_api_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,60 @@ func TestListUsers(t *testing.T) {
}
assert.ElementsMatch(t, wantUsers, users, "users mismatch")
}

func TestListGroups_FlattensNestedGroups_UsesPathAsName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/realms/test-realm/protocol/openid-connect/token":
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`{"access_token":"test-token","expires_in":3600}`))
require.NoError(t, err)
return

case "/admin/realms/test-realm/groups":
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, err := w.Write([]byte(`[
{
"id":"p1",
"name":"Platform",
"path":"/Platform",
"subGroupCount":2,
"subGroups":[
{"id":"c1","name":"Admins","path":"/Platform/Admins","subGroupCount":0,"subGroups":[]},
{"id":"c2","name":"Users","path":"/Platform/Users","subGroupCount":0,"subGroups":[]}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a subgroup to group Users to verify that we extract the subgroups recursively.

]
},
{
"id":"s1",
"name":"System",
"path":"/System",
"subGroupCount":0,
"subGroups":[]
}
]`))
require.NoError(t, err)
return
}

w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

authClient := newTestKeycloakClient(server.URL)
apiClient := NewKeycloakAPIClient(authClient)

got, err := apiClient.ListGroups(context.Background())
require.NoError(t, err)

want := []Group{
{ID: "p1", Name: "/Platform"},
{ID: "c1", Name: "/Platform/Admins"},
{ID: "c2", Name: "/Platform/Users"},
{ID: "s1", Name: "/System"},
}
assert.ElementsMatch(t, want, got)
}
Loading