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.
+
+
+
+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