Skip to content
Draft
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
2 changes: 2 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ BIN_FOLDER=$(VENV_FOLDER)/bin
# Environment variables to be exported
export PYTHONWARNINGS := ignore
export DOCKER_BUILDKIT := 1
export ALLOWED_DISTRIBUTIONS := testing


all: build

Expand Down
1 change: 1 addition & 0 deletions backend/news/604.breaking
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Removed the `keycloak`, `oidc`, `authomatic-github` and `authomatic-google` authentication providers from site creation: new sites use `pas.plugins.identity` through the `identity-keycloak`, `identity-oidc`, `identity-github` and `identity-google` providers, and creating a site with one of the old providers fails with `UnsupportedAuthenticationProvider`, naming its replacement. The shared schema definitions are now `identity-keycloak`, `identity-oidc`, `identity-github` and `identity-google`, so distributions have to update their `schema.json`. The `SITE_AUTHENTICATION_OIDC-*` and `SITE_AUTHENTICATION_AUTHOMATIC-*` environment variables are ignored with a warning, and the `setup_oidc_auth`, `setup_keycloak_auth` and `setup_authomatic` helpers are deprecated. Existing sites keep their `pas.plugins.oidc` and `pas.plugins.authomatic` configuration. @ericof
1 change: 1 addition & 0 deletions backend/news/604.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `pas.plugins.identity` as an always installed dependency, with `Person` as the user content type: each user account is a `Person` in the new `/users` folder, while other `Person` objects stay plain content. An upgrade step installs the add-on on existing sites and creates a `Person` for each user in `source_users`. Site creation gains the `identity-keycloak`, `identity-oidc`, `identity-github` and `identity-google` authentication providers, configured with `identity-*` answers or `SITE_AUTHENTICATION_IDENTITY-*` environment variables. With `identity-keycloak`, the realm groups come from `pas.plugins.keycloakgroups`, and Keycloak users get their Keycloak user id (`sub`) as Plone userid. @ericof
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies = [
"collective.honeypot",
"collective.person==1.0.0b6",
"pas.plugins.authomatic==2.0.0",
"pas.plugins.identity==1.0.0a13",
"pas.plugins.keycloakgroups==1.0.0b1",
"pas.plugins.oidc==2.0.0",
]
Expand Down
3 changes: 3 additions & 0 deletions backend/src/kitconcept/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@


def initialize(context):
from kitconcept.core.patches import identity
from kitconcept.core.tools import migration

identity.apply_patch(logger=logger)
from Products.CMFPlone.utils import ToolInit

tools = (migration.MigrationTool,)
Expand Down
21 changes: 20 additions & 1 deletion backend/src/kitconcept/core/_types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from typing import Literal
from typing import NotRequired
from typing import overload
from typing import Protocol
from typing import TypedDict
Expand Down Expand Up @@ -60,7 +61,23 @@ class DistributionInfo(TypedDict):

AnswersAuthomatic = AnswersAuthomaticGoogle | AnswersAuthomaticGitHub

AuthAnswers = AnswersKeycloak | AnswersAuthomatic | AnswersOIDC
AnswersIdentity = TypedDict(
"AnswersIdentity",
{
"provider": Literal[
"identity-keycloak", "identity-oidc", "identity-github", "identity-google"
],
"identity-server_url": NotRequired[str],
"identity-realm_name": NotRequired[str],
"identity-issuer": NotRequired[str],
"identity-client_id": str,
"identity-client_secret": str,
"identity-site-url": NotRequired[str],
"identity-scope": NotRequired[list[str]],
},
)

AuthAnswers = AnswersKeycloak | AnswersAuthomatic | AnswersOIDC | AnswersIdentity


class AuthSetupHandler(Protocol):
Expand All @@ -70,6 +87,8 @@ def __call__(self, answers: AnswersKeycloak) -> None: ...
def __call__(self, answers: AnswersOIDC) -> None: ...
@overload
def __call__(self, answers: AnswersAuthomatic) -> None: ...
@overload
def __call__(self, answers: AnswersIdentity) -> None: ...

