Self-Service Profile Editing
This document describes the design for making the /ui/me self-management page
editable for IPA users, bringing it to parity with FreeIPA’s self-service page.
Users can modify any attribute that FreeIPA ACIs grant them write access to.
Overview
The current /ui/me page displays read-only profile information and manages
passkeys and OTP tokens. This design adds:
- Inline editing of user profile attributes (name, contact, address, work info, etc.)
- Runtime editability detection via IPA
attributelevelrights - Multi-value support for attributes like email and phone numbers
- SSH public key management (add/remove)
- X.509 certificate management (upload/remove)
- Password change flow
- Manager/secretary user picker with typeahead search
- Typed field renderers (email, tel, URL, select dropdowns)
- Admin-configurable visibility — hide groups/fields, define custom groups
- Extensible attribute support — unknown IPA attributes render automatically
Architecture
Data flow
┌──────────────┐ GET /api/me/profile ┌──────────────┐ user_show --all --rights
│ │ ─────────────────────→ │ │ ─────────────────────────→ IPA
│ Frontend │ │ Backend │ (S4U2Self impersonation)
│ ProfilePage │ PATCH /api/me/profile │ /api/me/* │
│ │ ─────────────────────→ │ │ user_mod ──────────────→ IPA
└──────────────┘ └──────────────┘
GET /api/auth/info
←─────────────────────
(includes self_service config)
The backend calls IPA JSON-RPC as the authenticated user via S4U2Self
impersonation. FreeIPA ACIs are the security boundary — the backend does not
duplicate access control logic. The user_add_passkey() / user_remove_passkey()
methods in ipa.rs already prove this pattern works.
Backend
New IPA API client methods
Added to IpaApiClient in src/auth/ipa.rs:
user_show_with_rights(uid) — calls user_show with
{"all": true, "rights": true} via JSON-RPC. Returns the raw
serde_json::Value result (not parsed into FullUserEntry) because the
frontend needs all attributes and rights as-is.
user_mod(uid, changes) — generic user_mod JSON-RPC call. Accepts a
HashMap<String, serde_json::Value> of attribute modifications. Maps null
values to empty strings (IPA convention for clearing an attribute).
passwd(uid, current_password, new_password) — calls the IPA passwd
JSON-RPC command for password changes.
user_find(query, sizelimit) — calls user_find with a search string
and size limit. Returns a vec of {uid, cn, dn} for the user picker.
New endpoints in /api/me/
Added to src/routes/me/mod.rs:
GET /api/me/profile
Returns the full user profile with attribute-level rights.
Response body:
{
"attrs": {
"uid": ["alice"],
"givenname": ["Alice"],
"sn": ["Wonderland"],
"cn": ["Alice Wonderland"],
"displayname": ["Alice Wonderland"],
"mail": ["alice@example.com"],
"telephonenumber": ["555-1234"],
"mobile": ["555-5678"],
"title": ["Engineer"],
"loginshell": ["/bin/bash"],
"ipasshpubkey": ["ssh-ed25519 AAAA... alice@host"],
...
},
"attributelevelrights": {
"givenname": "rscwo",
"sn": "rscwo",
"mail": "rscwo",
"uid": "rsc",
...
}
}
All attributes come as arrays (IPA convention – even single-valued LDAP
attributes are returned as single-element arrays). The attributelevelrights
map contains per-attribute right strings where w means writable. Attributes
listed in [webui.self_service] readonly_attrs have w stripped from their
rights before the response is returned, so the frontend will not offer edit
controls for them.
The response also includes a cert_labels array of display labels for each
certificate in usercertificate/usercertificate;binary, extracted
server-side from the certificate Subject CN (or a fallback "Certificate #N"
when parsing fails).
For non-IPA users (static users), returns a minimal response without
attributelevelrights or cert_labels. The frontend detects this and hides
the Edit button.
PATCH /api/me/profile
Accepts attribute modifications. Each key maps to the new value as an array
(matching the GET response format), or null to clear:
{
"givenname": ["Ali"],
"mail": ["ali@example.com", "alice.alt@example.com"],
"title": null
}
The handler:
- Validates attribute names (alphanumeric, hyphens, and semicolons only; max 128 chars).
- Rejects attributes on the server-side deny-list (see below) with 403.
- Calls
user_modvia JSON-RPC with S4U2Self impersonation. - On success: calls
user_show_with_rightsto get fresh state, returns the updated profile (includingcert_labels). - On IPA ACL denial (code 4301): returns 403 with the IPA error message.
- On other IPA errors: returns 503.
Server-side attribute deny-list (DENIED_ATTRS): The PATCH endpoint
unconditionally rejects modifications to the following attributes, regardless
of IPA ACIs: objectclass, dn, uid, uidnumber, gidnumber,
krbprincipalname, krbprincipalkey, userpassword, nsaccountlock,
memberof, ipauniqueid, mepmanagedentry, ipapasskey, homedirectory,
loginshell. This is a defense-in-depth measure; IPA ACIs are still the
primary security boundary.
POST /api/me/password
Password change endpoint. Request body:
{
"current_password": "old",
"new_password": "new"
}
Calls IpaApiClient::passwd() via S4U2Self. IPA enforces password policy
(minimum length, complexity, history) and returns specific error messages on
violation. The backend propagates these as 400 responses with the IPA error text.
GET /api/me/user-search?q=fragment
User search for the manager/secretary picker. Calls user_find with
sizelimit=10. Returns:
[
{ "uid": "alice", "cn": "Alice Wonderland", "dn": "uid=alice,cn=users,..." },
{ "uid": "bob", "cn": "Bob Builder", "dn": "uid=bob,cn=users,..." }
]
Requires a minimum query length of 2 characters. Runs as the authenticated user so IPA access controls apply.
Extended GET /api/auth/info
The existing auth info endpoint gains a self_service block:
{
"display_name": "Ahdapa",
"realm": "EXAMPLE.COM",
"gssapi": true,
"self_service": {
"hidden_groups": ["Work"],
"hidden_attrs": ["carlicense"],
"readonly_attrs": ["loginshell", "manager"],
"hide_unknown_attrs": false,
"groups": [
{
"key": "fedora-account",
"label": "Fedora Account",
"fields": [
{ "attr": "fasircnick", "label": "IRC nick" },
{ "attr": "fasgpgkeyid", "label": "GPG key ID" }
]
}
]
}
}
Configuration
New [webui.self_service] section in ahdapa.toml:
[webui.self_service]
# Hide entire groups from the self-management page
hidden_groups = []
# Hide individual attributes (even if they'd otherwise appear)
hidden_attrs = []
# Force specific attributes to be read-only in the UI, even if IPA grants
# write permission. The backend strips 'w' from attributelevelrights for
# these attributes so the frontend won't offer edit controls.
readonly_attrs = []
# Hide the "Other attributes" catch-all group
hide_unknown_attrs = false
# Custom groups — define new sections or reorganize attributes.
# Attributes claimed here are removed from their default group (or "Other").
# If a custom group uses the same label as a default group, the custom
# definition replaces the default group's field list entirely.
[webui.self_service.groups.fedora-account]
label = "Fedora Account"
fields = [
{ attr = "fasircnick", label = "IRC nick" },
{ attr = "fasgpgkeyid", label = "GPG key ID" },
{ attr = "fasrhbzemail", label = "Bugzilla email" },
]
[webui.self_service.groups.communication]
label = "Communication"
fields = [
{ attr = "mail" }, # keeps its default label "Email"
{ attr = "mobile" },
{ attr = "xmppid", label = "XMPP" }, # custom attr with label
]
Rust config types:
#![allow(unused)]
fn main() {
#[derive(Deserialize, Default)]
pub struct SelfServiceConfig {
#[serde(default)]
pub hidden_groups: Vec<String>,
#[serde(default)]
pub hidden_attrs: Vec<String>,
#[serde(default)]
pub readonly_attrs: Vec<String>,
#[serde(default)]
pub hide_unknown_attrs: bool,
#[serde(default)]
pub groups: HashMap<String, CustomGroupConfig>,
}
#[derive(Deserialize)]
pub struct CustomGroupConfig {
pub label: String,
#[serde(default)]
pub fields: Vec<CustomFieldConfig>,
}
#[derive(Deserialize)]
pub struct CustomFieldConfig {
pub attr: String,
pub label: Option<String>,
}
}
The config defaults are all empty/false, meaning everything is visible and no custom groups are defined. Hiding is UI-only — IPA ACIs remain the security boundary on the PATCH endpoint.
Frontend
Data model
UserProfileDetail — generic attribute map, not a fixed interface:
interface UserProfileDetail {
attrs: Record<string, string[]>
attributelevelrights?: Record<string, string>
cert_labels?: string[]
warning?: string
}
SelfServiceConfig — admin visibility config from GET /api/auth/info:
interface SelfServiceConfig {
hidden_groups: string[]
hidden_attrs: string[]
readonly_attrs: string[]
hide_unknown_attrs: boolean
groups: CustomGroup[]
}
interface CustomGroup {
key: string
label: string
fields: { attr: string; label?: string }[]
}
Field registry
A KNOWN_FIELDS array defines display metadata for standard IPA attributes:
interface FieldDef {
attr: string
label: string
group: string
multi: boolean
readOnly?: boolean
inputType?: 'text' | 'email' | 'tel' | 'url'
renderer?: 'select' | 'user-picker' | 'ssh-keys' | 'certificates' | 'password'
options?: string[]
}
Fields with a renderer value of ssh-keys, certificates, or password
are excluded from inline field groups and rendered as dedicated sections.
A HIDDEN_ATTRS set filters out operational attributes that should never
render in the “Other attributes” group:
const HIDDEN_ATTRS = new Set([
'dn', 'objectclass', 'attributelevelrights',
'memberof_group', 'memberof_netgroup', 'memberof_role',
'memberof_hbacrule', 'memberof_sudorule', 'memberof_sudocmdgroup',
'has_keytab', 'has_password',
'krbpasswordexpiration', 'krbmaxpwdlife', 'krbminpwdlife',
'krbpwdhistorylength', 'krbpwdmindiffchars', 'krbpwdminlength',
'krbpwdmaxfailure', 'krbpwdfailurecountinterval', 'krbpwdlockoutduration',
'passwordgracelimit', 'krbmaxrenewableage', 'krbmaxticketlife',
'krbloginfailedcount', 'krblastpwdchange', 'krblastsuccessfulauth',
'krblastfailedauth',
'ipapasskey', 'ipatokenowner', 'ipatokenotpkey',
'krbprincipalkey', 'userpassword', 'sambalmpassword', 'sambantpassword',
'ipauniqueid', 'mepmanagedentry', 'nsaccountlock',
'ipantsecurityidentifier', 'ipanthash',
'ipasshpubkey', 'usercertificate', 'usercertificate;binary',
])
Rendering order
- Default groups from
KNOWN_FIELDS— minus any replaced by custom groups with the same label, minus hidden groups. - Custom groups from
GET /api/auth/infoself_service.groups— in config order. Attributes claimed by a custom group are removed from their default group or from “Other”. - “Other attributes” — remaining unclaimed, non-hidden attributes. Hidden
entirely when
hide_unknown_attrs = true.
Fields without a value and without write rights are not rendered. Groups with no rendered fields are not rendered.
Component structure
ProfilePage
├── Masthead (unchanged)
├── PageSection: "My profile"
│ ├── Alert (errors)
│ ├── ActionList: [Edit] / [Save + Cancel]
│ ├── ProfileFieldGroup group="Identity"
│ │ ├── ProfileField attr="givenname" ...
│ │ ├── ProfileField attr="sn" ...
│ │ └── ...
│ ├── ProfileFieldGroup group="Contact"
│ ├── ProfileFieldGroup group="Address"
│ ├── ProfileFieldGroup group="Work"
│ ├── ProfileFieldGroup group="Account" (always read-only: uid, uidnumber, ...)
│ ├── ProfileFieldGroup group="<custom groups>" (from config)
│ ├── ProfileFieldGroup group="Other attributes" (dynamic, unknown fields)
│ ├── PasswordSection
│ ├── SshKeysSection
│ └── CertificatesSection
├── Divider
├── PageSection: Passkeys (unchanged)
├── PageSection: OTP tokens (unchanged)
Edit flow
- User clicks “Edit” → component copies
attrsinto adraftstate object, setsediting = true. - Writable fields (
attributelevelrights[attr]containsw) becomeTextInputcontrols. Read-only fields stay as display text. - Multi-valued fields render each value as a
TextInputwith a remove button, plus an “Add” button to append a new value. - User clicks “Save” → component diffs
draftagainst originalattrs, sends only changed attributes toPATCH /api/me/profile. Save button shows a loading spinner. - On success → toast notification, update
attrsfrom response, exit edit mode. - On error → inline
Alertwith error message, stay in edit mode. - User clicks “Cancel” → discard draft, exit edit mode.
SSH public key management
Rendered as a dedicated section below the profile field groups.
Display: A table showing each key with:
- Key type (extracted from key string:
ssh-rsa,ssh-ed25519, etc.) - Comment (trailing field from the key string)
- Fingerprint (SHA-256, computed client-side)
- Remove button (if user has
wright onipasshpubkey)
Add: “Add SSH public key” button opens a modal with a TextArea. Client-side
validation checks the key parses as valid OpenSSH public key format
(type base64 [comment]). On submit: calls PATCH /api/me/profile with the
full ipasshpubkey array (existing keys + new one).
Remove: Confirm modal, then PATCH with the key removed from the array.
X.509 certificate management
Rendered as a dedicated section below SSH keys.
Display: A table showing each certificate with:
- Subject CN (parsed client-side from base64-encoded DER)
- Issuer CN
- Not After / expiry date
- Serial number
- Remove button (if
wright onusercertificate)
Parsing uses the Web Crypto API or a lightweight ASN.1 parser. If parsing fails, shows “Certificate #N” as fallback.
Add: “Add certificate” button opens a modal accepting:
- File upload (
<input type="file" accept=".pem,.crt,.cer,.der">) - Text paste into a
TextArea(for PEM format)
Client-side: strip PEM headers if present, validate base64, attempt basic
X.509 structure parse. Submit: PATCH with cert added to usercertificate array.
Remove: Confirm modal, then PATCH with cert removed.
Password change
Rendered as a dedicated section showing password expiration date from
krbpasswordexpiration (or “Password set” / “No password set”).
A “Change password” button opens a modal with:
- Current password (
TextInputtype=password) - New password (
TextInputtype=password) - Confirm new password (
TextInputtype=password)
Client-side: new password and confirm must match. On submit: calls
POST /api/me/password. IPA password policy errors are displayed in the
modal’s inline Alert. No password strength meter — IPA enforces policy
server-side and error messages are authoritative.
Manager/secretary user picker
In edit mode, manager and secretary fields render as a typeahead
autocomplete TextInput.
- User types a username fragment (minimum 2 characters).
- Frontend calls
GET /api/me/user-search?q=fragment. - Results appear as a dropdown showing
uid(andcnin parentheses). - Selecting a result sets the field value to the user’s DN.
- An “×” button clears the field (sends
nullin PATCH).
In display mode, the uid= component is extracted from the DN for a readable
display. If the DN cannot be parsed, the raw value is shown.
Typed field renderers
Certain fields use specific input types or custom renderers:
| Field | Renderer | Details |
|---|---|---|
loginshell | FormSelect dropdown | Static list of common shells (/bin/bash, /bin/sh, /bin/zsh, /bin/fish, /sbin/nologin, /bin/false) plus a “Custom…” option that switches to a freeform TextInput. |
preferredlanguage | FormSelect dropdown | Common locale codes plus freeform. |
mail | TextInput type=email | Browser-native email validation. |
telephonenumber, mobile, pager, facsimiletelephonenumber, homephone | TextInput type=tel | Browser-native tel input. |
labeleduri, inetuserhttpurl | TextInput type=url | Browser-native URL validation. |
manager, secretary | User picker | Typeahead search (see above). |
ipasshpubkey | Dedicated section | See SSH keys section above. |
usercertificate | Dedicated section | See certificates section above. |
API client additions
New methods in webui/src/api.ts:
api.me.profile() // GET /api/me/profile
api.me.updateProfile(changes) // PATCH /api/me/profile
api.me.changePassword(currentPassword, newPassword) // POST /api/me/password
api.me.searchUsers(query) // GET /api/me/user-search?q=...
Non-IPA users
For non-IPA users (static users), GET /api/me/profile returns a minimal
response without attributelevelrights. The frontend detects the absence and
hides the Edit button. Everything remains read-only, matching current behavior.