diff --git a/setup.py b/setup.py index 705e02d..a9b31a1 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ 'z3c.jbot', 'plone.api>=1.8.4', 'plone.app.dexterity', + 'pas.plugins.authomatic', ], extras_require={ 'test': [ diff --git a/src/pas/plugins/eea/browser/__init__.py b/src/pas/plugins/eea/browser/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pas/plugins/eea/browser/add_plugin.pt b/src/pas/plugins/eea/browser/add_plugin.pt new file mode 100644 index 0000000..b0aa63e --- /dev/null +++ b/src/pas/plugins/eea/browser/add_plugin.pt @@ -0,0 +1,28 @@ +

Header

+ +

Add EEA Entra plugin

+ +

+Adds the EEA Entra plugin. +

+ +
+ + + + + + + + + + + + +
Id
Title
+
+ +
+
+
+

Footer

diff --git a/src/pas/plugins/eea/interfaces.py b/src/pas/plugins/eea/interfaces.py index 47f8327..318daf3 100644 --- a/src/pas/plugins/eea/interfaces.py +++ b/src/pas/plugins/eea/interfaces.py @@ -3,6 +3,7 @@ from zope.publisher.interfaces.browser import IDefaultBrowserLayer +DEFAULT_ID = "eea_entra" class IPasPluginsEeaLayer(IDefaultBrowserLayer): """Marker interface that defines a browser layer.""" diff --git a/src/pas/plugins/eea/plugin.py b/src/pas/plugins/eea/plugin.py new file mode 100644 index 0000000..a1b9caa --- /dev/null +++ b/src/pas/plugins/eea/plugin.py @@ -0,0 +1,405 @@ +import logging +from pathlib import Path +from time import time + +import requests +from AccessControl import ClassSecurityInfo +from AccessControl.class_init import InitializeClass +from Products.PageTemplates.PageTemplateFile import PageTemplateFile +from Products.PlonePAS.interfaces.group import IGroupIntrospection +from Products.PlonePAS.interfaces.group import IGroupManagement +from Products.PlonePAS.plugins.autogroup import VirtualGroup +from Products.PluggableAuthService.interfaces import plugins as pas_interfaces +from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin +from plone.memoize import ram +from zope.interface import implementer + +from pas.plugins.authomatic.utils import authomatic_cfg + +logging.basicConfig(level=logging.DEBUG) +reqlogger = logging.getLogger("urllib3") +reqlogger.setLevel(logging.DEBUG) + +logger = logging.getLogger(__name__) +tpl_dir = Path(__file__).parent.resolve() / "browser" + +_marker = {} + +MS_TOKEN_CACHE: dict | None = None + +def manage_addEEAEntraPlugin(context, id, title="", RESPONSE=None, **kw): + """Create an instance of an EEA Plugin.""" + plugin = EEAEntraPlugin(id, title, **kw) + context._setObject(plugin.getId(), plugin) + if RESPONSE is not None: + RESPONSE.redirect("manage_workspace") + + +manage_addEEAEntraPluginForm = PageTemplateFile( + tpl_dir / "add_plugin.pt", + globals(), + __name__="addEEAEntraPlugin", +) + + +def _cachekey_ms_users(method, self, login): + return time() // (60 * 60), login + + +def _cachekey_ms_users_inconsistent(method, self, query, properties): + return time() // (60 * 60), query, properties.items() if properties else None + + +def _cachekey_ms_groups(method, self, group_id): + return time() // (60 * 60), group_id + + +def _cachekey_ms_groups_inconsistent(method, self, query, properties): + return time() // (60 * 60), query, properties.items() if properties else None + + +def _cachekey_ms_groups_for_principal(method, self, principal, *args, **kwargs): + return time() // (60 * 60), principal.getId() + + +@implementer( + pas_interfaces.IUserEnumerationPlugin, + pas_interfaces.IGroupEnumerationPlugin, + pas_interfaces.IGroupsPlugin, + IGroupManagement, + IGroupIntrospection, +) +class EEAEntraPlugin(BasePlugin): + """EEA PAS Plugin""" + + security = ClassSecurityInfo() + meta_type = "EEA Entra Plugin" + manage_options = BasePlugin.manage_options + + # Tell PAS not to swallow our exceptions + _dont_swallow_my_exceptions = True + + def __init__(self, id, title=None, **kw): + self._setId(id) + self.title = title + self.plugin_caching = True + + @security.private + def _getMSAccessToken(self): + global MS_TOKEN_CACHE + if MS_TOKEN_CACHE and MS_TOKEN_CACHE["expires"] > time(): + return MS_TOKEN_CACHE["access_token"] + + settings = authomatic_cfg() + cfg = settings.get("microsoft", {}) if settings else {} + domain = cfg.get("domain") + + if domain: + url = f"https://login.microsoftonline.com/{domain}/oauth2/v2.0/token" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + data = { + "grant_type": "client_credentials", + "client_id": cfg["consumer_key"], + "client_secret": cfg["consumer_secret"], + "scope": "https://graph.microsoft.com/.default", + } + + # TODO: maybe do this with authomatic somehow? (perhaps extend the default plugin?) + response = requests.post(url, headers=headers, data=data) + token_data = response.json() + + # TODO: cache this and refresh when necessary + MS_TOKEN_CACHE = {"expires": time() + token_data["expires_in"] - 60} + MS_TOKEN_CACHE.update(token_data) + return MS_TOKEN_CACHE["access_token"] + + @security.private + @ram.cache(_cachekey_ms_users) + def queryMSApiUsers(self, login=""): + pluginid = self.getId() + token = self._getMSAccessToken() + + url = ( + f"https://graph.microsoft.com/v1.0/users/{login}" + if login + else "https://graph.microsoft.com/v1.0/users" + ) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + users = response.json() + users = users.get("value", [users]) + return [ + {"login": user["displayName"], "id": user["id"], "pluginid": pluginid} + for user in users + ] + + return [] + + @security.private + @ram.cache(_cachekey_ms_users_inconsistent) + def queryMSApiUsersInconsistently(self, query="", properties=None): + pluginid = self.getId() + token = self._getMSAccessToken() + + customQuery = "" + + if not properties and query: + customQuery = f"displayName:{query}" + + if properties and properties.get("fullname"): + customQuery = f"displayName:{properties.get('fullname')}" + + elif properties and properties.get("email"): + customQuery = f"mail:{properties.get('email')}" + + if customQuery: + url = f'https://graph.microsoft.com/v1.0/users?$search="{customQuery}"' + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "ConsistencyLevel": "eventual", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + users = response.json() + users = users.get("value", [users]) + return [ + { + "login": user["displayName"], + "id": user["id"], + "pluginid": pluginid, + } + for user in users + ] + + return [] + + @security.private + def queryMSApiUsersEndpoint(self, login="", exact=False, **properties): + if exact: + return self.queryMSApiUsers(login) + else: + return self.queryMSApiUsersInconsistently(login, properties) + + @security.private + def enumerateUsers( + self, + id=None, + login=None, + exact_match=False, + sort_by=None, + max_results=None, + **kw, + ): + if id and login and id != login: + raise ValueError("plugin does not support id different from login") + + search_id = id or login + + if search_id and not isinstance(search_id, str): + raise NotImplementedError("sequence is not supported.") + + return self.queryMSApiUsersEndpoint(search_id, exact_match, **kw) + + @security.private + def addGroup(self, *args, **kw): + """noop""" + pass + + @security.private + def addPrincipalToGroup(self, *args, **kwargs): + """noop""" + pass + + @security.private + def removeGroup(self, *args, **kwargs): + """noop""" + pass + + @security.private + def removePrincipalFromGroup(self, *args, **kwargs): + """noop""" + pass + + @security.private + def updateGroup(self, *args, **kw): + """noop""" + pass + + @security.private + def setRolesForGroup(self, group_id, roles=()): + rmanagers = self._getPlugins().listPlugins(pas_interfaces.IRoleAssignerPlugin) + if not (rmanagers): + raise NotImplementedError( + "There is no plugin that can assign roles to groups" + ) + for rid, rmanager in rmanagers: + rmanager.assignRolesToPrincipal(roles, group_id) + + @security.private + def getGroupById(self, group_id): + groups = self.queryMSApiGroups(group_id) + group = groups[0] if len(groups) == 1 else None + if group: + return VirtualGroup( + group["id"], + title=group["title"], + description=group["title"], + ) + + @security.private + def getGroupIds(self): + return [group["id"] for group in self.queryMSApiGroups("")] + + @security.private + @ram.cache(_cachekey_ms_groups_for_principal) + def getGroupsForPrincipal(self, principal, *args, **kwargs): + token = self._getMSAccessToken() + + url = f"https://graph.microsoft.com/v1.0/users/{principal.getId()}/memberOf/microsoft.graph.group" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + groups = response.json() + groups = groups.get("value", []) + return [group["id"] for group in groups] + + return [] + + @security.private + def getGroupMembers(self, group_id): + token = self._getMSAccessToken() + + url = f"https://graph.microsoft.com/v1.0/groups/{group_id}/members" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + users = response.json() + users = users.get("value", []) + return [user["id"] for user in users] + + return [] + + @security.private + @ram.cache(_cachekey_ms_groups) + def queryMSApiGroups(self, group_id=""): + pluginid = self.getId() + token = self._getMSAccessToken() + + url = ( + f"https://graph.microsoft.com/v1.0/groups/{group_id}" + if group_id + else "https://graph.microsoft.com/v1.0/groups" + ) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + groups = response.json() + groups = groups.get("value", [groups]) + return [ + { + "title": group["displayName"], + "id": group["id"], + "groupid": group["id"], + "pluginid": pluginid, + } + for group in groups + ] + + return [] + + @security.private + @ram.cache(_cachekey_ms_groups_inconsistent) + def queryMSApiGroupsInconsistently(self, query="", properties=None): + pluginid = self.getId() + token = self._getMSAccessToken() + + customQuery = "" + + if not properties and query: + customQuery = f"displayName:{query}" + + if properties and properties.get("title"): + customQuery = f"displayName:{properties.get('title')}" + + if customQuery: + url = f'https://graph.microsoft.com/v1.0/groups?$search="{customQuery}"' + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + "ConsistencyLevel": "eventual", + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + groups = response.json() + groups = groups.get("value", [groups]) + return [ + { + "title": group["displayName"], + "id": group["id"], + "groupid": group["id"], + "pluginid": pluginid, + } + for group in groups + ] + + return [] + + @security.private + def queryMSApiGroupsEndpoint(self, query="", exact=False, **properties): + if exact or not query: + return self.queryMSApiGroups(query) + else: + return self.queryMSApiGroupsInconsistently(query, properties) + + @security.private + def enumerateGroups( + self, id=None, exact_match=False, sort_by=None, max_results=None, **kw + ): + from pprint import pprint + + pprint( + { + "id": id, + "exact_match": exact_match, + "kw": kw, + "sort_by": sort_by, + "max_results": max_results, + } + ) + return self.queryMSApiGroupsEndpoint(id, exact_match, **kw) + + +InitializeClass(EEAEntraPlugin) diff --git a/src/pas/plugins/eea/profiles/default/metadata.xml b/src/pas/plugins/eea/profiles/default/metadata.xml index 3fcf1da..98dfbfd 100644 --- a/src/pas/plugins/eea/profiles/default/metadata.xml +++ b/src/pas/plugins/eea/profiles/default/metadata.xml @@ -2,6 +2,6 @@ 1000 - + profile-pas.plugins.authomatic:default diff --git a/src/pas/plugins/eea/setuphandlers.py b/src/pas/plugins/eea/setuphandlers.py index 9911b7f..53c85a8 100644 --- a/src/pas/plugins/eea/setuphandlers.py +++ b/src/pas/plugins/eea/setuphandlers.py @@ -1,7 +1,13 @@ # -*- coding: utf-8 -*- -from Products.CMFPlone.interfaces import INonInstallable +from plone.base.interfaces import INonInstallable from zope.interface import implementer +from pas.plugins.authomatic.setuphandlers import post_install as authomatic_post_install +from .interfaces import DEFAULT_ID +from .plugin import EEAEntraPlugin + +TITLE = "EEA Entra plugin (pas.plugins.eea)" + @implementer(INonInstallable) class HiddenProfiles(object): @@ -17,11 +23,35 @@ def getNonInstallableProducts(self): return ["pas.plugins.eea.upgrades"] +def _add_plugin(pas, pluginid=DEFAULT_ID): + if pluginid in pas.objectIds(): + return f"{TITLE} already installed." + if pluginid != DEFAULT_ID: + return f"ID of plugin must be {DEFAULT_ID}" + plugin = EEAEntraPlugin(pluginid, title=TITLE) + pas._setObject(pluginid, plugin) + plugin = pas[plugin.getId()] # get plugin acquisition wrapped! + for info in pas.plugins.listPluginTypeInfo(): + interface = info["interface"] + if not interface.providedBy(plugin): + continue + pas.plugins.activatePlugin(interface, plugin.getId()) + pas.plugins.movePluginsDown( + interface, + [x[0] for x in pas.plugins.listPlugins(interface)[:-1]], + ) + + +def _remove_plugin(pas, pluginid=DEFAULT_ID): + if pluginid in pas.objectIds(): + pas.manage_delObjects([pluginid]) + + def post_install(context): """Post install script""" - # Do something at the end of the installation of this package. + _add_plugin(context.aq_parent.acl_users) def uninstall(context): """Uninstall script""" - # Do something at the end of the uninstallation of this package. + _remove_plugin(context.aq_parent.acl_users) diff --git a/src/pas/plugins/eea/tests/robot/test_example.robot b/src/pas/plugins/eea/tests/robot/test_example.robot deleted file mode 100644 index 473a25b..0000000 --- a/src/pas/plugins/eea/tests/robot/test_example.robot +++ /dev/null @@ -1,66 +0,0 @@ -# ============================================================================ -# EXAMPLE ROBOT TESTS -# ============================================================================ -# -# Run this robot test stand-alone: -# -# $ bin/test -s pas.plugins.eea -t test_example.robot --all -# -# Run this robot test with robot server (which is faster): -# -# 1) Start robot server: -# -# $ bin/robot-server --reload-path src pas.plugins.eea.testing.PAS_PLUGINS_EEA_ACCEPTANCE_TESTING -# -# 2) Run robot tests: -# -# $ bin/robot src/pas/plugins/eea/tests/robot/test_example.robot -# -# See the http://docs.plone.org for further details (search for robot -# framework). -# -# ============================================================================ - -*** Settings ***************************************************************** - -Resource plone/app/robotframework/selenium.robot -Resource plone/app/robotframework/keywords.robot - -Library Remote ${PLONE_URL}/RobotRemote - -Test Setup Open test browser -Test Teardown Close all browsers - - -*** Test Cases *************************************************************** - -Scenario: As a member I want to be able to log into the website - [Documentation] Example of a BDD-style (Behavior-driven development) test. - Given a login form - When I enter valid credentials - Then I am logged in - - -*** Keywords ***************************************************************** - -# --- Given ------------------------------------------------------------------ - -a login form - Go To ${PLONE_URL}/login_form - Wait until page contains Login Name - Wait until page contains Password - - -# --- WHEN ------------------------------------------------------------------- - -I enter valid credentials - Input Text __ac_name admin - Input Text __ac_password secret - Click Button Log in - - -# --- THEN ------------------------------------------------------------------- - -I am logged in - Wait until page contains You are now logged in - Page should contain You are now logged in diff --git a/src/pas/plugins/eea/tests/test_robot.py b/src/pas/plugins/eea/tests/test_robot.py deleted file mode 100644 index 2616bd2..0000000 --- a/src/pas/plugins/eea/tests/test_robot.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from pas.plugins.eea.testing import PAS_PLUGINS_EEA_ACCEPTANCE_TESTING # noqa: E501 -from plone.app.testing import ROBOT_TEST_LEVEL -from plone.testing import layered - -import os -import robotsuite -import unittest - - -def test_suite(): - suite = unittest.TestSuite() - current_dir = os.path.abspath(os.path.dirname(__file__)) - robot_dir = os.path.join(current_dir, 'robot') - robot_tests = [ - os.path.join('robot', doc) for doc in os.listdir(robot_dir) - if doc.endswith('.robot') and doc.startswith('test_') - ] - for robot_test in robot_tests: - robottestsuite = robotsuite.RobotTestSuite(robot_test) - robottestsuite.level = ROBOT_TEST_LEVEL - suite.addTests([ - layered( - robottestsuite, - layer=PAS_PLUGINS_EEA_ACCEPTANCE_TESTING, - ), - ]) - return suite