Repository: Nerzal/gocloak
Pull request #348 (2022-04-01 15:14:04)

Add missing endpoints for client scopes -> scope mappings -> client roles
**Summary:** A few endpoints were missing from the client.

Thanks for this useful library! During usage, I noticed that some of the endpoints from the Keycloak Admin API were missing, which I added to a temporary fork & now want to contribute to upstream :)

Namely, the ones where you can assign client roles to a client scope. Realm roles were already implemented, so addition & testing was rather straightforward. The application we're developing requires these endpoints, so I can also confirm that it works in practice. Nevertheless, I added tests for the endpoints to the existing suite.

Reference for API spec: https://www.keycloak.org/docs-api/15.0/rest-api/index.html#_scope_mappings_resource

Regarding the change in the unrelated test: It seems that Keycloak requires the client roles to be passed in the format "client name/role", whereas previously it was only passed as "role". This might be a recent improvement/non-improvement in validation: I noticed that the Keycloak image in docker-compose it set to the "latest" tag. The old version consistently failed on my machine and on GitHub Actions. I confirmed that this seems to be the intended way to do it using [this utility method in the Keycloak source](https://github.com/keycloak/keycloak/blob/main/core/src/main/java/org/keycloak/representations/idm/authorization/UmaPermissionRepresentation.java#L53). I haven't in detail looked up which commit would have introduced such a change as it didn't seem necessary (but I can check it out if required).

Thank you!

Nerzal: Hi! 
Thank you for the contribution! 

Could you please try and update your branch using the current main branch? 
That should fix the CI Pipeline :) 
literalplus: Thanks for the hint! I rebased :)
codecov[bot]: # [Codecov](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal) Report
> Merging [#348](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal) (517425d) into [main](https://codecov.io/gh/Nerzal/gocloak/commit/c740220171ec1414a232fafb2543d213d23b8232?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal) (c740220) will **increase** coverage by `0.11%`.
> The diff coverage is `85.18%`.

```diff
@@            Coverage Diff             @@
##             main     #348      +/-   ##
==========================================
+ Coverage   76.85%   76.96%   +0.11%     
==========================================
  Files           4        4              
  Lines        2005     2032      +27     
==========================================
+ Hits         1541     1564      +23     
- Misses        320      322       +2     
- Partials      144      146       +2     
```


| [Impacted Files](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal) | Coverage Δ | |
|---|---|---|
| [client.go](https://codecov.io/gh/Nerzal/gocloak/pull/348/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal#diff-Y2xpZW50Lmdv) | `75.32% <85.18%> (+0.14%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal). Last update [c740220...517425d](https://codecov.io/gh/Nerzal/gocloak/pull/348?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nerzal).


Patch:
diff --git a/README.md b/README.md
index c7b2c6a..7df6e9a 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,7 @@ type GoCloak interface {
  CreateClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error
  CreateClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error
  CreateClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfCLientScope string, roles []Role) error
+ CreateClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role) error
 
  UpdateUser(ctx context.Context, accessToken, realm string, user User) error
  UpdateGroup(ctx context.Context, accessToken, realm string, updatedGroup Group) error
@@ -177,6 +178,7 @@ type GoCloak interface {
  DeleteClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error
  DeleteClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error
  DeleteClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfCLientScope string, roles []Role) error
+ DeleteClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, ifOfClient string, roles []Role) error
 
  GetClient(ctx context.Context, accessToken, realm, idOfClient string) (*Client, error)
  GetClientsDefaultScopes(ctx context.Context, token, realm, idOfClient string) ([]*ClientScope, error)
@@ -193,8 +195,10 @@ type GoCloak interface {
  GetClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string) ([]*Role, error)
  GetClientScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClient string) ([]*Role, error)
  GetClientScopesScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error)
+ GetClientScopesScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error)
  GetClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error)
  GetClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error)
+ GetClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error)
  GetClientScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error)
  GetClientSecret(ctx context.Context, token, realm, idOfClient string) (*CredentialRepresentation, error)
  GetClientServiceAccount(ctx context.Context, token, realm, idOfClient string) (*User, error)
diff --git a/client.go b/client.go
index 98c7c1a..6447f74 100644
--- a/client.go
+++ b/client.go
@@ -3777,3 +3777,65 @@ func (client *gocloak) UpdateRequiredAction(ctx context.Context, token string, r
 
 	return err
 }
