-
-
Notifications
You must be signed in to change notification settings - Fork 207
Description
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.apialready transitively depends onplone.app.users(viaProducts.CMFPlone). Making this an explicit dependency is acceptable.plone.restapimust maintain backward compatibility with older Plone versions viatry/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.restapiusestry/except ImportErrorfallback for older Plone versions. - plone.api signature: The
create()function signature does not change. Existing callers continue to work. The only behavioral change: whenuse_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
- New feature version of
plone.app.users(standalonegenerate_user_id,generate_login_namein utils) - Bugfix version of
plone.api(respect UUID/email settings inuser.create()) - Cleanup version of
plone.restapi(use standalone functions with BBB fallback)
Participants
This PLIP was prepared with Claude agentic support (Claude Code / Opus 4.6).
Metadata
Metadata
Assignees
Labels
Type
Projects
Status