def __call__(self, answers: AuthAnswers) -> None: ...

Expand Down
7 changes: 7 additions & 0 deletions backend/src/kitconcept/core/behaviors/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@
provides=".person_image.IPersonImage"
/>

<plone:behavior
name="kitconcept.core.person_emails"
title="Person: Email addresses"
description="Email addresses of a person that represents a user."
provides=".person_emails.IPersonEmails"
/>

</configure>
52 changes: 52 additions & 0 deletions backend/src/kitconcept/core/behaviors/person_emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from kitconcept.core import _
from plone.autoform.directives import read_permission
from plone.autoform.directives import write_permission
from plone.autoform.interfaces import IFormFieldProvider
from plone.schema import Email
from plone.supermodel import model
from zope import schema
from zope.interface import provider


@provider(IFormFieldProvider)
class IPersonEmails(model.Schema):
"""Email addresses of a Person that represents a user.

Same fields as the ``pas.plugins.identity.email_addresses`` behavior, but
optional: a Person that is not a user does not need an address.
"""

model.fieldset(
"email",
label=_("Email"),
fields=["emails", "email"],
)

emails = schema.Tuple(
title=_("label_person_emails", default="Email addresses"),
description=_(
"help_person_emails",
default="The addresses this person uses to sign in, most preferred first.",
),
value_type=Email(title=_("Email")),
required=False,
missing_value=(),
default=(),
)

email = Email(
title=_("Email"),
description=_(
"help_person_email",
default="The first verified address above, or the first one when "
"none is verified.",
),
required=False,
readonly=True,
)

write_permission(emails="pas.plugins.identity.content.edit")
read_permission(
emails="pas.plugins.identity.content.viewpii",
email="pas.plugins.identity.content.viewpii",
)
1 change: 1 addition & 0 deletions backend/src/kitconcept/core/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<include package=".indexers" />
<include package=".serializers" />
<include package=".services" />
<include package=".subscribers" />
<include package=".vocabularies" />

<utility
Expand Down
159 changes: 159 additions & 0 deletions backend/src/kitconcept/core/content/person.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Person as the user content type of pas.plugins.identity.

A ``Person`` is a user only when it represents an account: it was created with
a ``login``, and it provides :class:`IUserProfile`. Other ``Person`` objects,
like team pages, remain plain content.
"""

from collective.person.content.person import IPerson as IBasePerson
from kitconcept.core import logger
from pas.plugins.identity.core.interfaces import IUserContent
from pas.plugins.identity.core.utils.emails import clean
from pas.plugins.identity.core.utils.emails import normalize
from pas.plugins.identity.core.utils.emails import preferred_address
from pas.plugins.identity.core.utils.emails import verified_addresses
from typing import TYPE_CHECKING


class IPerson(IBasePerson, IUserContent):
"""A Person that can represent a user.

pas.plugins.identity only creates users of a type whose schema extends
:class:`IUserContent`.
"""


def split_fullname(fullname: str) -> tuple[str, str]:
"""Split a full name into first and last name.

:param fullname: The full name.
:returns: First name and last name, empty when there is no full name.
"""
parts = fullname.strip().split(maxsplit=1)
if not parts:
return "", ""
return parts[0], parts[1] if len(parts) > 1 else ""


# Attributes copied into collective.person's Person class
USER_ATTRIBUTES = (
"login",
"email_confirmation_pending",
"userid",
"fullname",
"emails",
"email",
"verified_emails",
)


class PersonUserMixin:
"""User attributes of a Person, mirroring pas.plugins.identity UserProfile."""

if TYPE_CHECKING:
title: str
first_name: str
last_name: str

def getId(self) -> str: ...

login = ""

email_confirmation_pending = False

@property
def userid(self) -> str:
"""Return the userid, which is the object id.

