Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jobs:
config:
name: "Compute configuration values"
uses: ./.github/workflows/config.yml

lint:
name: "Lint codebase"
uses: plone/meta/.github/workflows/backend-lint.yml@2.x
Expand All @@ -16,6 +17,8 @@ jobs:
with:
python-version: ${{ needs.config.outputs.python-version }}
plone-version: ${{ needs.config.outputs.plone-version }}
check-typing: true

test:
name: "Test codebase"
uses: plone/meta/.github/workflows/backend-pytest.yml@2.x
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,13 @@ format: ## Check and fix code base according to Plone standards
@uvx ruff@latest format --config $(BACKEND_FOLDER)/pyproject.toml
@uvx zpretty@latest -i src

.PHONY: typecheck
typecheck: ## Run static type checking
@echo "$(GREEN)==> Type check codebase$(RESET)"
@uvx mypy@latest --config-file $(BACKEND_FOLDER)/pyproject.toml src

.PHONY: check
check: format lint ## Check and fix code base according to Plone standards
check: format lint typecheck ## Check and fix code base according to Plone standards

############################################
# i18n
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,59 @@ There are some differences in configuration:
- Each provider can get an optional entry `propertymap`.
It is a mapping from authomatic/provider user properties to plone user properties, like `"fullname": "name",`. Look at each providers documentation which properties are available.

## Migrating user identities

When migrating a site — for example moving to a new instance — you can export the complete set of user identities stored in the Authomatic plugin to a JSON file and import them again later.

Two helper functions are available in `pas.plugins.authomatic.utils.exportimport`:

- `export_plugin_data(path)`: writes all stored identities to the JSON file at `path` and returns the path.
- `import_plugin_data(path)`: reads the JSON file at `path` and restores the identities into the plugin, returning `True` on success (or `False` when no Authomatic plugin is installed).

Both accept an optional `delimiter` argument used to serialize the provider identity keys (defaults to `|`).
Using the console, to export the identities:

```python
from pas.plugins.authomatic.utils import exportimport
from pathlib import Path
from plone import api
from zope.component.hooks import setSite


path = Path("export_authomatic.json")

app = globals()["app"]
site = app.Plone
setSite(site)

with api.env.adopt_roles(["Manager"]):
exportimport.export_plugin_data(path)

```

To import them again:

```python
from pas.plugins.authomatic.utils import exportimport
from pathlib import Path
from plone import api
from zope.component.hooks import setSite

import transaction


path = Path("export_authomatic.json")

app = globals()["app"]
site = app.Plone
setSite(site)

with api.env.adopt_roles(["Manager"]):
exportimport.import_plugin_data(path)

transaction.commit()
```

## Integration with Entra ID

