Skip to content

Commit 9d0af30

Browse files
Keycloak: add clientscope management (ansible-collections#2905) (ansible-collections#3037)
* Add new keycloak_clienscope module * Add description and protocol parameter + Indentation Fix * Add protocolMappers parameter * Add documentation and Fix updatating of protocolMappers * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Add sanitize_cr(clientscoperep) function to sanitize the clientscope representation * Add unit tests for clientscope Keycloak module * Apply suggestions from code review Co-authored-by: Felix Fontein <felix@fontein.de> (cherry picked from commit 4a39237) Co-authored-by: Gaetan2907 <48204380+Gaetan2907@users.noreply.github.com>
1 parent 9dc2144 commit 9d0af30

File tree

4 files changed

+1345
-0
lines changed

4 files changed

+1345
-0
lines changed

plugins/module_utils/identity/keycloak/keycloak.py

+238
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
URL_GROUPS = "{url}/admin/realms/{realm}/groups"
5858
URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}"
5959

60+
URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes"
61+
URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}"
62+
URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models"
63+
URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}"
64+
6065
URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows"
6166
URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}"
6267
URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy"
@@ -511,6 +516,239 @@ def delete_client_template(self, id, realm="master"):
511516
self.module.fail_json(msg='Could not delete client template %s in realm %s: %s'
512517
% (id, realm, str(e)))
513518