:returns: The userid.
"""
return self.getId()

@userid.setter
def userid(self, value: str) -> None:
"""Ignore writes: the userid is derived from the object id.

:param value: The value being written.
"""
if value and value != self.getId():
logger.warning(
f"Ignoring an attempt to set userid to {value!r} on {self.getId()!r}"
)

@property
def fullname(self) -> str:
"""Return the full name.

pas.plugins.identity writes a name claim only over the value it wrote
itself, so this returns exactly the full name last written, while the
first and last name still split from it. After they are edited, it
returns them joined, and the provider no longer overwrites them.

Not the Person title: the title utility of collective.person may
order the names differently (``last_first``), and formats a missing
first name as ``"None"``.

:returns: The full name, empty when there are no names.
"""
first_name = getattr(self, "first_name", None) or ""
last_name = getattr(self, "last_name", None) or ""
written = getattr(self, "_fullname", "")
if written and split_fullname(written) == (first_name, last_name):
return written
return " ".join(name for name in (first_name, last_name) if name)

@fullname.setter
def fullname(self, value: str) -> None:
"""Store a full name in the first and last name fields.

:param value: The full name. An empty value is ignored.
"""
first_name, last_name = split_fullname(value or "")
if not first_name:
return
self.first_name = first_name
self.last_name = last_name
self._fullname = value

@property
def emails(self) -> tuple[str, ...]:
"""Return the addresses of this person, most preferred first.

:returns: The addresses.
"""
return getattr(self, "_emails", ())

@emails.setter
def emails(self, value: object) -> None:
"""Store the addresses, normalized and de-duplicated.

:param value: The addresses.
"""
self._emails = clean(value)

@property
def email(self) -> str:
"""Return the address that stands for this person.

:returns: The first verified address, else the first address.
"""
return preferred_address(self.getId(), self.emails)

@email.setter
def email(self, value: str) -> None:
"""Move an address to the front of :attr:`emails`.

:param value: The address.
"""
address = normalize(value)
if not address:
return
self.emails = (address, *(a for a in self.emails if a != address))

@property
def verified_emails(self) -> tuple[str, ...]:
"""Return the addresses this site verified for this person.

:returns: The verified addresses.
"""
return verified_addresses(self.getId(), self.emails)
1 change: 1 addition & 0 deletions backend/src/kitconcept/core/dependencies.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<include package="plonegovbr.socialmedia" />

<include package="pas.plugins.authomatic" />
<include package="pas.plugins.identity" />
<include package="pas.plugins.keycloakgroups" />
<include package="pas.plugins.oidc" />

Expand Down
3 changes: 3 additions & 0 deletions backend/src/kitconcept/core/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"souper.plone",
"collective.volto.otp",
"pas.plugins.authomatic",
"pas.plugins.identity",
# Identity provider (server) layer of pas.plugins.identity, not offered yet
"pas.plugins.identity.server",
"pas.plugins.oidc",
"pas.plugins.keycloakgroups",
]
Expand Down
25 changes: 25 additions & 0 deletions backend/src/kitconcept/core/patches/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Give ``Person`` the user attributes of ``pas.plugins.identity``.

``Person`` is the user content type (``user_content_type``), so the identity
catalog and the PAS plugins read from a ``Person`` the attributes they read
from a ``UserProfile``. The ``collective.person`` class gets them here.
"""

from logging import Logger


def _patch_person(logger: Logger):
from collective.person.content.person import Person
from kitconcept.core.content import person

for name in person.USER_ATTRIBUTES:
setattr(Person, name, getattr(person.PersonUserMixin, name))
logger.info("Patched collective.person Person with user attributes")


def apply_patch(logger: Logger):
"""Apply the pas.plugins.identity patches.

:param logger: Logger used to report the patches.
"""
_patch_person(logger)
Loading
Loading