Skip to content

PLIP: Fix user creation to respect use_uuid_as_userid across all code paths #4292

@jensens

Description

@jensens

PLIP (Plone Improvement Proposal)

Responsible Persons

Proposer: @jensens

Seconder:

Abstract

plone.api.user.create() ignores the use_uuid_as_userid and use_email_as_login security settings.
Only the browser-based registration form (plone.app.users) handles them correctly.
plone.restapi works around this by instantiating the form view just to call two methods — functional but not DRY.

This PLIP extracts the user ID / login name generation logic from the form class into standalone functions in plone.app.users.utils, making them the single source of truth for all code paths.

Motivation

When a user is deleted and recreated with the same username, the new user inherits the old user's local roles (see #3937).
The use_uuid_as_userid setting exists to prevent this by generating unique UUIDs as user IDs.

Current state of user creation code paths:

Code path Respects use_uuid_as_userid? How
plone.app.users (browser registration form) Yes BaseRegistrationForm.generate_user_id()
plone.restapi (POST /@users) Yes (indirectly) Instantiates the form view to call its methods
plone.api.user.create() No Hardcoded: user_id = email or username

plone.api.user.create() is the intended programmatic API for user creation.
Its failure to respect these settings means any code using it (add-ons, migration scripts, tests) silently ignores site security configuration.

Reference: #3937 (comment)

Assumptions

  • plone.api already transitively depends on plone.app.users (via Products.CMFPlone). Making this an explicit dependency is acceptable.
  • plone.restapi must maintain backward compatibility with older Plone versions via try/except ImportError.
  • The extracted standalone functions have the same behavior as the existing form methods — this is a refactoring, not a behavior change.

Proposal & Implementation

1. Extract standalone functions into plone.app.users.utils

Move logic from BaseRegistrationForm.generate_user_id() (register.py:127-222) and BaseRegistrationForm.generate_login_name() (register.py:224-271) into two standalone functions:

# plone/app/users/utils.py

def generate_user_id(context, data):
    """Generate a user id from data.

    Checks (in order):
    1. IUserIdGenerator utility (integration hook)
    2. use_uuid_as_userid registry setting → UUID
    3. username (if not using email as login)
    4. fullname-based id with collision handling
    5. fallback to email/username

    Args:
        context: Any Plone context object (for tool lookups).
        data: Dict with keys 'username', 'email', 'fullname'.
              Sets data['user_id'] as side effect.
    Returns:
        The generated user_id string.
    """

def generate_login_name(context, data):
    """Generate a login name from data.

    Checks (in order):
    1. ILoginNameGenerator utility (integration hook)
    2. username (if not using email as login)
    3. email (if using email as login)

    Args:
        context: Any Plone context object (for PAS applyTransform).
        data: Dict with keys 'username', 'email'.
              Sets data['login_name'] as side effect.
    Returns:
        The generated login_name string.
    """

These are 1:1 extractions of the existing form methods. self.context becomes the context parameter, self._get_security_settings() is resolved inline via IRegistry.

2. Update BaseRegistrationForm to delegate

# plone/app/users/browser/register.py

class BaseRegistrationForm(...):
    def generate_user_id(self, data):
        from plone.app.users.utils import generate_user_id
        return generate_user_id(self.context, data)

    def generate_login_name(self, data):
        from plone.app.users.utils import generate_login_name
        return generate_login_name(self.context, data)

3. Fix plone.api.user.create()

# plone/api/user.py

def create(email=None, username=None, password=None, roles=("Member",), properties=None):
    ...
    from plone.app.users.utils import generate_user_id, generate_login_name

    site = portal.get()
    data = {"username": username, "email": email, "fullname": properties.get("fullname", "")}
    generate_user_id(site, data)
    generate_login_name(site, data)
    user_id = data["user_id"]
    login_name = data.get("login_name", username or email)

    ...
    registration.addMember(user_id, password, roles, properties=properties)

    if user_id != login_name:
        pas = portal.get_tool("acl_users")
        pas.updateLoginName(user_id, login_name)

    return get(userid=user_id)  # was: get(username=user_id)

4. Simplify plone.restapi with BBB fallback

# plone/restapi/services/users/add.py (top of file)

try:
    from plone.app.users.utils import generate_login_name
    from plone.app.users.utils import generate_user_id
    HAS_STANDALONE_GENERATE = True
except ImportError:
    # BBB: Plone 6.0 and older
    HAS_STANDALONE_GENERATE = False
# In _add_user():

if HAS_STANDALONE_GENERATE:
    generate_user_id(self.context, user_id_data)
    generate_login_name(self.context, user_id_data)
else:
    # BBB: delegate to register form view
    register_view = getMultiAdapter(
        (self.context, self.request), name="register"
    )
    register_view.generate_user_id(user_id_data)
    register_view.generate_login_name(user_id_data)

5. Add plone.app.users as explicit dependency in plone.api

Risks

  • Low risk: The extracted functions are 1:1 copies of existing, well-tested code.
  • Backward compatibility: plone.restapi uses try/except ImportError fallback for older Plone versions.
  • plone.api signature: The create() function signature does not change. Existing callers continue to work. The only behavioral change: when use_uuid_as_userid=True, the user ID is now a UUID instead of the username/email. This is the intended behavior that was previously broken.

Deliverables

  1. New feature version of plone.app.users (standalone generate_user_id, generate_login_name in utils)
  2. Bugfix version of plone.api (respect UUID/email settings in user.create())
  3. Cleanup version of plone.restapi (use standalone functions with BBB fallback)

Participants

@jensens


This PLIP was prepared with Claude agentic support (Claude Code / Opus 4.6).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions