Profile: read-only name for cn-named directory entries

slapd refuses to remove an RDN value, and every legacy account is
cn=<name>,ou=People, so a name edit there always failed. Those accounts
now see their name read-only (the view rejects the profile action and
update_profile refuses as a backstop); uid-named accounts from website
registration can still edit it. README documents the cn/mail self-write
ACL and mail uniqueness overlay now applied in production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 22:05:29 -05:00
co-authored by Claude Opus 5.5
parent d73f7d1b80
commit 86ce546a17
5 changed files with 69 additions and 3 deletions
+7 -1
View File
@@ -394,7 +394,10 @@ Other directory attributes (photos, SSH keys, addresses, `displayName`, etc.)
are neither shown nor modified, so existing values are left in place. If the
connector's `nameAttr` is changed, change `profiles.TEXT_ATTRIBUTES` to match.
Saving the name replaces every `cn` value with the single entered value; Dex
already uses only the first. Account DNs must not use `cn` as their RDN.
already uses only the first. slapd refuses to remove an RDN value, so accounts
whose DN is named by `cn` (the legacy `cn=<name>,ou=People` entries) see their
name read-only; renaming them would change DNs other services store. Accounts
created by website registration are `uid`-named and can edit their name.
**Directory prerequisites:**
@@ -404,6 +407,9 @@ already uses only the first. Account DNs must not use `cn` as their RDN.
Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall
back to a reused username. LDAP unavailability disables editing rather than
presenting an empty form that could erase values.
- Example ACL (insert before the catch-all rule), plus the `unique` overlay with
`olcUniqueURI: ldap:///dc=eom,dc=dev?mail?sub`:
`to attrs=cn,mail by self write by dn="cn=admin,dc=eom,dc=dev" write by dn="cn=readonly,dc=eom,dc=dev" read by * none`
- Every write binds with the member's **current LDAP password** and checks that
entry's UUID again. Configure directory **self-write ACLs** only for `cn`,
`mail` and self Password Modify; do not grant writes to `uid`,
+21
View File
@@ -16,6 +16,7 @@ from ldap3.core.exceptions import LDAPException
from ldap3.operation.search import compile_filter, parse_filter
from ldap3.protocol.controls import build_control
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import parse_dn
from . import directory
from .models import Account
@@ -83,6 +84,24 @@ def read_profile(account):
return values
def name_editable(dn):
"""False when cn names the entry: slapd refuses to remove an RDN value.
Legacy accounts are cn=<name>,ou=People; renaming them would change DNs
that other services store, so their name is shown read-only instead.
"""
try:
components = parse_dn(dn)
except Exception:
return False
for attribute, _value, separator in components:
if attribute.casefold() in {"cn", "2.5.4.3"}:
return False
if separator != "+":
return True
return False
def revision(account, values):
return signing.dumps({"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}, salt="ldap-profile")
@@ -118,6 +137,8 @@ def update_profile(account, cleaned, expected_revision):
with _authorized(account, cleaned["current_password"]) as (conn, values):
if proof != {"account": str(account.pk), "uuid": account.ldap_uuid, "digest": _digest(values)}:
raise ValueError("Your profile changed. Reload it before saving.")
if not name_editable(account.ldap_dn):
raise ValueError("This account's name is part of its directory entry name.")
changes = {}
for name in TEXT_ATTRIBUTES:
new_values = cleaned[name]
+31
View File
@@ -81,6 +81,17 @@ class ProfileFormTests(SimpleTestCase):
self.assertEqual(profiles.initial(profile_values(cn=["Alice", "Alice Example"])), {"cn": "Alice"})
self.assertEqual(profiles.initial(profile_values(cn=[])), {"cn": ""})
def test_name_editable_only_when_cn_does_not_name_the_entry(self):
for dn, editable in (
(ALICE_DN, True), ("uid=bob,ou=People,dc=eom,dc=dev", True),
("uid=bob,cn=Group,dc=eom,dc=dev", True),
("cn=Logan Fick,ou=People,dc=eom,dc=dev", False), ("CN=x,dc=eom,dc=dev", False),
("uid=bob+cn=Bob,ou=People,dc=eom,dc=dev", False), ("2.5.4.3=x,dc=eom,dc=dev", False),
("", False), ("not a dn", False),
):
with self.subTest(dn=dn):
self.assertIs(profiles.name_editable(dn), editable)
def test_field_groups_and_unique_password_ids(self):
form = self.form()
grouped = [*form.identity_fields(), *form.authorization_fields()]
@@ -481,6 +492,19 @@ class ProfileViewTests(TestCase):
self.account.refresh_from_db()
self.assertEqual(self.account.email, "alice@example.org")
def test_cn_named_account_sees_read_only_name_and_cannot_post(self):
Account.objects.filter(pk=self.account.pk).update(ldap_dn="cn=Alice Example," + SEARCH_DN)
self.read.side_effect = lambda account: copy.deepcopy(self.values)
response = self.client.get(self.profile_url)
self.assertFalse(response.context["name_editable"])
self.assertContains(response, "<strong>Alice Example</strong>")
self.assertNotContains(response, 'name="cn"')
self.assertNotContains(response, 'value="profile"')
self.assertContains(response, 'value="email"')
self.assertContains(response, 'value="password"')
self.assertEqual(self.client.post(self.profile_url, self.profile_data(cn="New Name")).status_code, 400)
self.assert_no_writes()
def test_photo_endpoint_removed(self):
with self.assertRaises(NoReverseMatch):
reverse("members:profile_photo")
@@ -640,6 +664,13 @@ class ProfileDirectoryTests(TestCase):
self.assertEqual(conn.modify.call_args.args, (ALICE_DN, {"cn": [(MODIFY_REPLACE, ["Changed"])]}))
self.assert_critical_assertion(conn.modify.call_args)
def test_profile_update_refuses_cn_named_entry(self):
conn, authorized = self.authorized()
self.account.ldap_dn = "cn=Alice Example," + SEARCH_DN
with patch("members.profiles._authorized", authorized), self.assertRaisesRegex(ValueError, "entry name"):
profiles.update_profile(self.account, self.cleaned(cn=["Changed"]), profiles.revision(self.account, self.values))
conn.modify.assert_not_called()
def test_profile_edit_without_timestamp_fails_closed(self):
values = profile_values(modifyTimestamp=[])
conn, authorized = self.authorized(values=values)
+2 -2
View File
@@ -274,7 +274,7 @@ def logout(request):
def _profile_context(account, values):
return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "email_values": values["mail"], "revision": profiles.revision(account, values)}
return {"account": account, "profile_loaded": True, "name_editable": profiles.name_editable(account.ldap_dn), "name_values": values["cn"], "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "email_values": values["mail"], "revision": profiles.revision(account, values)}
@sensitive_post_parameters()
@@ -298,7 +298,7 @@ def profile(request):
return HttpResponse("Please try again later.", status=429)
action = request.POST.get("action")
forms = {"profile": ("form", ProfileForm), "email": ("email_form", ProfileEmailForm), "password": ("password_form", ProfilePasswordForm)}
if action not in forms:
if action not in forms or (action == "profile" and not context["name_editable"]):
return HttpResponse(status=400)
form_key, form_class = forms[action]
form = form_class(request.POST)
+8
View File
@@ -45,6 +45,7 @@
<nav class="profile-jump-links" aria-label="Profile sections">
<a href="#identity">Name</a><a href="#email">Email</a>{% if newsletter_status %}<a href="#newsletter">Newsletter</a>{% endif %}<a href="#password">Password</a>
</nav>
{% if name_editable %}
<form id="identity" action="{% url 'members:profile' %}" method="post" class="member-panel profile-section profile-edit-form" aria-labelledby="identity-heading">
{% csrf_token %}
<input type="hidden" name="action" value="profile">
@@ -55,6 +56,13 @@
{% include "profile_fields.html" with fields=form.authorization_fields only %}
<button class="button button-primary" type="submit">Save name</button>
</form>
{% else %}
<section id="identity" class="member-panel profile-section" aria-labelledby="identity-heading">
<h2 id="identity-heading">Name</h2>
<p class="profile-readonly-name"><strong>{{ name_values|first|default:account.uid }}</strong></p>
<p class="member-small">Your name is part of how your account is stored, so it can't be changed here. Contact eric@eom.dev if it needs updating.</p>
</section>
{% endif %}
<section id="email" class="member-panel profile-section" aria-labelledby="email-heading">
<h2 id="email-heading">Email address</h2>
<p>Current email address:</p>