+
+// CreateClientScopesScopeMappingsClientRoles attaches a client role to a client scope (not client's scope)
+func (client *gocloak) CreateClientScopesScopeMappingsClientRoles(
+	ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role,
+) error {
+	const errMessage = "could not create client-level roles to the client-scope"
+
+	resp, err := client.getRequestWithBearerAuth(ctx, token).
+		SetBody(roles).
+		Post(client.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient))
+
+	return checkForError(resp, err, errMessage)
+}
+
+// GetClientScopesScopeMappingsClientRolesAvailable returns available (i.e. not attached via
+// CreateClientScopesScopeMappingsClientRoles) client roles for a specific client, for a client scope
+// (not client's scope).
+func (client *gocloak) GetClientScopesScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) {
+	const errMessage = "could not get available client-level roles with the client-scope"
+
+	var result []*Role
+
+	resp, err := client.getRequestWithBearerAuth(ctx, token).
+		SetResult(&result).
+		Get(client.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient, "available"))
+
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// GetClientScopesScopeMappingsClientRoles returns attached client roles for a specific client, for a client scope
+// (not client's scope).
+func (client *gocloak) GetClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) {
+	const errMessage = "could not get client-level roles with the client-scope"
+
+	var result []*Role
+
+	resp, err := client.getRequestWithBearerAuth(ctx, token).
+		SetResult(&result).
+		Get(client.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient))
+
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// DeleteClientScopesScopeMappingsClientRoles removes attachment of client roles from a client scope
+// (not client's scope).
+func (client *gocloak) DeleteClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role) error {
+	const errMessage = "could not delete client-level roles from the client-scope"
+
+	resp, err := client.getRequestWithBearerAuth(ctx, token).
+		SetBody(roles).
+		Delete(client.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient))
+
+	return checkForError(resp, err, errMessage)
+}
diff --git a/gocloak.go b/gocloak.go
index a478854..91c4af3 100644
--- a/gocloak.go
+++ b/gocloak.go
@@ -86,6 +86,8 @@ type GoCloak interface {
 	CreateClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error
 	// CreateClientScopesScopeMappingsRealmRoles creates realm-level roles to the client-scope
 	CreateClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClientScope string, roles []Role) error
+	// CreateClientScopesScopeMappingsClientRoles creates client-level roles to the client-scope
+	CreateClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role) error
 	// CreateClientRepresentation creates a new client representation
 	CreateClientRepresentation(ctx context.Context, realm string) (*Client, error)
 
@@ -118,6 +120,8 @@ type GoCloak interface {
 	DeleteClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error
 	// DeleteClientScopesScopeMappingsRealmRoles deletes realm-level roles from the client-scope
 	DeleteClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClientScope string, roles []Role) error
+	// DeleteClientScopesScopeMappingsClientRoles deletes client-level roles from the client-scope
+	DeleteClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, ifOfClient string, roles []Role) error
 	// DeleteClientRepresentation deletes a given client representation
 	DeleteClientRepresentation(ctx context.Context, accessToken, realm, clientID string) error
 
@@ -155,10 +159,14 @@ type GoCloak interface {
 	GetClientScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClient string) ([]*Role, error)
 	// GetClientScopesScopeMappingsRealmRolesAvailable returns realm-level roles that are available to attach to this client-scope
 	GetClientScopesScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error)
+	// GetClientScopesScopeMappingsClientRolesAvailable returns client-level roles that are available to attach to this client-scope
+	GetClientScopesScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error)
 	// GetClientScopeMappingsClientRoles returns roles associated with a client’s scope
 	GetClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error)
 	// GetClientScopesScopeMappingsRealmRoles returns roles associated with a client-scope
 	GetClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error)
+	// GetClientScopesScopeMappingsClientRoles returns client roles associated with a client-scope
+	GetClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error)
 	// GetClientScopeMappingsClientRolesAvailable returns available roles associated with a client’s scope
 	GetClientScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error)
 	// GetClientSecret returns a client's secret


Test patch:
diff --git a/client_test.go b/client_test.go
index 6d94ff0..6a4209d 100644
--- a/client_test.go
+++ b/client_test.go
@@ -1665,7 +1665,7 @@ func Test_ClientScopeMappingsClientRoles(t *testing.T) {
 	token := GetAdminToken(t, client)
 	testClient := gocloak.Client{
 		ClientID:         GetRandomNameP("ClientID"),
-		BaseURL:          gocloak.StringP("http://example.com"),
+		BaseURL:          gocloak.StringP("https://example.com"),
 		FullScopeAllowed: gocloak.BoolP(false),
 	}
 	// Creating client
@@ -1811,6 +1811,112 @@ func Test_ClientScopeMappingsRealmRoles(t *testing.T) {
 	)
 }
 
