You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

417 lines
15 KiB

  1. # -*- coding: utf-8 -*-
  2. #
  3. # The MIT License (MIT)
  4. #
  5. # Copyright (C) 2017 Marcos Pereira <marcospereira.mpj@gmail.com>
  6. #
  7. # Permission is hereby granted, free of charge, to any person obtaining a copy of
  8. # this software and associated documentation files (the "Software"), to deal in
  9. # the Software without restriction, including without limitation the rights to
  10. # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  11. # the Software, and to permit persons to whom the Software is furnished to do so,
  12. # subject to the following conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be included in all
  15. # copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  19. # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  20. # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  21. # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  22. # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  23. """Keycloak UMA module.
  24. The module contains a UMA compatible client for keycloak:
  25. https://docs.kantarainitiative.org/uma/wg/rec-oauth-uma-federated-authz-2.0.html
  26. """
  27. import json
  28. from typing import Iterable
  29. from urllib.parse import quote_plus
  30. from .connection import ConnectionManager
  31. from .exceptions import (
  32. KeycloakDeleteError,
  33. KeycloakGetError,
  34. KeycloakPostError,
  35. KeycloakPutError,
  36. raise_error_from_response,
  37. )
  38. from .openid_connection import KeycloakOpenIDConnection
  39. from .uma_permissions import UMAPermission
  40. from .urls_patterns import URL_UMA_WELL_KNOWN
  41. class KeycloakUMA:
  42. """Keycloak UMA client.
  43. :param connection: OpenID connection manager
  44. """
  45. def __init__(self, connection: KeycloakOpenIDConnection):
  46. """Init method.
  47. :param connection: OpenID connection manager
  48. :type connection: KeycloakOpenIDConnection
  49. """
  50. self.connection = connection
  51. custom_headers = self.connection.custom_headers or {}
  52. custom_headers.update({"Content-Type": "application/json"})
  53. self.connection.custom_headers = custom_headers
  54. self._well_known = None
  55. def _fetch_well_known(self):
  56. params_path = {"realm-name": self.connection.realm_name}
  57. data_raw = self.connection.raw_get(URL_UMA_WELL_KNOWN.format(**params_path))
  58. return raise_error_from_response(data_raw, KeycloakGetError)
  59. @staticmethod
  60. def format_url(url, **kwargs):
  61. """Substitute url path parameters.
  62. Given a parameterized url string, returns the string after url encoding and substituting
  63. the given params. For example,
  64. `format_url("https://myserver/{my_resource}/{id}", my_resource="hello world", id="myid")`
  65. would produce `https://myserver/hello+world/myid`.
  66. :param url: url string to format
  67. :type url: str
  68. :param kwargs: dict containing kwargs to substitute
  69. :type kwargs: dict
  70. :return: formatted string
  71. :rtype: str
  72. """
  73. return url.format(**{k: quote_plus(v) for k, v in kwargs.items()})
  74. @property
  75. def uma_well_known(self):
  76. """Get the well_known UMA2 config.
  77. :returns: It lists endpoints and other configuration options relevant
  78. :rtype: dict
  79. """
  80. # per instance cache
  81. if not self._well_known:
  82. self._well_known = self._fetch_well_known()
  83. return self._well_known
  84. def resource_set_create(self, payload):
  85. """Create a resource set.
  86. Spec
  87. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#rfc.section.2.2.1
  88. ResourceRepresentation
  89. https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation
  90. :param payload: ResourceRepresentation
  91. :type payload: dict
  92. :return: ResourceRepresentation with the _id property assigned
  93. :rtype: dict
  94. """
  95. data_raw = self.connection.raw_post(
  96. self.uma_well_known["resource_registration_endpoint"], data=json.dumps(payload)
  97. )
  98. return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[201])
  99. def resource_set_update(self, resource_id, payload):
  100. """Update a resource set.
  101. Spec
  102. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#update-resource-set
  103. ResourceRepresentation
  104. https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation
  105. :param resource_id: id of the resource
  106. :type resource_id: str
  107. :param payload: ResourceRepresentation
  108. :type payload: dict
  109. :return: Response dict (empty)
  110. :rtype: dict
  111. """
  112. url = self.format_url(
  113. self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id
  114. )
  115. data_raw = self.connection.raw_put(url, data=json.dumps(payload))
  116. return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204])
  117. def resource_set_read(self, resource_id):
  118. """Read a resource set.
  119. Spec
  120. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#read-resource-set
  121. ResourceRepresentation
  122. https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation
  123. :param resource_id: id of the resource
  124. :type resource_id: str
  125. :return: ResourceRepresentation
  126. :rtype: dict
  127. """
  128. url = self.format_url(
  129. self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id
  130. )
  131. data_raw = self.connection.raw_get(url)
  132. return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200])
  133. def resource_set_delete(self, resource_id):
  134. """Delete a resource set.
  135. Spec
  136. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#delete-resource-set
  137. :param resource_id: id of the resource
  138. :type resource_id: str
  139. :return: Response dict (empty)
  140. :rtype: dict
  141. """
  142. url = self.format_url(
  143. self.uma_well_known["resource_registration_endpoint"] + "/{id}", id=resource_id
  144. )
  145. data_raw = self.connection.raw_delete(url)
  146. return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204])
  147. def resource_set_list_ids(
  148. self,
  149. name: str = "",
  150. exact_name: bool = False,
  151. uri: str = "",
  152. owner: str = "",
  153. resource_type: str = "",
  154. scope: str = "",
  155. first: int = 0,
  156. maximum: int = -1,
  157. ):
  158. """Query for list of resource set ids.
  159. Spec
  160. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets
  161. :param name: query resource name
  162. :type name: str
  163. :param exact_name: query exact match for resource name
  164. :type exact_name: bool
  165. :param uri: query resource uri
  166. :type uri: str
  167. :param owner: query resource owner
  168. :type owner: str
  169. :param resource_type: query resource type
  170. :type resource_type: str
  171. :param scope: query resource scope
  172. :type scope: str
  173. :param first: index of first matching resource to return
  174. :type first: int
  175. :param maximum: maximum number of resources to return (-1 for all)
  176. :type maximum: int
  177. :return: List of ids
  178. :rtype: List[str]
  179. """
  180. query = dict()
  181. if name:
  182. query["name"] = name
  183. if exact_name:
  184. query["exactName"] = "true"
  185. if uri:
  186. query["uri"] = uri
  187. if owner:
  188. query["owner"] = owner
  189. if resource_type:
  190. query["type"] = resource_type
  191. if scope:
  192. query["scope"] = scope
  193. if first > 0:
  194. query["first"] = first
  195. if maximum >= 0:
  196. query["max"] = maximum
  197. data_raw = self.connection.raw_get(
  198. self.uma_well_known["resource_registration_endpoint"], **query
  199. )
  200. return raise_error_from_response(data_raw, KeycloakGetError, expected_codes=[200])
  201. def resource_set_list(self):
  202. """List all resource sets.
  203. Spec
  204. https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg-v1_0_1.html#list-resource-sets
  205. ResourceRepresentation
  206. https://www.keycloak.org/docs-api/20.0.0/rest-api/index.html#_resourcerepresentation
  207. :yields: Iterator over a list of ResourceRepresentations
  208. :rtype: Iterator[dict]
  209. """
  210. for resource_id in self.resource_set_list_ids():
  211. resource = self.resource_set_read(resource_id)
  212. yield resource
  213. def permission_ticket_create(self, permissions: Iterable[UMAPermission]):
  214. """Create a permission ticket.
  215. :param permissions: Iterable of uma permissions to validate the token against
  216. :type permissions: Iterable[UMAPermission]
  217. :returns: Keycloak decision
  218. :rtype: boolean
  219. :raises KeycloakPostError: In case permission resource not found
  220. """
  221. resources = dict()
  222. for permission in permissions:
  223. resource_id = getattr(permission, "resource_id", None)
  224. if resource_id is None:
  225. resource_ids = self.resource_set_list_ids(
  226. exact_name=True, name=permission.resource, first=0, maximum=1
  227. )
  228. if not resource_ids:
  229. raise KeycloakPostError("Invalid resource specified")
  230. setattr(permission, "resource_id", resource_ids[0])
  231. resources.setdefault(resource_id, set())
  232. if permission.scope:
  233. resources[resource_id].add(permission.scope)
  234. payload = [
  235. {"resource_id": resource_id, "resource_scopes": list(scopes)}
  236. for resource_id, scopes in resources.items()
  237. ]
  238. data_raw = self.connection.raw_post(
  239. self.uma_well_known["permission_endpoint"], data=json.dumps(payload)
  240. )
  241. return raise_error_from_response(data_raw, KeycloakPostError)
  242. def permissions_check(self, token, permissions: Iterable[UMAPermission]):
  243. """Check UMA permissions by user token with requested permissions.
  244. The token endpoint is used to check UMA permissions from Keycloak. It can only be
  245. invoked by confidential clients.
  246. https://www.keycloak.org/docs/latest/authorization_services/#_service_authorization_api
  247. :param token: user token
  248. :type token: str
  249. :param permissions: Iterable of uma permissions to validate the token against
  250. :type permissions: Iterable[UMAPermission]
  251. :returns: Keycloak decision
  252. :rtype: boolean
  253. """
  254. payload = {
  255. "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
  256. "permission": ",".join(str(permission) for permission in permissions),
  257. "response_mode": "decision",
  258. "audience": self.connection.client_id,
  259. }
  260. # Everyone always has the null set of permissions
  261. # However keycloak cannot evaluate the null set
  262. if len(payload["permission"]) == 0:
  263. return True
  264. connection = ConnectionManager(self.connection.base_url)
  265. connection.add_param_headers("Authorization", "Bearer " + token)
  266. connection.add_param_headers("Content-Type", "application/x-www-form-urlencoded")
  267. data_raw = connection.raw_post(self.uma_well_known["token_endpoint"], data=payload)
  268. try:
  269. data = raise_error_from_response(data_raw, KeycloakPostError)
  270. except KeycloakPostError:
  271. return False
  272. return data.get("result", False)
  273. def policy_resource_create(self, resource_id, payload):
  274. """Create permission policy for resource.
  275. Supports name, description, scopes, roles, groups, clients
  276. https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource
  277. :param resource_id: _id of resource
  278. :type resource_id: str
  279. :param payload: permission configuration
  280. :type payload: dict
  281. :return: PermissionRepresentation
  282. :rtype: dict
  283. """
  284. data_raw = self.connection.raw_post(
  285. self.uma_well_known["policy_endpoint"] + f"/{resource_id}", data=json.dumps(payload)
  286. )
  287. return raise_error_from_response(data_raw, KeycloakPostError)
  288. def policy_update(self, policy_id, payload):
  289. """Update permission policy.
  290. https://www.keycloak.org/docs/latest/authorization_services/#associating-a-permission-with-a-resource
  291. https://www.keycloak.org/docs-api/21.0.1/rest-api/index.html#_policyrepresentation
  292. :param policy_id: id of policy permission
  293. :type policy_id: str
  294. :param payload: policy permission configuration
  295. :type payload: dict
  296. :return: PermissionRepresentation
  297. :rtype: dict
  298. """
  299. data_raw = self.connection.raw_put(
  300. self.uma_well_known["policy_endpoint"] + f"/{policy_id}", data=json.dumps(payload)
  301. )
  302. return raise_error_from_response(data_raw, KeycloakPutError)
  303. def policy_delete(self, policy_id):
  304. """Delete permission policy.
  305. https://www.keycloak.org/docs/latest/authorization_services/#removing-a-permission
  306. https://www.keycloak.org/docs-api/21.0.1/rest-api/index.html#_policyrepresentation
  307. :param policy_id: id of permission policy
  308. :type policy_id: str
  309. :return: PermissionRepresentation
  310. :rtype: dict
  311. """
  312. data_raw = self.connection.raw_delete(
  313. self.uma_well_known["policy_endpoint"] + f"/{policy_id}"
  314. )
  315. return raise_error_from_response(data_raw, KeycloakDeleteError)
  316. def policy_query(
  317. self,
  318. resource: str = "",
  319. name: str = "",
  320. scope: str = "",
  321. first: int = 0,
  322. maximum: int = -1,
  323. ):
  324. """Query permission policies.
  325. https://www.keycloak.org/docs/latest/authorization_services/#querying-permission
  326. :param resource: query resource id
  327. :type resource: str
  328. :param name: query resource name
  329. :type name: str
  330. :param scope: query resource scope
  331. :type scope: str
  332. :param first: index of first matching resource to return
  333. :type first: int
  334. :param maximum: maximum number of resources to return (-1 for all)
  335. :type maximum: int
  336. :return: List of ids
  337. :return: List of ids
  338. :rtype: List[str]
  339. """
  340. query = dict()
  341. if name:
  342. query["name"] = name
  343. if resource:
  344. query["resource"] = resource
  345. if scope:
  346. query["scope"] = scope
  347. if first > 0:
  348. query["first"] = first
  349. if maximum >= 0:
  350. query["max"] = maximum
  351. data_raw = self.connection.raw_get(self.uma_well_known["policy_endpoint"], **query)
  352. return raise_error_from_response(data_raw, KeycloakGetError)