519+
def get_clientscopes(self, realm="master"):
520+
""" Fetch the name and ID of all clientscopes on the Keycloak server.
521+
522+
To fetch the full data of the group, make a subsequent call to
523+
get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return.
524+
525+
:param realm: Realm in which the clientscope resides; default 'master'.
526+
:return The clientscopes of this realm (default "master")
527+
"""
528+
clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm)
529+
try:
530+
return json.loads(to_native(open_url(clientscopes_url, method="GET", headers=self.restheaders,
531+
validate_certs=self.validate_certs).read()))
532+
except Exception as e:
533+
self.module.fail_json(msg="Could not fetch list of clientscopes in realm %s: %s"
534+
% (realm, str(e)))
535+
536+
def get_clientscope_by_clientscopeid(self, cid, realm="master"):
537+
""" Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID.
538+
539+
If the clientscope does not exist, None is returned.
540+
541+
gid is a UUID provided by the Keycloak API
542+
:param cid: UUID of the clientscope to be returned
543+
:param realm: Realm in which the clientscope resides; default 'master'.
544+
"""
545+
clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid)
546+
try:
547+
return json.loads(to_native(open_url(clientscope_url, method="GET", headers=self.restheaders,
548+
validate_certs=self.validate_certs).read()))
549+
550+
except HTTPError as e:
551+
if e.code == 404:
552+
return None
553+
else:
554+
self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s"
555+
% (cid, realm, str(e)))
556+
except Exception as e:
557+
self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s"
558+
% (cid, realm, str(e)))
559+
560+
def get_clientscope_by_name(self, name, realm="master"):
561+
""" Fetch a keycloak clientscope within a realm based on its name.
562+
563+
The Keycloak API does not allow filtering of the clientscopes resource by name.
564+
As a result, this method first retrieves the entire list of clientscopes - name and ID -
565+
then performs a second query to fetch the group.
566+
567+
If the clientscope does not exist, None is returned.
568+
:param name: Name of the clientscope to fetch.
569+
:param realm: Realm in which the clientscope resides; default 'master'
570+
"""
571+
try:
572+
all_clientscopes = self.get_clientscopes(realm=realm)
573+
574+
for clientscope in all_clientscopes:
575+
if clientscope['name'] == name:
576+
return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm)
577+
578+
return None
579+
580+
except Exception as e:
581+
self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s"
582+
% (name, realm, str(e)))
583+
584+
def create_clientscope(self, clientscoperep, realm="master"):
585+
""" Create a Keycloak clientscope.
586+
587+
:param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name.
588+
:return: HTTPResponse object on success
589+
"""
590+
clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm)
591+
try:
592+
return open_url(clientscopes_url, method='POST', headers=self.restheaders,
593+
data=json.dumps(clientscoperep), validate_certs=self.validate_certs)
594+
except Exception as e:
595+
self.module.fail_json(msg="Could not create clientscope %s in realm %s: %s"
596+
% (clientscoperep['name'], realm, str(e)))
597+
598+
def update_clientscope(self, clientscoperep, realm="master"):
599+
""" Update an existing clientscope.
600+
601+
:param grouprep: A GroupRepresentation of the updated group.
602+
:return HTTPResponse object on success
603+
"""
604+
clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id'])
605+
606+
try:
607+
return open_url(clientscope_url, method='PUT', headers=self.restheaders,
608+
data=json.dumps(clientscoperep), validate_certs=self.validate_certs)
609+
610+
except Exception as e:
611+
self.module.fail_json(msg='Could not update clientscope %s in realm %s: %s'
612+
% (clientscoperep['name'], realm, str(e)))
613+
614+
def delete_clientscope(self, name=None, cid=None, realm="master"):
615+
""" Delete a clientscope. One of name or cid must be provided.
616+
617+
Providing the clientscope ID is preferred as it avoids a second lookup to
618+
convert a clientscope name to an ID.
619+
620+
:param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID.
621+
:param cid: The ID of the clientscope (preferred to name).
622+
:param realm: The realm in which this group resides, default "master".
623+
"""
624+
625+
if cid is None and name is None:
626+
# prefer an exception since this is almost certainly a programming error in the module itself.
627+
raise Exception("Unable to delete group - one of group ID or name must be provided.")
628+
629+
# only lookup the name if cid isn't provided.
630+
# in the case that both are provided, prefer the ID, since it's one
631+
# less lookup.
632+
if cid is None and name is not None:
633+
for clientscope in self.get_clientscopes(realm=realm):
634+
if clientscope['name'] == name:
635+
cid = clientscope['id']
636+
break
637+
638+
# if the group doesn't exist - no problem, nothing to delete.
639+
if cid is None:
640+
return None
641+
642+
# should have a good cid by here.
643+
clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl)
644+
try:
645+
return open_url(clientscope_url, method='DELETE', headers=self.restheaders,
646+
validate_certs=self.validate_certs)
647+
648+
except Exception as e:
649+
self.module.fail_json(msg="Unable to delete clientscope %s: %s" % (cid, str(e)))
650+
651+
def get_clientscope_protocolmappers(self, cid, realm="master"):
652+
""" Fetch the name and ID of all clientscopes on the Keycloak server.
653+
654+
To fetch the full data of the group, make a subsequent call to
655+
get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return.
656+
657+
:param cid: id of clientscope (not name).
658+
:param realm: Realm in which the clientscope resides; default 'master'.
659+
:return The protocolmappers of this realm (default "master")
660+
"""
661+
protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm)
662+
try:
663+
return json.loads(to_native(open_url(protocolmappers_url, method="GET", headers=self.restheaders,
664+
validate_certs=self.validate_certs).read()))
665+
except Exception as e:
666+
self.module.fail_json(msg="Could not fetch list of protocolmappers in realm %s: %s"
667+
% (realm, str(e)))
668+
669+
def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"):
670+
""" Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID.
671+
672+
If the clientscope does not exist, None is returned.
673+
674+
gid is a UUID provided by the Keycloak API
675+
676+
:param cid: UUID of the protocolmapper to be returned
677+
:param cid: UUID of the clientscope to be returned
678+
:param realm: Realm in which the clientscope resides; default 'master'.
679+
"""
680+
protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid)
681+
try:
682+
return json.loads(to_native(open_url(protocolmapper_url, method="GET", headers=self.restheaders,
683+
validate_certs=self.validate_certs).read()))
684+
685+
except HTTPError as e:
686+
if e.code == 404:
687+
return None
688+
else:
689+
self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s"
690+
% (pid, realm, str(e)))
691+
except Exception as e:
692+
self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s"
693+
% (cid, realm, str(e)))
694+
695+
def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"):
696+
""" Fetch a keycloak clientscope within a realm based on its name.
697+
698+
The Keycloak API does not allow filtering of the clientscopes resource by name.
699+
As a result, this method first retrieves the entire list of clientscopes - name and ID -
700+
then performs a second query to fetch the group.
701+
702+
If the clientscope does not exist, None is returned.
703+
:param cid: Id of the clientscope (not name).
704+
:param name: Name of the protocolmapper to fetch.
705+
:param realm: Realm in which the clientscope resides; default 'master'
706+
"""
707+
try:
708+
all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm)
709+
710+
for protocolmapper in all_protocolmappers:
711+
if protocolmapper['name'] == name:
712+
return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm)
713+
714+
return None
715+
716+
except Exception as e:
717+
self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s"
718+
% (name, realm, str(e)))
719+
720+
def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"):
721+
""" Create a Keycloak clientscope protocolmapper.
722+
723+
:param cid: Id of the clientscope.
724+
:param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name.
725+
:return: HTTPResponse object on success
726+
"""
727+
protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm)
728+
try:
729+
return open_url(protocolmappers_url, method='POST', headers=self.restheaders,
730+
data=json.dumps(mapper_rep), validate_certs=self.validate_certs)
731+
except Exception as e:
732+
self.module.fail_json(msg="Could not create protocolmapper %s in realm %s: %s"
733+
% (mapper_rep['name'], realm, str(e)))
734+
735+
def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"):
736+
""" Update an existing clientscope.
737+
738+
:param cid: Id of the clientscope.
739+
:param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper.
740+
:return HTTPResponse object on success
741+
"""
742+
protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id'])
743+
744+
try:
745+
return open_url(protocolmapper_url, method='PUT', headers=self.restheaders,
746+
data=json.dumps(mapper_rep), validate_certs=self.validate_certs)
747+
748+
except Exception as e:
749+
self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s'
750+
% (mapper_rep, realm, str(e)))
751+
514752
def get_groups(self, realm="master"):
515753
""" Fetch the name and ID of all groups on the Keycloak server.
516754

0 commit comments

Comments
 (0)