+func CreateClientScopesMappingsClientRoles(
+	t *testing.T, client gocloak.GoCloak, scopeID, idOfClient string, roles []gocloak.Role,
+) func() {
+	token := GetAdminToken(t, client)
+	cfg := GetConfig(t)
+
+	// Creating client scope mappings
+	err := client.CreateClientScopesScopeMappingsClientRoles(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		scopeID,
+		idOfClient,
+		roles,
+	)
+	require.NoError(t, err, "CreateClientScopesScopeMappingsClientRoles failed")
+
+	tearDown := func() {
+		err = client.DeleteClientScopesScopeMappingsClientRoles(
+			context.Background(),
+			token.AccessToken,
+			cfg.GoCloak.Realm,
+			scopeID,
+			idOfClient,
+			roles,
+		)
+		require.NoError(t, err, "DeleteClientScopesScopeMappingsClientRoles failed")
+	}
+	return tearDown
+}
+
+// Test_ClientScopesMappingsClientRoles tests API calls related to client role attachment for a client scope.
+func Test_ClientScopesMappingsClientRoles(t *testing.T) {
+	cfg := GetConfig(t)
+	client := NewClientWithDebug(t)
+	token := GetAdminToken(t, client)
+
+	// Creating client roles (on shared client)
+	var roles []gocloak.Role
+	tearDownRole1, assignRoleName := CreateClientRole(t, client)
+	defer tearDownRole1()
+	role, err := client.GetClientRole(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		gocloakClientID,
+		assignRoleName,
+	)
+	require.NoError(t, err, "CreateClientRole failed")
+	roles = append(roles, *role)
+	tearDownRole2, noAssignRoleName := CreateClientRole(t, client)
+	defer tearDownRole2()
+	role, err = client.GetClientRole(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		gocloakClientID,
+		noAssignRoleName,
+	)
+	require.NoError(t, err, "GetClientRole after CreateClientRole failed")
+	roles = append(roles, *role)
+
+	// Creating scope
+	tearDownScope, scopeID := CreateClientScope(t, client, nil)
+	defer tearDownScope()
+
+	// Creating client roles for client scope mappings
+	onlyFirstRole := roles[:1]
+	tearDownMappings := CreateClientScopesMappingsClientRoles(t, client, scopeID, gocloakClientID, onlyFirstRole)
+	defer tearDownMappings()
+
+	// Check client roles
+	mappedRoles, err := client.GetClientScopesScopeMappingsClientRoles(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		scopeID,
+		gocloakClientID,
+	)
+	require.NoError(t, err, "GetClientScopesScopeMappingsClientRoles failed")
+	require.Len(
+		t, mappedRoles, len(onlyFirstRole),
+		"GetClientScopeMappingsClientRoles should return exact %s roles", len(onlyFirstRole),
+	)
+
+	clientRolesAvailable, err := client.GetClientScopesScopeMappingsClientRolesAvailable(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		scopeID,
+		gocloakClientID,
+	)
+	require.NoError(t, err, "GetClientScopesScopeMappingsClientRolesAvailable failed")
+	foundUnassignedRole := false
+	for _, roleAvailable := range clientRolesAvailable {
+		require.NotEqual(
+			t, assignRoleName, roleAvailable.Name,
+			"assigned role %v should not be available", assignRoleName,
+		)
+		if *roleAvailable.Name == noAssignRoleName {
+			foundUnassignedRole = true
+		}
+	}
+	require.True(t, foundUnassignedRole, "expected role %s to be available", noAssignRoleName)
+}
+
 func Test_CreateListGetUpdateDeleteClient(t *testing.T) {
 	// t.Parallel()
 	cfg := GetConfig(t)
@@ -5306,7 +5412,9 @@ func Test_CreateGetUpdateDeleteResourcePolicy(t *testing.T) {
 			Name:        policyNameP,
 			Description: gocloak.StringP("Role Policy"),
 			Scopes:      &scopes,
-			Roles:       &[]string{roleName},
+			// "gocloak" is the client name here, apparently it's necessary to scope client roles like that here.
+			// ref: https://github.com/keycloak/keycloak/blob/main/core/src/main/java/org/keycloak/representations/idm/authorization/UmaPermissionRepresentation.java#L53
+			Roles: &[]string{fmt.Sprintf("gocloak/%v", roleName)},
 		},
 		{
 			Name:        policyNameP,