Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Validates attribute names (alphanumeric, hyphens, and semicolons only; max 128 chars).
  2. Rejects attributes on the server-side deny-list (see below) with 403.
  3. Calls user_mod via JSON-RPC with S4U2Self impersonation.
  4. On success: calls user_show_with_rights to get fresh state, returns the updated profile (including cert_labels).
  5. On IPA ACL denial (code 4301): returns 403 with the IPA error message.
  6. 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

  1. Default groups from KNOWN_FIELDS — minus any replaced by custom groups with the same label, minus hidden groups.
  2. Custom groups from GET /api/auth/info self_service.groups — in config order. Attributes claimed by a custom group are removed from their default group or from “Other”.
  3. “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

  1. User clicks “Edit” → component copies attrs into a draft state object, sets editing = true.
  2. Writable fields (attributelevelrights[attr] contains w) become TextInput controls. Read-only fields stay as display text.
  3. Multi-valued fields render each value as a TextInput with a remove button, plus an “Add” button to append a new value.
  4. User clicks “Save” → component diffs draft against original attrs, sends only changed attributes to PATCH /api/me/profile. Save button shows a loading spinner.
  5. On success → toast notification, update attrs from response, exit edit mode.
  6. On error → inline Alert with error message, stay in edit mode.
  7. 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 w right on ipasshpubkey)

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 w right on usercertificate)

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 (TextInput type=password)
  • New password (TextInput type=password)
  • Confirm new password (TextInput type=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 (and cn in parentheses).
  • Selecting a result sets the field value to the user’s DN.
  • An “×” button clears the field (sends null in 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:

FieldRendererDetails
loginshellFormSelect dropdownStatic 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.
preferredlanguageFormSelect dropdownCommon locale codes plus freeform.
mailTextInput type=emailBrowser-native email validation.
telephonenumber, mobile, pager, facsimiletelephonenumber, homephoneTextInput type=telBrowser-native tel input.
labeleduri, inetuserhttpurlTextInput type=urlBrowser-native URL validation.
manager, secretaryUser pickerTypeahead search (see above).
ipasshpubkeyDedicated sectionSee SSH keys section above.
usercertificateDedicated sectionSee 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.