Skip to content

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 15 Oct 00:04
· 17 commits to main since this release
aa0b8e2

Breaking / behaviour changes

  • Machine deleteUser / modrdn narrowed (Q16). With the v2c LDAP rules installed,
    machine deleteUser and modrdn only reach entries created through W_data; other
    entries are refused.
  • ppolicy state attributes are read-only for self (#400). pwdReset,
    pwdStartTime, pwdEndTime and pwdAccountLockedTime can no longer be written by
    the user on its own entry. The ACL is bootstrap-only: existing volumes keep their
    stored olcAccess until upgraded in place (see image/README.md, in-place upgrade).
  • The image refuses to start with extended or all in LDAP_ACCESSLOG_OPS (#398).
    Use writes reads bind.

Web UI

  • Users and Groups lists page through the cursor API (limit/cursor/q, #253).
    Search is now server-side and matches only uid/cn/mail/displayName (users)
    and cn/description (groups); the old client-side search over department,
    organization and organizationalUnit is gone (accepted in D215-7). The list no
    longer shows a total count.

Image

  • The entrypoint now refuses to start when the accesslog overlay is enabled and
    LDAP_ACCESSLOG_OPS contains extended or all (#398, F-7, D398-1). OpenLDAP
    2.6.15's accesslog overlay aborts slapd (accesslog.c:1623, exit 133) on the first
    Password Modify, including the human setPassword path. Use writes reads bind;
    there is no override until upstream fixes it.

API

  • Machine write v2c opened (#379, T-026): machine setPassword. With
    MACHINE_WRITE_ENABLED and MACHINE_WRITE_CRED_ENABLED on and
    directory.users.password.write in a client's ceiling, a machine client sets the
    initial password of an account it created itself with createUser
    credentialPending: true (body exactly {dn, password}, Idempotency-Key
    required, no If-Match, response {}; any other target 403, an account whose
    password was already set 409). It runs as the separate W_cred identity behind
    the T-028 premise checks. OpenAPI marks it machineBearer /
    x-machine-scope: directory.users.password.write. A ceiling may now name the
    password scope; with MACHINE_WRITE_CRED_ENABLED=false it still starts and the
    request is 403 scope_denied without any W_cred bind (the emergency block;
    AC-011 (a) is revised to 403, Q19).
    Unlock stays human-only and is the only write scope still refused in a ceiling.

  • Contract narrowing with v2c on (Q16, accepted 2026-10-14): a deployment that
    installs the v2c LDAP rules narrows machine deleteUser (and W_data's direct
    LDAP modrdn; the machine API has no rename operation) to entries W_data created; deleting an administrator-made user (any class) or a
    container is refused by the directory (LDAP 50) and answered 403 forbidden.
    Groups (v2b deleteGroup) and deployments without v2c are unchanged.

  • W_cred gets a no-lockout password policy (CHANGE.md Q17, decided 2026-10-14):
    guide docs/machine-write-account.md 8.1 attaches wcred-policy.ldif
    (pwdLockout FALSE, no pwdMaxFailure), so wrong binds can no longer lock
    W_cred and stop setPassword; the operator owns brute-force mitigation (long
    random password, network restriction, failed-bind alert). The premise checks
    enforce it (Q18 (b)): at start-up and every 60 s W_cred's policy must exist,
    differ from the default and pending policies and state pwdLockout: FALSE with
    no pwdMaxFailure/pwdMaxAge/pwdMustChange TRUE, else start-up is refused /
    setPassword answers 503.

  • Accepted risk (2026-10-14): with v2c on, a leaked write credential can rename an
    administrator-made user's mail/uid and give the old value to a new pending
    account; applications that resolve the login name from mail/uid are at risk
    (bind by entryUUID or alert on changes, or keep v2c off).

  • Chart: ui.machineAuth.write.cred.* renders the W_cred identity, the
    configuration reader and the canaries (MACHINE_WRITE_CRED_*; passwords are
    Secret references only, every identity and Secret key distinct). It still
    refuses next to audit.enabled=true or an accesslog ops list holding
    extended/all. The chart still grants no write scope in allowedClients.

  • Machine write v2c (#379, T-020) configuration and start-up validation only; no
    operation is opened
    (directory.users.password.write is still refused in a
    ceiling and the password routes stay 403 scope_denied with no bind). New
    MACHINE_WRITE_CRED_ENABLED (default false; when off no other
    MACHINE_WRITE_CRED_* is read). When on, start-up requires
    MACHINE_WRITE_CRED_BIND_DN/_PASSWORD (distinct from every other identity, in
    the protected set), a non-empty MACHINE_WRITE_PRIVILEGED_GROUPS,
    MACHINE_WRITE_CRED_SUBTREES inside the same client's MACHINE_WRITE_SUBTREES,
    and MACHINE_WRITE_CRED_PENDING_POLICY_DN (not the default policy); optional
    MACHINE_WRITE_CRED_MIN_LENGTH (12–1024, default 12) and
    MACHINE_WRITE_CRED_POLICY_DNS. The policy entries join the protected set, so a
    write ceiling holding one fails start-up. The chart is unchanged. See
    machine-write-account.md section 8.

  • Machine write v2c (#379, T-021) LDAP fragments, opt-in and not yet used by the app: W_cred
    (write-cred.ldif, initial password on pending W_data-made accounts only, cleanup of the marker
    and pending policy values) and the W_data v2c rules (write-data-cred.ldif). Installing them
    requires olcAddContentAcl TRUE on the main database and narrows machine deleteUser/modrdn to
    entries W_data created, groups excepted (Q16). Guide docs/machine-write-account.md section 8; the read-only
    checker gains --cred-dn/--pending-policy-dn/--privileged-group/--wcred-unpinned; non-pinned
    providers get write-cred-deny.ldif. Image defaults unchanged.

  • Machine write v2c (#379, T-023) password limits and audit, still not opened:
    new MACHINE_WRITE_CRED_RATE_LIMIT_RPS (global password budget, 1–10, default 1,
    read only with MACHINE_WRITE_CRED_ENABLED=true) and a per-target serialization
    (entryUUID, shared across clients); either refusal is 429 machine_rate_limited
    with Retry-After. Audit lines gain compartment=cred (no password field).

  • Machine write v2c (#379, T-025) per-compartment emergency-block drill for the password
    compartment, tests and docs only, setPassword still closed (T-026 opens it). New
    scripts/test/test-machine-write-cred-drill.py and a live-tagged Go test measure the
    MACHINE_WRITE_CRED_ENABLED=false redeploy, the W_cred rotation (old credential refused,
    W_data/W_lock/read unaffected on running replicas, restarts fail until the new Secret is
    rolled out), the MACHINE_REQUEST_TIMEOUT bound of an in-flight request (8.0 s at 10 s, 2.0 s
    at 4 s), the W_cred lockout at pwdMaxFailure and the Revision 8 manual cleanup of a stuck
    entry, run live. docs/machine-auth-operations.md gains "Password compartment emergency
    block". A flag-only redeploy answers 503, not the 403 of AC-011 (a); AC-011 is corrected. The
    W_cred lockout policy is an open maintainer decision (CHANGE.md Q17).

  • Machine write v2c (#379, T-028) run-time premise checks and sweeps, still not
    opened
    (setPassword stays unreachable until T-026). With
    MACHINE_WRITE_CRED_ENABLED=true start-up now also requires
    MACHINE_WRITE_CRED_ACL_READER_DN/_PASSWORD (a cn=config reader, distinct from
    every other identity, in the protected set) and MACHINE_WRITE_CRED_CANARIES (one
    set of three locked canary entries inside every MACHINE_WRITE_CRED_SUBTREES DN), and
    reads MACHINE_WRITE_CRED_PENDING_TTL (default 24h, 1h–720h). The UI then checks,
    at start-up (failure: non-zero exit), before every password write (failure: that
    request 503, no write) and every 60 s (failure: password operations 503 until a
    passing cycle): the main database olcAccess against the guide's layout as text,
    olcAddContentAcl TRUE, the default and pending policy values against the floor
    (the image's default pwdMinLength 8 is below the floor 12: v2c deployments need
    LDAP_PASSWORD_MIN_LENGTH>=12
    , an intended operating cost), no cn=accesslog child
    readable by M, and the canary probes. The 60 s cycle also runs the stuck / TTL /
    orphan / foreign sweeps of the pending policy (W_cred cleans the first three, with a
    re-read retry; a cleanup that still fails closes the password operations and alerts;
    after a failed check the sweep only counts). Start-up sweeps read-only and refuses to
    start on a stuck entry; a cycle older than two periods closes the password operations.
    MACHINE_WRITE_CRED_POLICY_DNS has no effect yet (the pre-read admits only the pending
    policy). Only result codes and counts are logged. New
    fixtures config-reader.ldif, config-reader-main.ldif and guide sections 8.8–8.9
    (docs/machine-write-account.md). Chart:
    ui.machineAuth.write.cred.enabled (default false) exists only to refuse: next to
    audit.enabled=true (D379-14), next to an audit.accessLog.ops holding extended
    or all (F-7), and on its own until T-026.

  • Machine write v2c (#379, T-024) live proof of the password step, tests only, still
    not opened: scripts/test/test-machine-write-cred-secret-live.py runs the real
    pipeline (a live-tagged test server that adds setPassword to its own table) against
    slapd and scans responses, headers, the UI log and audit, the slapd log and
    cn=accesslog (rootdn and M) for the one-time sentinels and for hash patterns: 0
    in the configuration the chart and T-028 accept; with audit.enabled the auditlog
    carries hashes, which the chart refuses. The T-028 check-5 pre-hashed probe is now
    one fixed public constant ({SSHA} of zz, salt ldapium!; it was a random throwaway
    value): its refused Modify still leaves that value in cn=accesslog at start-up and
    every 60 s when writes is logged, and the scan allowlists exactly it (EVIDENCE.md
    §2k, T024-F1).

  • Machine write scope (#285) maintainer decisions, 2026-10-13, docs and one regression
    test only: the closed request-body guard is the sole defence against an objectClass
    value ADD on Modify (#386, option A; the val.regex allowlist B is not adopted) and
    against userPassword on a W_data Add (#390, option (a): slapd checks only
    entry/children on Add). Nested privileged members being lockable, the chart staying
    read-only for write scopes, and over-refusal of non-ASCII memberOf are recorded as
    accepted/by-design (#394 items 2, 5, 6). TestMachineWriteClosedFieldSet now covers
    userPassword spelling variants on every machine write operation. See
    CHANGE.md D17a/D17b/D18.

  • Machine write v2b hardening (#394, items 1, 3, 4). ACL (operators, re-apply on
    every node; roll back the old fragments first, guide section 7):
    write-lock.ldif
    grants W_lock only add on pwdAccountLockedTime, so a W_lock credential can
    set a lock but never delete or replace it (unlock stays human-only at the ACL
    level too); write-data.ldif moves description into its own rule with
    filter=(objectClass=groupOfNames), so W_data cannot write a user's description
    (the fragment now has 9 rules). Machine lockUser adds the marker instead of
    replacing it: re-locking is idempotent (204), an account the password policy
    locked with a timestamp answers 409 and keeps that lock. MACHINE_WRITE_GROUP_SEED_DN
    must be a direct child of the container that holds MACHINE_WRITE_DATA_BIND_DN,
    or start-up fails. scripts/check-machine-write-acl.sh reports the old fragments
    as a mismatch.

  • Optional LDAP machine-token revocation (#286), default off: validated tokens
    are checked against a background snapshot before request budgets or directory
    access. A revoked JTI or client cutoff returns 401 token_invalid; missing,
    invalid or expired snapshots return 503 unavailable with Retry-After only
    after token verification. The revocation subtree is protected from HTTP reads
    and administrator writes. Initialize the sentinel and schedule the operator
    heartbeat before enabling MACHINE_REVOCATION_ENABLED or the chart's
    ui.machineAuth.revocation.enabled; keep each replica on a stable LDAP node.
    The healthy propagation bound is REFRESH + 2×5 s + replication delay, subject
    to the clock and replication assumptions. A partition eventually refuses
    requests after sentinel freshness and snapshot grace expire. Disabling the
    feature removes these revocations; use the allowlist rollout backstop when
    rolling back. See operations and the
    change package for limits,
    migration, evidence and known follow-ups. Live drills (cutoff boundary,
    heartbeat stop, heartbeat-only aging) run in machine-keycloak-e2e.yml.

  • Optional machine-principal writes (#285, #372, #378), default off
    (MACHINE_WRITE_ENABLED, a switch separate from MACHINE_AUTH_ENABLED). Five
    single-entry operations open to a verified Keycloak client: createUser
    (without a password), patchUser, deleteUser (leaf users only),
    addGroupMember, removeGroupMember. Each client has a write-subtree ceiling
    (strict descendants only), a group allow-list and a separate write budget;
    Idempotency-Key is required (428 idempotency_key_required) and all but
    createUser need exactly one strong entryCSN If-Match (428
    if_match_required). Writes bind per request as a dedicated LDAP identity
    (MACHINE_WRITE_DATA_BIND_DN) behind the ACL in
    machine-write-account.md; the switch refuses to
    start without idempotency, a single replica and the write identity.
    Emergency block and measured limits: operations.
    Design and evidence: change package.
    The idempotency store is in memory, so a restart turns a replay into a
    re-execution (If-Match and entry uniqueness remain the second line).

  • A machine write whose target no longer exists now returns the cookie path's 404 not_found instead of a retryable 503 unavailable (#385). Only a genuine LDAP
    noSuchObject for the target maps to 404; directory failures stay 503 and a missing
    target outside the client's subtree is still the guard's 403. The audit line carries
    the new outcome=target_not_found (reason=request_rejected) rather than
    reason=upstream_error.

  • Chart: new optional ui.machineAuth.write.groupSeedDN renders MACHINE_WRITE_GROUP_SEED_DN
    (empty by default; the default render is byte-identical). The chart's allowedClients
    schema still lists only the read scopes, so write scopes cannot be granted through the chart yet.

  • Machine group and lock writes (#379, v2b, accepted 2026-10-12), default off:
    with MACHINE_WRITE_ENABLED=true (separate switch, default off; off, all of them
    stay 403 scope_denied with no bind, byte-identical to before) four more
    operations open, each needing its scope in the client ceiling: createGroup
    (directory.groups.create; the new group's only initial member is the fixed
    placeholder MACHINE_WRITE_GROUP_SEED_DN, never the write identity, and it is not
    writable by any machine client until the operator lists its DN in
    MACHINE_WRITE_GROUPS), patchGroup (directory.groups.update, description
    only; cn is 400), deleteGroup (directory.groups.delete, allow-listed groups
    only, never a privileged group) and lockUser (directory.users.lock, separate
    W_lock identity, refuses members of MACHINE_WRITE_PRIVILEGED_GROUPS; nested
    group membership is an accepted residual risk, see the change package). Unlock
    stays human-only
    : unlockUser/directory.users.unlock are not opened and a
    machine POST /api/users/unlock is 403 with no bind (D379-7). updateUser,
    updateGroup (PUT) and every password operation stay closed. OpenAPI gains the
    additive machineBearer/x-machine-scope on the four operations. The T-015 live
    drill (scripts/test/test-machine-write-v2b-live.py, real Keycloak and slapd) runs
    in the machine-write E2E workflow. The groundwork notes below describe the
    steps that led here. New optional MACHINE_WRITE_GROUP_SEED_DN (read only
    with MACHINE_WRITE_ENABLED; canonical DN inside the base, distinct from every
    protected identity, added to the protected set) is the placeholder first member a
    machine-created group will get instead of the W_data DN. The group pipeline rows
    (createGroup, patchGroup description only, deleteGroup) exist as catalog data
    and are exercised in tests only. ACL (operators): the W_data fragment
    (scripts/test/fixtures/machine-acl/write-data.ldif, rule {5}) now lists
    description; re-apply it where W_data is installed. Because the ACL cannot tell
    groups from users, W_data can then write description on user entries too (the
    machine patchUser body still excludes it). See
    change package.

  • Groundwork for machine user locking (#379, v2b), opened by the entry above: the
    lockUser pipeline row (W_lock, closed body {dn}, strong If-Match,
    inetOrgPerson) was catalog data first. A lock
    refuses a target that is a direct member of a MACHINE_WRITE_PRIVILEGED_GROUPS
    group (an M read of memberOf, before the W_lock bind), and a lock ceiling
    with MACHINE_WRITE_LOCK_ENABLED off fails startup. Unlocking stays human-only:
    unlockUser and directory.users.unlock remain unopened (D379-7), so a machine
    token still gets 403 scope_denied with no bind on POST /api/users/unlock. The
    cookie lock/unlock path is unchanged.

  • Compatibility change (UI_TRUSTED_PROXIES): with an explicit CIDR list, only
    the listed ranges are now trusted for X-Forwarded-For. Echo's implicit trust of
    loopback, link-local and private networks used to stay on next to the list, so a
    private peer outside the list could forge X-Forwarded-For and take a fresh
    login-limiter/machine-limiter budget per request. The extractor is used by the login
    limiter and the machine limiter only (the machine audit line carries no IP). If you set a CIDR list and relied on the implicit
    private-network trust, list every proxy CIDR explicitly (private and none
    are unchanged). Related to #214.

  • Machine bearer limits: the per-IP reservation now outlives the longest possible
    request (authentication deadline 10 s + MACHINE_REQUEST_TIMEOUT + 1 s) and the
    authentication phase has an explicit 10 s deadline that fails closed with 503
    (change package decision D31).

  • The login limiter's per-source state is now bounded (#270): hard cap
    UI_LOGIN_LIMITER_MAX_ENTRIES (default 10000), IPv6 clients grouped per /64, an
    amortized expiry sweep, and graded eviction (expired
    first, then the non-blocked source with the fewest stored failures — counts as of
    each source's last update, not time-decayed — oldest on ties; a blocked source is
    never evicted; new sources get the blocked-source 429 only
    when all slots are blocked, which costs cap x limit failed binds). Thresholds,
    window, Retry-After and response bodies are unchanged for normal traffic.

  • Machine bearer authentication, unit 1 of the staged rollout (#214, change package
    machine-principal-auth, default off): config parsing and startup validation of
    the MACHINE_* environment, a Keycloak service-account token verifier, the JWKS key
    source (refresh budget, single flight, bounded stale use, discovery retry), the pure
    selectAuth precedence rules, and the static operation-to-scope allowlist with a
    deny-by-default guard. Additive only: with MACHINE_AUTH_ENABLED unset no request,
    response or route changes. OpenAPI gains securitySchemes.machineBearer plus
    security/x-machine-scope on exactly the eight allowed GET operations, and three
    new stable error codes token_invalid, token_expired, scope_denied.
    Enabling it did not expose any data in unit 1: an authorized machine request
    ended in a fixed 503 until unit 2 below.

  • Machine bearer authentication, unit 2 (#214, default off): the machine
    execution identity and its boundaries. An authorized machine request now runs as
    the one dedicated LDAP account MACHINE_LDAP_BIND_DN, bound per request and closed
    when the request ends (also on panic, client abort and timeout); the global LDAP
    slot (MACHINE_MAX_CONCURRENCY) is taken, non-blocking, before the bind, one
    MACHINE_REQUEST_TIMEOUT deadline bounds dial, bind and every search, and a bind
    failure is a 503 with no fallback to another identity. Machine-only boundaries:
    getEntry/listTree refuse any DN outside LDAP_BASE_DN (so cn=accesslog,
    cn=config and cn=Monitor) with 403 scope_denied before any LDAP connection is
    opened, listTree refuses a listing of more than 1000 children with 422 size_limit_exceeded, and getMonitor reads the accesslog only with the audit.read
    scope. List cursors of a machine principal are bound to issuer plus client id (a
    refreshed token keeps paging; cross-client and human/machine replay is 400 cursor_invalid). Every request that carries an Authorization header while the
    feature is on is exactly one event=machine_access log line (see
    docs/audit-event-schema.md). The Go interface Client.MonitorStats gained an
    includeAccessLog parameter (human sessions pass true; unchanged behaviour).
    With MACHINE_AUTH_ENABLED unset nothing changes. Still later units: the
    operator ACL guide, the rate/concurrency limiters, Helm values and the Keycloak e2e.

  • Machine bearer authentication, unit 3 (#214, documentation and proof only, default
    off
    ): the operator guide docs/machine-ldap-account.md for the dedicated read-only
    LDAP account (strong password, the final olcAccess LDIF with the machine rules at
    {0}-{2} ahead of every existing allow, apply/verify/rotate/rollback commands, the
    derived secret-attribute list, per-node and replication caveats), and the live proof
    scripts/test/test-machine-acl-readonly-live.py run in api-credentials-e2e: three
    freshly initialised slapd containers (anonymous read base unset, set, operator leading
    allow), olcAccess read back in order, the account reads inside its subtree only, never
    sees secrets, cannot write or change its own password, other identities are unchanged,
    and the documented rollback restores the original olcAccess byte for byte; three
    deliberately broken variants (including each secret attribute dropped from the deny on its
    own, every one of them seeded with a recognizable value) must fail their expected checks. The committed LDIF's rule {0} now also covers
    pwdHistory, pKCS8PrivateKey, userPKCS12, oathSecret, oathEncKey and
    oathTokenPIN. No image, chart or backend change. Known and documented: ACLs are per
    node, the default policy locks the account after 5 bad binds, and the machine rules
    combine with LDAP_REPLICATION_IDENTITY=prepare only in the defined order (identity
    rule {0}, machine rules {1}-{3}; D30 / T-034, #277).

  • Machine bearer authentication, unit 4 (#214, default off): the limits and the
    Helm values. Order per request: Authorization grammar, then a per-source IP
    failure throttle before any signature or JWKS work
    (10 failures per sliding 60 s,
    inclusive boundary; in-flight requests hold reservations that are released exactly
    once on every exit path and expire after MACHINE_REQUEST_TIMEOUT; 429 machine_rate_limited with an exact Retry-After), a global authentication
    concurrency cap (503 + Retry-After: 1), verification, then a token bucket and
    concurrency cap per verified client (unverified claims never create or drain
    state), then the global LDAP slot. State is bounded: the IP table reuses the bounded
    login-limiter table (MACHINE_IP_LIMITER_MAX), per-client state is capped by the
    allowlist. New stable error code machine_rate_limited (429, retryable); the audit
    line uses reason=rate. Helm gains ui.machineAuth.* (off by default; the
    rendered chart is byte-identical when disabled). Limits are per replica.

  • Machine bearer authentication, unit 5b (#214, documentation only, default off;
    release notes for the feature as a whole). What it is: an opt-in way for a
    Keycloak service client (client_credentials access token) to call eight read-only
    GET operations (listUsers, listGroups, listTree, getEntry,
    listPasswordPolicies, getMonitor, and the opt-in listAuditActions,
    getServerSettings) as one dedicated read-only LDAP account. Writes, passwords,
    backups, application profiles, entry/move and getMe are never available to a
    machine. Compatibility: with MACHINE_AUTH_ENABLED unset (the default, Helm
    ui.machineAuth.enabled=false) nothing changes: no MACHINE_* variable is read, the
    chart renders byte-identical manifests, and Authorization is ignored. Existing
    paths, cookies, sessions and the existing OpenAPI operations are unchanged; the
    OpenAPI additions (securitySchemes.machineBearer, x-machine-scope) and the four
    new stable error codes (token_invalid, token_expired, scope_denied,
    machine_rate_limited) are additive. Turning it on needs: the Keycloak client
    settings in docs/machine-keycloak-client.md (audience mapper, lightweight access
    token off), the LDAP account and ACL on every LDAP node
    (docs/machine-ldap-account.md), and UI_TRUSTED_PROXIES set to CIDRs or none.
    Rollback: MACHINE_AUTH_ENABLED=false (or ui.machineAuth.enabled=false) and
    replace every replica; there is no persisted state. Emergency revocation: remove
    the client from MACHINE_ALLOWED_CLIENTS or disable the feature, replace all
    replicas and confirm no old pod remains, then disable the Keycloak client.
    Disabling the Keycloak client alone does not revoke tokens already issued; they
    stay valid until they expire (at most MACHINE_TOKEN_MAX_TTL plus the clock skew).
    Procedure: docs/machine-auth-operations.md. Known limits: requests the Go HTTP
    server rejects before any handler (431, malformed request line, header timeout,
    TLS/HTTP/2 pre-handler errors) are not audited and leave no log without an ingress
    access log (D25); limits are per replica; the machine ACL combines with
    LDAP_REPLICATION_IDENTITY=prepare in the defined order (D30, T-034, #277). Verification: the live end-to-end runs against a real Keycloak (both UI modes), the Keycloak client settings checks, the JWKS rotation and flood checks and the emergency revocation drill run in machine-keycloak-e2e.yml and gate releases (see the unit 5a note below). Not verified: installing the chart with machine auth on a cluster, a real ingress in front of X-Forwarded-For, and the ACL on more than one LDAP node (#284).

  • Self-service change password with a current password the directory does not
    accept is now 400 with the new stable code current_password_rejected and a
    fixed text (#264, D264-1..D264-3); it used to be 500 internal. The cause
    is ambiguous by design (slapd answers LDAP result 53 both when the current
    password does not verify and when current-password verification is not
    enabled), so the text does not say the password is wrong. It is 400 like the other
    input and policy rejections, not 401/403. Only the slapd diagnostic
    unwilling to verify old password on a request that carries oldPassword maps
    to it; result 53 for any other reason (for example operation restricted on a
    read-only database), result 53 without oldPassword and every other
    unclassified error stay a redacted 500 internal. If a future slapd rewords
    that diagnostic, this case degrades to 500, never to a wrong 400. The web UI
    shows its translated "ambiguous current password" message for this code.
    Rollback: revert the commit; clients that only read error keep working
    either way and older clients just see the text.

  • Optional Idempotency-Key on the core user/group writes, entry move and backup
    start (change package api-conditional-writes, part B, #216). A retry of the
    same request replays the first result (Idempotent-Replayed: true) and writes
    nothing again; a different request under the same key is 422
    idempotency_key_reused, one in flight is 409 idempotency_key_conflict, an
    unknown result is 409 idempotency_outcome_unknown. New codes
    idempotency_key_conflict, idempotency_key_reused,
    idempotency_outcome_unknown, idempotency_capacity and
    idempotency_unsupported. Off by default (UI_IDEMPOTENCY_ENABLED, chart
    ui.idempotency.enabled); records are in memory and lost on restart, so the
    chart enables it for a single replica only. Backup start keys are stored in the
    durable job record (fingerprint and key_id only) and survive restarts; they
    need UI_IDEMPOTENCY_KEY_FILE, which the chart sets when backups are enabled.
    GET /api/server-settings gains idempotencyEnabled.

  • Every /api error is now one JSON envelope
    {"error", "message", "code", "requestId", "retryable"} (change package
    docs/changes/api-error-envelope, #218). error keeps its text and
    message is an identical, deprecated alias, so existing clients keep
    working; new keys are code (a closed, append-only set — see
    docs/api.md), requestId (= X-Request-Id) and retryable. Bodies that
    used to be {"message"} only (sessions, validation, profiles, backups,
    Keycloak, login 429) and router 404/405 now carry all five keys; 5xx
    responses carry a fixed per-code text instead of a handler-chosen one (for
    example could not save application profile is now internal error; the
    cause is in the server log under the same requestId). 429 and retryable
    503 responses carry Retry-After. OpenAPI now has one Error schema and
    shared error responses.

  • 4xx messages no longer carry text derived from LDAP diagnostics, which can
    include DNs. They are replaced by the code's fixed text (for example
    invalid input) and the original is logged. Known password-policy texts
    from ppolicy/ppm still reach the change-password screen; ppm's
    Password for dn="…" prefix is dropped, so that screen now reads
    Password does not pass required number of strength checks (1 of 3).

  • Rollback: additive for clients that read error or message; revert the
    commits to restore the old bodies. No configuration, chart or image change.

  • User and group writes can now be conditional (opt-in, issue #216 part A).
    List items carry etag and GET /api/entry an ETag header (the entry's
    entryCSN); If-Match on PUT/PATCH/DELETE, lock/unlock, group member
    add/remove and entry move is enforced by slapd inside the write (RFC 4528
    assertion control), a stale tag is 412 revision_conflict with nothing
    written. Requests without the header behave as before. New
    PATCH /api/users and PATCH /api/groups (JSON Merge Patch) keep fields
    they do not mention; PUT is unchanged and still erases omitted optional
    fields. A user creation whose password step fails now removes the entry
    again when it provably is the request's own (the error says the user was
    not created) and otherwise answers 500 partial_failure (the one envelope
    that also carries state and dn) with the entry to check. The
    ETag also changes on directory bookkeeping writes (password-policy
    failure records), which can cause a harmless 412 and a re-read, and the
    condition is node-local on multi-provider replication. The ETag only
    reflects attributes written directly to the entry: memberOf and refint's
    removal of a deleted member from a group do not change it. Before setting
    the password the new entry is verified as created by this request and
    untouched; otherwise the password is not set and nothing is deleted
    (state: identity_changed), and a compensating delete whose outcome is
    not observed is state: unknown. Idempotency keys
    are not part of this change (see the Idempotency-Key entry above).

  • Breaking for some non-browser clients: every state-changing /api
    request (POST/PUT/PATCH/DELETE, including login and logout) that
    carries an Origin header is now refused with 403 origin_mismatch before
    any handler runs unless that Origin is the server's own origin (change
    package api-error-envelope, D218-16, #218). An empty, null or repeated
    Origin is refused too. Requests without an Origin header (curl, scripts,
    services) are unaffected, as are GET/HEAD/OPTIONS. Clients whose HTTP
    library adds an Origin header on its own must stop sending it. Behind a
    reverse proxy the server compares against its own scheme (from
    X-Forwarded-Proto and friends) and the request Host; a proxy that
    rewrites Host makes ordinary browser writes 403, one that keeps Host and
    forwards the scheme is fine. There is no switch to turn the gate off;
    rollback is reverting the change.

  • Opt-in /metrics listener (#218): set METRICS_ADDR (host:port, chart
    ui.metrics.enabled) to serve the ldapium_ui_* process metrics (request
    rate and latency, API errors per code, login failures, directory calls,
    active sessions) on a second port. Unset by default: no listener and no
    collection. The port must differ from the public one, has no authentication
    and answers only GET /metrics. On the public port GET /metrics is now a
    404 error envelope; the single-page-app fallback used to answer it with
    index.html. Adds the Go dependency github.com/prometheus/client_golang.
    Remove METRICS_ADDR to turn it off again.

  • Opt-in CORS for reads (#218): CORS_ALLOWED_ORIGINS (comma-separated exact
    scheme://host[:port], chart ui.cors.allowedOrigins) lets the listed
    origins read GET/HEAD responses with credentials and answers their
    preflight for GET, HEAD, OPTIONS only. Off by default, in which case no
    Access-Control-* header is sent; when on, Vary: Origin is on every
    response. It never opens a write path: the Origin gate above ignores the
    list, and the session cookie stays SameSite=Lax. The OpenAPI description
    and llms.txt no longer claim unconditionally that there are no CORS
    headers.

  • Compatibility of the error envelope's message: it stays a copy of error
    for the whole of /api/v1. Removing it needs a separate change package and
    at least one release after the UI stopped reading it (D218-4); new clients
    should read error.

  • Cursor pagination for GET /api/users and GET /api/groups (change package
    api-cursor-pagination, #215). Sending any of limit (1-200, default 50),
    cursor, q or sort selects cursor mode, which walks a directory past the
    5000-entry cap in a fixed order; the response gains hasMore and
    nextCursor (continue while hasMore is true, a page can be short or empty
    while it is). Without those parameters the response is byte-for-byte what it
    was. Cursors are opaque, bound to the login session, the resource and q,
    and a re-login invalidates them. New codes: 400 cursor_invalid, 422
    size_limit_exceeded and scan_limit_exceeded, 503 scan_timeout
    (retryable, with Retry-After). Each page costs a scan of the candidates
    (100000 per request, 30 s deadline); a non-root identity needs read access
    to entryUUID on every listed entry. Items carry the same etag as in the
    legacy list. See docs/api.md. Rollback: additive, revert the commits; no
    stored state. The web UI keeps its client-side paging.

  • Backup jobs (change package backup-job-ids, #217). POST /api/v1/backups/jobs/{kind} now answers 202 with job_id and a Location
    header (the body still has kind and status). New endpoints:
    GET /api/v1/backups/jobs, GET /api/v1/backups/jobs/{id} and
    POST /api/v1/backups/jobs/{id}/cancel. Compatibility changes:
    states[kind].status can now be cancelled, so clients that treat it as a
    closed set need updating (job records add abandoned, for a run whose
    result could not be established after a restart); the 409 backup_busy
    body gains active_job_id and active_kind; a failure to record a start or
    cancel request is now 503 persistence_unavailable (nothing started, no
    signal sent) where it used to be a 422 that also covered other failures,
    which stay 422 validation_failed; the backup_started log line carries
    actor_fp (a one-way fingerprint) instead of actor (the DN). New
    BACKUP_JOB_TIMEOUT_DATA/BACKUP_JOB_TIMEOUT_LOGS (1m-24h, default 2h, chart
    ui.backups.jobTimeout) bound a run; cancel and deadline send SIGTERM to the
    worker's process group and SIGKILL after 10 s. Job records are kept in
    backup-jobs.json next to the policy file (200 records or 90 days) and the
    worker leaves <root>/.results/<job_id>.json (0600) per run. The worker
    ships in the UI image, so the image must be rebuilt and deployed for these to
    work. Rollback: an older version never reads backup-jobs.json or
    .results/, so both can stay or be deleted; the older version simply shows
    no job history.

Images

  • Security: a user can no longer choose its own password policy (#399, F-9). The
    catch-all to * by self write covered pwdPolicySubentry, so any bound user could
    point its own entry at any pwdPolicy subentry (e.g. one without lockout, quality
    check or history). A new rule before the catch-all gives the entry itself only
    read on pwdPolicySubentry and passes every other identity through unchanged
    (by * break); the admin still assigns policies. The catch-all moves from {2} to
    {3} ({4} to {5} with LDAP_ANONYMOUS_READ_BASE). Upgrade: the ACL is
    written at bootstrap only, so existing volumes keep the old behaviour until the rule
    is added by hand (one ldapmodify, see image/README.md "Access control"). Other
    ppolicy attributes a user can still write on itself, measured and left as they are:
    pwdReset, pwdStartTime, pwdEndTime (replace and delete) and
    pwdAccountLockedTime (add, i.e. locking itself out).
  • Security / behaviour change: a user can no longer write its own ppolicy state
    (#400, D400-1). The #399 rule's attrs= list now also covers pwdReset,
    pwdStartTime, pwdEndTime and pwdAccountLockedTime, so the entry itself gets
    read only: it can no longer clear its own forced reset, extend its own validity
    window or lock itself. No new rule, so the rule count and every {n} position stay
    as they were. The ppolicy overlay's own lock/unlock/reset writes and the admin are
    unaffected (live-verified, including lock/unlock replicating to a second node).
    Users who relied on self-writing these attributes now get Insufficient access
    (50); there is no escape hatch, an operator who wants self-managed validity windows
    must use an admin identity.
    pwdChangedTime, pwdFailureTime, pwdGraceUseTime,
    pwdLastSuccess and pwdHistory were already refused by the schema
    (NO-USER-MODIFICATION, 19). Upgrade: bootstrap only, as for #399; existing
    volumes keep the old behaviour until the rule is replaced by hand (image/README.md
    "Access control").
  • A kept config volume with a lost data volume now refuses to start in every mode
    with a message naming the missing database directory and the recovery (delete BOTH
    the data and the config claims; the node refills from its peers). dedicated no
    longer reports it as an unreadable stored olcSyncrepl (#387). Behaviour is
    otherwise unchanged: exit non-zero, nothing created.
  • LDAP_REPLICATION_IDENTITY (admin default / prepare / dedicated), unit 1
    of the staged replication-identity change (#229, docs/changes/replication-identity,
    T-010). Only admin is effective and it changes nothing (cn=config and
    olcSyncrepl byte-identical). Any other value is validated and then (until
    unit 2 below made prepare effective) the
    container refuses to start ("not implemented in this image yet"): an invalid
    value, a non-admin mode without LDAP_REPLICATION_ENABLED, LDAP_ADMIN_DN
    equal to cn=replicator,<root>, prepare with an explicit replication
    password, and dedicated with a missing or weak replication password (non-printable-ASCII
    or non-ASCII characters, length below 32, fewer than 10 distinct characters, or equal to the admin password;
    a hygiene check, not proof of randomness) are refused with fixed messages.
    Do not set it to anything but admin until later units ship.
  • LDAP_REPLICATION_IDENTITY=prepare, unit 2 of the same change (#229, T-011):
    installs the replication identity's read-only ACL as the FIRST olcAccess rule
    ({0}to * by dn.exact="cn=replicator,<root>" ssf=128 read by dn.exact=... none by * break; slapd renumbers the other rules, so everyone else's rights are
    unchanged) and an olcLimits rule (size=unlimited time=unlimited) with one
    offline slapmodify -n 0, verified by reading cn=config back; a second start
    is a no-op. It never creates the identity entry and replication still binds as
    the admin DN. It starts only when the node holds no entry at the reserved DN or
    the rule is already stored, and it refuses (fixed messages, volume untouched):
    an existing entry without the rule, stored olcAuthzRegexp/olcAuthIDRewrite,
    olcAuthzPolicy other than none, olcTLSVerifyClient other than never
    (so prepare and LDAP_TLS_MUTUAL_AUTH do not combine), any entry with
    authzTo/authzFrom, a stored olcRootDN (any database) or replication bind
    DN equal to the reserved DN, a root DN with quotes/backslashes/non-ASCII, and a
    serverID-1 start on a fresh volume. DNs are compared as slapd normalizes them
    (slapdn -N: case, spaces, hex escapes, quoting, multivalued RDN order), and a
    DN that cannot be parsed is refused. The identity's olcLimits rule is always
    placed FIRST (limits are first-match) and any other stored rule for the identity
    is replaced, so an earlier size=1 rule cannot cap it.
    admin stays byte-identical; dedicated was still refused at this unit
    (unit 3 below). Rollback: an older image or admin leaves the rule in place,
    which is harmless while no entry exists at the reserved DN.
  • LDAP_REPLICATION_IDENTITY=dedicated, unit 3 of the same change (#229, T-012):
    syncrepl binds as cn=replicator,<root> (password from
    LDAP_REPLICATION_PASSWORD(_FILE)) with tls_reqcert=demand and
    tls_cacert=$LDAP_TLS_CA_FILE, and EVERY node is consumer-only, serverID 1
    included: no base DIT is created, no peer is probed or waited for, so a wiped
    node, a wrong password or a missing peer leaves the node empty (consumer
    rc 49 / retry) instead of minting a second tree. It installs the unit-2 ACL
    and shares its refusals. New fixed-message refusals before anything is written:
    LDAP_TLS_ENABLED off, no readable LDAP_TLS_CA_FILE, a peer list that is not exactly
    ldaps://<host>[:<port>] entries (strict grammar: no whitespace, userinfo, path or
    option text, so a smuggled provider=ldap://... cannot send the identity's bind in
    clear text; re-checked before olcSyncrepl is rendered and read back after), a
    password with a quote or backslash, a retry list or interval outside a strict
    grammar slapd can always load (retry=+ used to store a config that no offline
    tool could read; a dedicated start now removes such an unreadable stored
    olcSyncrepl and re-renders it from the corrected environment, only after every
    stored-config refusal has passed (checks run on a throwaway copy) and with a
    crash-safe atomic replace of an already verified file (no clear-text rollback copy is
    ever made); the kept backup is structure only (values withheld, also base64 and
    folded ones; leftovers of a crash are deleted by the next start). Rewriting the config
    cannot erase the old file's disk blocks: rotate the admin password after the switch if
    the old olcSyncrepl held it and the volume may be copied, and encrypt it at rest; the read-back
    check is quote-aware, so a password containing provider= starts normally), a _FILE secret that is
    read once (never twice, never substituted by the admin password),
    LDAP_TLS_MUTUAL_AUTH, a custom LDAP_REPLICATION_BIND_DN; stored-config
    refusals (olcAuthzRegexp, olcAuthIDRewrite, olcAuthzPolicy,
    olcTLSVerifyClient, authzTo/authzFrom, rootDN = reserved DN, existing
    entry without the ACL) leave the volume untouched. admin and prepare stay
    byte-identical. Not yet a supported mode: the identity entry is created by an
    administrator by hand (the ensure/rotate/retire commands, restore.sh
    total-loss, Kubernetes recovery, credential rotation, the checks and the chart
    are later units). A new cluster cannot start in dedicated; migrate
    admin -> prepare -> entry -> dedicated. Rollback: restart the node in
    admin (re-renders olcSyncrepl with the admin DN); prepare refuses to start
    on a volume that still stores the identity as its syncrepl bind DN.
  • LDAP_PAGED_TOTAL_LIMIT (#215), opt-in: lifts the total of a paged search
    for authenticated non-root identities, which LDAP_SIZE_LIMIT otherwise caps
    at 10000 however small the pages are (the admin DN is already exempt). Unset
    leaves cn=config alone; a number up to 2147483647 or unlimited converges
    to one olcLimits: users size.prtotal=<value> rule; off removes it. It
    weakens LDAP_SIZE_LIMIT as a bulk-dump backstop, so leave it unset unless an
    API client must enumerate more than 10000 entries without the admin DN.
    Rollback: set off and restart once before moving to an image that predates
    the setting; the rule lives in cn=config and an older image would not
    remove it.
  • Admin-mode startup now logs a WARNING when LDAP_REPLICATION_PASSWORD(_FILE)
    differs from the admin password while replication binds as the admin DN: peers
    reject that bind (rc 49) and replication stalls silently (#355, proposed in
    docs/changes/replication-identity/CHANGE.md). A log line only; the start is
    never refused, and cn=config/olcSyncrepl stay byte-identical (a custom
    LDAP_REPLICATION_BIND_DN with a password does not warn).
  • Documentation (#354): the replication identity transition (prepare, check,
    ensure, check, dedicated, rollback, total-loss recovery) is in
    docs/migration.md; the limits of the contextCSN alerts and the data check are
    in docs/ha-profile.md. rotate, rollback-admin and the periodic check
    remain unimplemented.

Helm chart

  • New values, all off or empty by default: ui.metrics.enabled with
    ui.metrics.port (9331), ui.metrics.networkPolicy.from (required once
    metrics are on; an empty list fails the render) and
    ui.metrics.serviceMonitor.enabled/ui.metrics.podMonitor.enabled (#218);
    ui.networkPolicy.httpFrom; ui.cors.allowedOrigins (#218);
    ldap.limits.pagedTotal (#215); ui.backups.jobTimeout.data/.logs (#217).
    Enabling ui.metrics.enabled adds a NetworkPolicy that selects the UI pods,
    which makes them default-deny for ingress: the HTTP port stays open to every
    source unless ui.networkPolicy.httpFrom narrows it. The metrics port is
    exposed only by a separate <release>-ldapium-ui-metrics Service, never by
    the public UI Service.

CI

  • Heavy E2E jobs (anything that builds the OpenLDAP server image and/or
    creates a kind cluster) now share one of three job-level concurrency
    lanes (heavy-e2e-lane-a/b/c, keyed per PR/commit) across
    e2e.yml, backup-restore.yml, keycloak-federation-e2e.yml,
    metrics-e2e.yml's metrics job, replication-chaos-e2e.yml,
    security-e2e.yml, sssd-e2e.yml, ui-e2e.yml, upgrade-e2e.yml, and
    offline-bundle.yml's offline-e2e job. A single push previously
    started up to ~16 of these image-build/cluster-create jobs at once; each
    lane now caps that at three concurrent heavy jobs, queued (not
    cancelled) via queue: max. Required status check names are unchanged.
  • Machine bearer authentication, unit 5a (#214; tests, CI and drills only, no product
    change): the new machine-keycloak-e2e.yml workflow (no paths filter; Keycloak
    quay.io/keycloak/keycloak:26.7.4) runs four live scripts against a real Keycloak, a real
    slapd and the real UI backend: both UI auth modes with the valid, invalid-token and
    never-allowed-operation matrices (slapd bind counts, JWKS outage, rate limits, secret
    scan of every container log), the Keycloak client settings an operator must apply
    (lightweight tokens, scopes, token exchange incl. the legacy feature, human tokens),
    JWKS key rotation / floods / hostile issuer responses / discovery recovery behind a
    counting proxy, and the emergency revocation and rollback drill across replicas.
    release.yml now also requires the job machine bearer auth (real Keycloak) to have
    succeeded on the tagged commit. From now on tagging a main commit that predates this
    workflow (no run of that job on the SHA) fails the release gate: re-run the workflow on
    that SHA via workflow_dispatch or tag a newer commit; skipped, cancelled or other-SHA
    runs never satisfy it (see RELEASING.md).

What's Changed

  • ci: bump OpenForge status publisher to revision-bound evidence by @dasomel in #198
  • ci: throttle heavy E2E jobs into three concurrency lanes by @dasomel in #199
  • docs(agent): drop stale issue-range claim and dedupe directory-change skill by @dasomel in #200
  • fix(image): keep probe socket closed until final slapd serves all listeners by @dasomel in #202
  • fix(image): write bootstrap marker only after seeds apply by @dasomel in #204
  • test(e2e): guard bootstrap seed semantics from #203 in CI by @dasomel in #205
  • docs(agents): prune restated global rules and fix stale references by @dasomel in #211
  • docs(agents): restructure agent instructions around AGENTS.md by @dasomel in #212
  • docs: repoint stale CLAUDE.md references to AGENTS.md by @dasomel in #213
  • test(skill): fresh-session verification evidence for ldapium-directory-change (openforge#54) by @dasomel in #219
  • chore(research): add shared evidence recorder and CI validator by @dasomel in #207
  • feat: OpenLDAP 2.6.15 hardening, Keycloak management, external API docs and generated credentials by @dasomel in #226
  • fix: follow-ups from the #226 reviews (password off argv, restore.sh, UI session secret, CI wiring, tests) by @dasomel in #228
  • build(deps): bump golang from cf6fca6 to 8a5910f in /ui by @dependabot[bot] in #208
  • build(deps): bump the actions group with 3 updates by @dependabot[bot] in #210
  • build(deps): bump the frontend-dependencies group in /ui/frontend with 4 updates by @dependabot[bot] in #209
  • feat(api): HEAD mirrors GET, OPTIONS answers Allow; guard licence inventory; close out #206 doc by @dasomel in #230
  • fix(image): unset the admin/replication password before exec slapd (#229 part 1) by @dasomel in #231
  • ci: run the @fixture UI specs against real fixtures (#227) by @dasomel in #232
  • docs(changes): Change Packages for #215, #216, #217, #218 (reviewed, accepted) by @dasomel in #233
  • feat(api): one error envelope for every /api error (#218, slice A) by @dasomel in #234
  • feat(api): ETag and If-Match with an assertion control, PATCH, safe user create (#216, part A) by @dasomel in #235
  • docs(changes): Class D package for a dedicated replication identity (#229) by @dasomel in #238
  • feat(backup): job ids, durable job records, orphan-aware lifecycle, cancel/deadline (#217) by @dasomel in #236
  • build(deps): bump source-map-js from 1.2.1 to 1.2.2 in /ui/frontend by @dependabot[bot] in #239
  • feat(api): same-origin gate for state-changing requests (#218, slice B) by @dasomel in #240
  • feat(image): opt-in LDAP_PAGED_TOTAL_LIMIT for paged listing past the size limit (#215) by @dasomel in #237
  • feat(api): opt-in /metrics listener (#218, slice C) by @dasomel in #242
  • ci(security-e2e): grep the audit export without a pipe (SIGPIPE flake) by @dasomel in #245
  • fix(image): reject NUL in stored olcLimits values; fix off-mode comment (#215 follow-up) by @dasomel in #243
  • feat(api): opt-in Idempotency-Key for writes and backup start (#216, part B) by @dasomel in #241
  • feat(api): opt-in read-only CORS; write gate stays same-origin (#218, slice D) by @dasomel in #246
  • feat(api): keyset cursor pagination for users and groups (#215) by @dasomel in #244
  • docs(changes): close-out audit of the #215-#218 API change packages by @dasomel in #247
  • fix(api): cursor follow-ups - panic-safe lock, v2 cursor, retryable scan_timeout, live vet guard (#215) by @dasomel in #256
  • docs: release notes, ADRs, operator guide and package sync for #215-#218 by @dasomel in #257
  • feat(ui): send If-Match and Idempotency-Key from Users and Groups (#216, T-018) by @dasomel in #258
  • test(backup): cancel from the afterReap seam in the reap test (CI flake) by @dasomel in #259
  • test(backup): live runs for S3/FTP/SFTP, SIGKILL-after-grace and deadline (#255) by @dasomel in #263
  • test(ui): groups error-envelope e2e, live change-password check, EVIDENCE (#249) by @dasomel in #260
  • test(api): live verification for conditional writes and idempotency (#251) by @dasomel in #262
  • feat(ui): cursor pagination in the Users and Groups lists (#253) by @dasomel in #261
  • fix(api,ui): map LDAP result 53 on the old-password path to 400 current_password_rejected (#264) by @dasomel in #265
  • feat(image): parse LDAP_REPLICATION_IDENTITY and check replication-password hygiene (#229, T-010) by @dasomel in #267
  • docs(changes): revise and accept the machine-principal-auth package (#214) by @dasomel in #269
  • feat(image): LDAP_REPLICATION_IDENTITY=prepare installs the read-only identity ACL and olcLimits (#229, T-011) by @dasomel in #271
  • feat(api): machine bearer auth core - config, verifier, selectAuth, scope allowlist, default off (#214) by @dasomel in #272
  • fix(api): bound the login limiter state and group IPv6 sources (#270) by @dasomel in #273
  • feat(api): machine execution identity, base/accesslog boundary, cursor binding and audit line (#214, unit 2) by @dasomel in #274
  • feat(docs): machine LDAP account guide and read-only ACL proof (#214, unit 3) by @dasomel in #276
  • feat(api): machine limits and ui.machineAuth Helm values (#214, unit 4) by @dasomel in #278
  • docs(api): machine bearer auth operator docs, ADR, release notes (#214, unit 5b) by @dasomel in #279
  • fix(api): trust only the configured proxies for X-Forwarded-For and keep IP reservations alive for the whole authentication by @dasomel in #282
  • test(ci): live Keycloak e2e, JWKS, settings and revocation drill for machine bearer auth (#214, unit 5a) by @dasomel in #281
  • docs(changes): close-out audit of the machine-principal-auth package (#214) by @dasomel in #289
  • fix(api): report the request deadline error consistently from the machine dial path by @dasomel in #283
  • feat(image): LDAP_REPLICATION_IDENTITY=dedicated, syncrepl as the identity, consumer-only nodes (#229, T-012) by @dasomel in #275
  • fix(api): derive graceful shutdown wait from MACHINE_REQUEST_TIMEOUT (#280) by @dasomel in #291
  • fix(api): classify handler-side directory timeouts at the request deadline as deadline (#288) by @dasomel in #292
  • test(image): tls_reqcert=demand negative probe and signal-safe repair temp cleanup (#290) by @dasomel in #293
  • test(api): in-flight requests during graceful shutdown (#280) by @dasomel in #294
  • feat(ui): machine-callable badge and default-off note on the API docs page (#287) by @dasomel in #295
  • test(api): live concurrent same-tag, same-key and PATCH-merge checks (#268, part) by @dasomel in #296
  • feat(image): replication identity ensure/retire operator command (#229, T-013 part) by @dasomel in #299
  • test(chart): kind cluster test for ui.machineAuth (#284, part) by @dasomel in #302
  • fix(api): throttle repeated wrong current passwords on POST /api/users/password (#266) by @dasomel in #300
  • feat(image): define and test machine ACL + replication-identity coexistence (#277) by @dasomel in #303
  • fix(api): restore the HEAD method before the handler writes so net/http omits the body (#301) by @dasomel in #304
  • docs(design): machine principal write scope change package (#285) by @dasomel in #297
  • docs(design): immediate machine token revocation change package (#286) by @dasomel in #298
  • fix(deps): bump debian:trixie-slim base digest and golang.org/x/crypto to 0.56.0 by @dasomel in #305
  • ci: pin remaining actions and research pip deps by hash; scope cleanup-merged-branch write permission by @dasomel in #309
  • ci(chart): kind job for ui.machineAuth (#284) by @dasomel in #306
  • fix(api): register password_change_rate_limited in the error envelope; live throttle proof (#266) by @dasomel in #307
  • test(ui): live keyed-retry Playwright scenarios for create and edit (#268, part) by @dasomel in #308
  • docs(design): accept machine write scope with security-review revisions (#285) by @dasomel in #311
  • docs(design): accept machine token revocation with evidence and security-review revisions (#286) by @dasomel in #312
  • test(api): live delete/recreate stress, assertion-control absence and 2-node replication (#268, part) by @dasomel in #310
  • docs(machine-principal-auth): record the kind cluster proof and close out #284 by @dasomel in #316
  • feat(api): machine write scope contract scaffolding, no behaviour change (#285, T-010) by @dasomel in #318
  • feat(api): pure machine token revocation snapshot and sentinel validation (#286, T-012) by @dasomel in #320
  • docs(api-conditional-writes): close out T-021 with the live evidence of #296, #308 and #310 (#268) by @dasomel in #321
  • feat(api): parse and validate the MACHINE_REVOCATION_* settings, no consumer yet (#286, T-010) by @dasomel in #322
  • fix(image): build openldap_exporter against golang.org/x/crypto v0.56.0 by @dasomel in #317
  • build(deps): bump anchore/sbom-action from 0.24.2 to 0.24.3 in the actions group by @dependabot[bot] in #315
  • build(deps): bump the go-dependencies group in /ui/backend with 2 updates by @dependabot[bot] in #313
  • docs: record machine auth and replication verification baselines by @dasomel in #324
  • feat(api): carry iat and jti on the machine Principal, optional RequireJTI (#286, T-011) by @dasomel in #319
  • build(deps): bump the frontend-dependencies group in /ui/frontend with 4 updates by @dependabot[bot] in #314
  • test(replication): reject failed writable-layer secret scans by @dasomel in #326
  • test(auth): record closed write attribute inventory (T-002) by @dasomel in #328
  • feat(auth): add LDAP revocation source and refresh loop (T-014) by @dasomel in #325
  • feat(auth): enforce strict machine write If-Match prerequisite (T-017) by @dasomel in #329
  • feat(config): validate data and lock write identities (T-011) by @dasomel in #327
  • feat(chart): gate dedicated replication identity credentials by @dasomel in #332
  • feat(replication): reconcile restored identity credentials over TLS by @dasomel in #330
  • feat(scripts): maintain machine revocation entries and sentinel (#286 T-013) by @dasomel in #323
  • fix(deps): update Go HTTP security patch levels by @dasomel in #343
  • fix(test): avoid opaque cursor false positives in secret scan by @dasomel in #344
  • feat(auth): enforce token revocation and protect revocation subtree by @dasomel in #336
  • feat(auth): add strict machine write DN policies (T-012/018/019 scaffold) by @dasomel in #331
  • docs(replication): record accepted identity design and partial implementation by @dasomel in #341
  • feat(auth): add separate bounded machine write budget (T-016) by @dasomel in #346
  • test(replication): verify dedicated restore and credential reconciliation by @dasomel in #340
  • feat(helm): configure opt-in machine token revocation by @dasomel in #335
  • feat(replication): compare identity visibility with stable entry CSNs by @dasomel in #334
  • test(machineauth): verify LDAP revocation retry, bounds and partition by @dasomel in #342
  • feat(auth): isolate machine idempotency ownership and require keys (T-014) by @dasomel in #333
  • feat(replication): inspect identity configuration and consumer credentials by @dasomel in #338
  • test(auth): drill live LDAP revocation on unchanged replicas by @dasomel in #345
  • feat(replication): compare selected peers with strict convergence gates by @dasomel in #347
  • test(auth): repeat machine contracts with empty revocation snapshots by @dasomel in #350
  • feat(ldap): add atomic machine write target-type constraints (T-018) by @dasomel in #337
  • feat(ldap): verify machine write target identities through M (T-018) by @dasomel in #339
  • test(auth): verify revocation through HTTP during LDAP peer partition by @dasomel in #349
  • docs(auth): connect revocation design and rollback notes by @dasomel in #348
  • test(machineauth): pin the retention constants table of the revocation design (#286) by @dasomel in #351
  • docs(auth): heartbeat/rollback operations guide, testable 80% warning and task bookkeeping for the revocation package (#286) by @dasomel in #356
  • test(auth): pin the machine revocation wiring through the real constructor path; tool limits (#286) by @dasomel in #357
  • feat(api): inert machine write configuration and runtime constructor, no operation opened (#285) by @dasomel in #358
  • test(auth): live cutoff-boundary and heartbeat-stop/aging drills plus mutation evidence for machine token revocation (#286, T-017) by @dasomel in #359
  • docs(auth): close out the machine token revocation package (#286) by @dasomel in #364
  • feat(api): machine write audit fields, no operation opened (#285, T-015) by @dasomel in #365
  • feat(chart): ui.machineAuth.write gate values, schema and render tests, no operation opened (#285, T-024) by @dasomel in #366
  • feat(acl): machine write ACL fragments (W_data, W_lock), operator guide and live smoke, no operation opened (#285, T-020) by @dasomel in #367
  • feat(api): machine write request pipeline with enforced security order, no operation opened (#285) by @dasomel in #368
  • test(acl): full live proof of the machine write ACL with mutation switches (#285, T-021) by @dasomel in #370
  • feat(acl): operator check for the machine write ACL order and rule sets (#285, T-023) by @dasomel in #369
  • test(auth): drill-only build for the machine write emergency-block drill, guard and artifact check (#285, T-022) by @dasomel in #371
  • fix(api): revocation refresher backoff, audit.read accesslog reach, rollback guard decision (#363) by @dasomel in #374
  • feat(api): open the five machine write operations behind MACHINE_WRITE_ENABLED, default off (#285, T-013) by @dasomel in #372
  • feat(replication): rotate and rollback-admin subcommands (#352, T-013) by @dasomel in #377
  • docs(auth): decisions for #360, #361, #362 machine revocation follow-ups (D13-D15) by @dasomel in #373
  • docs(replication): identity migration procedure, limits, TASKS status; warn on stalling admin-mode replication password (#354, #355) by @dasomel in #376
  • test(replication): unified replication identity verification, chaos job and Kubernetes recovery (#353) by @dasomel in #380
  • docs(api): machine write v1 close-out - T-004 live measurements and status sync (#378) by @dasomel in #382
  • docs(auth): change package for machine write v2b/v2c (group writes, locks, password compartment) (#379) by @dasomel in #383
  • ci(auth): machine write release gate against real Keycloak + slapd (#378, T-025) by @dasomel in #381
  • feat(replication): check CronJob, PrometheusRule and check-script gaps (#375, T-017, T-014) by @dasomel in #384
  • fix(image): name the lost data volume when the config volume is kept (#387) by @dasomel in #388
  • fix(api): a machine write on a missing target returns 404, not 503 (#385) by @dasomel in #389
  • test(api): objectClass modify-ADD and password-on-Add ACL evidence, regression tests, proposal (#386, #390) by @dasomel in #391
  • feat(api): v2b group seed, group pipeline (unreachable) and description ACL (#379, T-010–T-012) by @dasomel in #392
  • feat(api): machine lockUser pipeline with fail-closed privileged-member refusal, unreachable (#379, T-013) by @dasomel in #393
  • feat(api): open machine createGroup/patchGroup/deleteGroup/lockUser with the v2b live drill (#379, T-014, T-015) by @dasomel in #395
  • docs(machine-write): record accepted decisions for #386 (A) and #390 (a), #394 items 2/5/6 by @dasomel in #396
  • feat(api): harden v2b machine ACLs - add-only W_lock, group-only description, seed placement (#394 items 1,3,4) by @dasomel in #397
  • docs(machine-write): v2c design gate - W_cred ship-gating experiments, pending-account policy, security reviews (#379) by @dasomel in #402
  • fix(image): a user can no longer choose its own password policy (pwdPolicySubentry self write) (#399) by @dasomel in #401
  • feat(api): W_cred configuration and startup validation, nothing opened (#379, T-020) by @dasomel in #403
  • feat(api): credentialPending marker on machine createUser, nothing opened (#379, T-029) by @dasomel in #405
  • feat(acl): W_cred and W_data v2c fragments with live proof, nothing opened (#379, T-021) by @dasomel in #404
  • feat(api): machine setPassword pipeline with W_cred exop and pending cleanup, unreachable (#379, T-022) by @dasomel in #406
  • feat(api): machine setPassword budget, per-target serialization and cred audit, unreachable (#379, T-023) by @dasomel in #407
  • feat(api): v2c run-time premise checks and sweeps, unreachable (#379, T-028) by @dasomel in #408
  • test(api): v2c password step live proof and secret scan, unreachable (#379, T-024) by @dasomel in #409
  • test(api): v2c per-compartment emergency-block drill, setPassword still closed (#379, T-025) by @dasomel in #410
  • feat(api): open machine setPassword with the W_cred no-lockout policy (#379, T-026) by @dasomel in #411
  • ci(api): join the v2b/v2c proofs to the machine write release gate (#379, T-027) by @dasomel in #412
  • docs(machine-write): v2c status, ADR promotion and task sync (#379, T-050) by @dasomel in #413
  • docs(machine-write): v2b/v2c close-out - T-040 verification, T-050 sync, T-060 review (#379) by @dasomel in #416
  • fix(image): refuse LDAP_ACCESSLOG_OPS containing extended/all (#398, F-7) by @dasomel in #414
  • fix(image): ppolicy state attributes are read-only for self (#400) by @dasomel in #415
  • chore(release): 0.2.0 by @dasomel in #418
  • refactor: simplify the #398 accesslog guard, hoist the #400 attrs list, dedupe security-e2e loops by @dasomel in #419
  • refactor: trim the #398 refusal message and two restating comments by @dasomel in #425

Full Changelog: v0.1.1...v0.2.0