Enumeration PAS plugin: if you're using **pas.plugins.authomatic** with *Microsoft Entra ID*, we recommend pairing it with [pas.plugins.eea](https://github.com/eea/pas.plugins.eea) for proper user enumeration and metadata synchronization. This complementary plugin enables listing all the Entra ID users and groups and is compatible with Plone 6.1 and 6.2.
Expand Down
1 change: 1 addition & 0 deletions news/111.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added type annotations across the package and shipped a `py.typed` marker so downstream code can type-check against it (PEP 561). @ericof
1 change: 1 addition & 0 deletions news/111.internal
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a `make typecheck` target (mypy) and wired it into `make check`; modernized `setuphandlers` to use `plone.api` and the `plone.base` `INonInstallable` import. @ericof
1 change: 1 addition & 0 deletions news/112.internal
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reorganized the package following cookieplone conventions: promoted `utils` to a package (`content`/`plugin`/`request`/`settings`), split `useridfactories` into a package, added `vocabularies` and a `config` module, and slimmed `interfaces` down to interface definitions. Also modernized the `IPloneSiteRoot` and request imports. @ericof
1 change: 1 addition & 0 deletions news/112.tests
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expanded test coverage across the add-on: the `utils` subpackages, the user-id factories and their dispatcher, the `vocabularies`, the REST and Zope request adapters, and the `config` validator. @ericof
1 change: 1 addition & 0 deletions news/113.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `export_plugin_data` and `import_plugin_data` helpers (in `pas.plugins.authomatic.utils.exportimport`) to export the plugin's user identities to a JSON file and import them back, for use during site migrations. @ericof
1 change: 1 addition & 0 deletions news/114.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Changed the default `userid_factory_name` from `uuid` to `username_userid`, so new installations get deterministic, human-meaningful user ids that are easier to target with permissions and group assignments. Existing sites keep their configured value. @ericof
13 changes: 12 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies = [
[project.optional-dependencies]
test = [
"collective.MockMailHost",
"mypy",
"plone.app.robotframework[debug]",
"plone.app.testing",
"plone.restapi[test]",
Expand Down Expand Up @@ -190,11 +191,21 @@ preview = true
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E501", "RUF001", "S101"]

[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
# The package uses a PEP 420 native namespace (no __init__.py under src/pas),
# so tell mypy the source root explicitly to avoid "found twice under different
# module names" clashes with the installed ``authomatic`` distribution.
explicit_package_bases = true
namespace_packages = true
mypy_path = "src"

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
source_pkgs = ["pas.plugins.authomatic", "tests"]
source_pkgs = ["pas.plugins.authomatic"]
branch = true
parallel = true
omit = [
Expand Down
3 changes: 3 additions & 0 deletions src/pas/plugins/authomatic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pas.plugins.authomatic.patches import apply_patches
from zope.i18nmessageid import MessageFactory

import logging

Expand All @@ -8,6 +9,8 @@

PACKAGE_NAME = "pas.plugins.authomatic"

_ = MessageFactory(PACKAGE_NAME)


logger = logging.getLogger(PACKAGE_NAME)

Expand Down
146 changes: 146 additions & 0 deletions src/pas/plugins/authomatic/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Shared typing helpers for :mod:`pas.plugins.authomatic`.

This module centralizes the :class:`~typing.TypedDict` definitions used to
document the various mappings handled by the package -- provider
configuration, REST API replies and user enumeration results.
"""

from typing import Any
from typing import Protocol
from typing import TypedDict


class AuthProvider(Protocol):
"""Structural type for an ``authomatic`` provider instance.

``authomatic`` ships without type information, so we describe here only
the surface consumed by this package.
"""

name: str


class AuthUser(Protocol):
"""Structural type for :class:`authomatic.core.User`.

Only the attributes and methods used by this package are declared. Fields
are optional because ``authomatic`` populates them from provider data.
"""

id: str | None
username: str | None
name: str | None
email: str | None
data: dict[str, Any] | None

def to_dict(self) -> dict[str, Any]: ...

def update(self) -> None: ...


class AuthResult(Protocol):
"""Structural type for :class:`authomatic.core.LoginResult`."""

user: AuthUser
provider: AuthProvider
error: Any


class ProviderConfig(TypedDict, total=False):
"""Configuration for a single Authomatic provider.

The values come from the user-provided JSON configuration, so every key
is optional. ``class_`` is stored as a dotted-path string in the JSON but
resolved to the provider class by :func:`.utils.authomatic_cfg`.
"""

id: int
class_: Any
display: dict[str, Any]
propertymap: dict[str, str | dict[str, str]]
consumer_key: str
consumer_secret: str
access_headers: dict[str, str]


#: Mapping of provider name to its :class:`ProviderConfig`.
AuthomaticConfig = dict[str, ProviderConfig]


class ErrorDetail(TypedDict):
"""Body of an error returned by the REST API services."""

type: str
message: str


class ErrorReply(TypedDict):
"""Error reply envelope returned by the REST API services."""

error: ErrorDetail


class NextURLReply(TypedDict):
"""Successful reply of the ``@login-authomatic`` GET service."""

next_url: str
session: str


class TokenReply(TypedDict):
"""Successful reply of the ``@login-authomatic`` POST service."""

token: str


class UserInfo(TypedDict):
"""User enumeration entry returned by the PAS plugin."""

id: str
login: str
pluginid: str


class LoginCredentials(TypedDict, total=False):
"""Credentials mapping passed to the PAS authentication plugin."""

login: str
password: str


class LoginProvider(TypedDict):
"""A possible login provider."""

id: str
plugin: str
title: str
url: str


class ProviderButton(TypedDict):
"""A provider entry rendered as a login button by the Classic UI view."""

identifier: str
title: str
iconclasses: str
buttonclasses: str
as_form: bool


class PasPluginsAuthomaticSettings(Protocol):
"""Protocol implementation for the IPasPluginsAuthomaticSettings interface."""

secret: str
userid_factory_name: str
json_config: str


class SerializedPluginData(TypedDict):
"""Serialized representation of the PAS plugin state.

This is used to export and import the plugin configuration and user
identities.
"""

userid_by_identityinfo: dict[str, str]
useridentities_by_userid: dict[str, dict[str, Any]]
2 changes: 1 addition & 1 deletion src/pas/plugins/authomatic/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<!-- Control panel -->
<browser:page
name="authomatic-controlpanel"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
for="plone.base.interfaces.IPloneSiteRoot"
class=".controlpanel.AuthomaticSettingsEditFormSettingsControlPanel"
permission="cmf.ManagePortal"
layer="pas.plugins.authomatic.interfaces.IPasPluginsAuthomaticLayer"
Expand Down
18 changes: 10 additions & 8 deletions src/pas/plugins/authomatic/browser/controlpanel.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
from pas.plugins.authomatic.interfaces import _
from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticLayer
from pas.plugins.authomatic.interfaces import IPasPluginsAuthomaticSettings
from pas.plugins.authomatic import _
from pas.plugins.authomatic import interfaces as ifaces
from plone.app.registry.browser import controlpanel
from plone.restapi.controlpanels import RegistryConfigletPanel
from zope.component import adapter
from zope.interface import Interface


class AuthomaticSettingsEditForm(controlpanel.RegistryEditForm):
schema = IPasPluginsAuthomaticSettings
label = _("PAS Authomatic Plugin Settings")
description = ""

def updateFields(self):
@property
def schema(self):
return ifaces.IPasPluginsAuthomaticSettings

def updateFields(self) -> None:
super().updateFields()
# self.fields['json_config'].widgetFactory = TextLinesFieldWidget

def updateWidgets(self):
def updateWidgets(self) -> None:
super().updateWidgets()


Expand All @@ -26,11 +28,11 @@ class AuthomaticSettingsEditFormSettingsControlPanel(
form = AuthomaticSettingsEditForm


@adapter(Interface, IPasPluginsAuthomaticLayer)
@adapter(Interface, ifaces.IPasPluginsAuthomaticLayer)
class AuthomaticSettingsConfigletPanel(RegistryConfigletPanel):
"""Control Panel endpoint"""

schema = IPasPluginsAuthomaticSettings
schema = ifaces.IPasPluginsAuthomaticSettings
configlet_id = "authomatic"
configlet_category_id = "plone-users"
title = _("Authomatic settings")
Expand Down
Loading
Loading