Repository navigation
v0.2.0
Breaking / behaviour changes
- Machine
deleteUser/ modrdn narrowed (Q16). With the v2c LDAP rules installed,
machinedeleteUserand modrdn only reach entries created throughW_data; other
entries are refused. - ppolicy state attributes are read-only for self (#400).
pwdReset,
pwdStartTime,pwdEndTimeandpwdAccountLockedTimecan no longer be written by
the user on its own entry. The ACL is bootstrap-only: existing volumes keep their
storedolcAccessuntil upgraded in place (seeimage/README.md, in-place upgrade). - The image refuses to start with
extendedorallinLDAP_ACCESSLOG_OPS(#398).
Usewrites reads bind.
Web UI
- Users and Groups lists page through the cursor API (
limit/cursor/q, #253).
Search is now server-side and matches onlyuid/cn/mail/displayName(users)
andcn/description(groups); the old client-side search overdepartment,
organizationandorganizationalUnitis gone (accepted inD215-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_OPScontainsextendedorall(#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 humansetPasswordpath. Usewrites reads bind;
there is no override until upstream fixes it.
API
-
Machine write v2c opened (#379, T-026): machine
setPassword. With
MACHINE_WRITE_ENABLEDandMACHINE_WRITE_CRED_ENABLEDon and
directory.users.password.writein a client's ceiling, a machine client sets the
initial password of an account it created itself withcreateUser
credentialPending: true(body exactly{dn, password},Idempotency-Key
required, noIf-Match, response{}; any other target 403, an account whose
password was already set 409). It runs as the separateW_credidentity behind
the T-028 premise checks. OpenAPI marks itmachineBearer/
x-machine-scope: directory.users.password.write. A ceiling may now name the
password scope; withMACHINE_WRITE_CRED_ENABLED=falseit still starts and the
request is 403scope_deniedwithout anyW_credbind (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 machinedeleteUser(andW_data's direct
LDAP modrdn; the machine API has no rename operation) to entriesW_datacreated; deleting an administrator-made user (any class) or a
container is refused by the directory (LDAP 50) and answered 403forbidden.
Groups (v2bdeleteGroup) and deployments without v2c are unchanged. -
W_credgets a no-lockout password policy (CHANGE.md Q17, decided 2026-10-14):
guidedocs/machine-write-account.md8.1 attacheswcred-policy.ldif
(pwdLockout FALSE, nopwdMaxFailure), so wrong binds can no longer lock
W_credand stopsetPassword; 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 sW_cred's policy must exist,
differ from the default and pending policies and statepwdLockout: FALSEwith
nopwdMaxFailure/pwdMaxAge/pwdMustChange TRUE, else start-up is refused /
setPasswordanswers 503. -
Accepted risk (2026-10-14): with v2c on, a leaked write credential can rename an
administrator-made user'smail/uidand give the old value to a new pending
account; applications that resolve the login name frommail/uidare at risk
(bind byentryUUIDor alert on changes, or keep v2c off). -
Chart:
ui.machineAuth.write.cred.*renders theW_credidentity, the
configuration reader and the canaries (MACHINE_WRITE_CRED_*; passwords are
Secret references only, every identity and Secret key distinct). It still
refuses next toaudit.enabled=trueor an accesslog ops list holding
extended/all. The chart still grants no write scope inallowedClients. -
Machine write v2c (#379, T-020) configuration and start-up validation only; no
operation is opened (directory.users.password.writeis still refused in a
ceiling and the password routes stay 403scope_deniedwith no bind). New
MACHINE_WRITE_CRED_ENABLED(defaultfalse; 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-emptyMACHINE_WRITE_PRIVILEGED_GROUPS,
MACHINE_WRITE_CRED_SUBTREESinside the same client'sMACHINE_WRITE_SUBTREES,
andMACHINE_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 pendingW_data-made accounts only, cleanup of the marker
and pending policy values) and theW_datav2c rules (write-data-cred.ldif). Installing them
requiresolcAddContentAcl TRUEon the main database and narrows machinedeleteUser/modrdn to
entriesW_datacreated, groups excepted (Q16). Guidedocs/machine-write-account.mdsection 8; the read-only
checker gains--cred-dn/--pending-policy-dn/--privileged-group/--wcred-unpinned; non-pinned
providers getwrite-cred-deny.ldif. Image defaults unchanged. -
Machine write v2c (#379, T-023) password limits and audit, still not opened:
newMACHINE_WRITE_CRED_RATE_LIMIT_RPS(global password budget, 1–10, default 1,
read only withMACHINE_WRITE_CRED_ENABLED=true) and a per-target serialization
(entryUUID, shared across clients); either refusal is429 machine_rate_limited
withRetry-After. Audit lines gaincompartment=cred(no password field). -
Machine write v2c (#379, T-025) per-compartment emergency-block drill for the password
compartment, tests and docs only,setPasswordstill closed (T-026 opens it). New
scripts/test/test-machine-write-cred-drill.pyand alive-tagged Go test measure the
MACHINE_WRITE_CRED_ENABLED=falseredeploy, theW_credrotation (old credential refused,
W_data/W_lock/read unaffected on running replicas, restarts fail until the new Secret is
rolled out), theMACHINE_REQUEST_TIMEOUTbound of an in-flight request (8.0 s at 10 s, 2.0 s
at 4 s), theW_credlockout atpwdMaxFailureand the Revision 8 manual cleanup of a stuck
entry, run live.docs/machine-auth-operations.mdgains "Password compartment emergency
block". A flag-only redeploy answers 503, not the 403 of AC-011 (a); AC-011 is corrected. The
W_credlockout policy is an open maintainer decision (CHANGE.md Q17). -
Machine write v2c (#379, T-028) run-time premise checks and sweeps, still not
opened (setPasswordstays unreachable until T-026). With
MACHINE_WRITE_CRED_ENABLED=truestart-up now also requires
MACHINE_WRITE_CRED_ACL_READER_DN/_PASSWORD(acn=configreader, distinct from
every other identity, in the protected set) andMACHINE_WRITE_CRED_CANARIES(one
set of three locked canary entries inside everyMACHINE_WRITE_CRED_SUBTREESDN), and
readsMACHINE_WRITE_CRED_PENDING_TTL(default24h, 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 databaseolcAccessagainst the guide's layout as text,
olcAddContentAcl TRUE, the default and pending policy values against the floor
(the image's defaultpwdMinLength8 is below the floor 12: v2c deployments need
LDAP_PASSWORD_MIN_LENGTH>=12, an intended operating cost), nocn=accesslogchild
readable byM, 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_DNShas no effect yet (the pre-read admits only the pending
policy). Only result codes and counts are logged. New
fixturesconfig-reader.ldif,config-reader-main.ldifand guide sections 8.8–8.9
(docs/machine-write-account.md). Chart:
ui.machineAuth.write.cred.enabled(defaultfalse) exists only to refuse: next to
audit.enabled=true(D379-14), next to anaudit.accessLog.opsholdingextended
orall(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.pyruns the real
pipeline (alive-tagged test server that addssetPasswordto its own table) against
slapd and scans responses, headers, the UI log and audit, the slapd log and
cn=accesslog(rootdn andM) for the one-time sentinels and for hash patterns: 0
in the configuration the chart and T-028 accept; withaudit.enabledthe auditlog
carries hashes, which the chart refuses. The T-028 check-5 pre-hashed probe is now
one fixed public constant ({SSHA}ofzz, saltldapium!; it was a random throwaway
value): its refused Modify still leaves that value incn=accesslogat start-up and
every 60 s whenwritesis 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; theval.regexallowlist B is not adopted) and
againstuserPasswordon aW_dataAdd (#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-ASCIImemberOfare recorded as
accepted/by-design (#394 items 2, 5, 6).TestMachineWriteClosedFieldSetnow covers
userPasswordspelling 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
grantsW_lockonlyaddonpwdAccountLockedTime, so aW_lockcredential can
set a lock but never delete or replace it (unlock stays human-only at the ACL
level too);write-data.ldifmovesdescriptioninto its own rule with
filter=(objectClass=groupOfNames), soW_datacannot write a user's description
(the fragment now has 9 rules). MachinelockUseradds 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 holdsMACHINE_WRITE_DATA_BIND_DN,
or start-up fails.scripts/check-machine-write-acl.shreports 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 returns401 token_invalid; missing,
invalid or expired snapshots return503 unavailablewithRetry-Afteronly
after token verification. The revocation subtree is protected from HTTP reads
and administrator writes. Initialize the sentinel and schedule the operator
heartbeat before enablingMACHINE_REVOCATION_ENABLEDor the chart's
ui.machineAuth.revocation.enabled; keep each replica on a stable LDAP node.
The healthy propagation bound isREFRESH + 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 inmachine-keycloak-e2e.yml. -
Optional machine-principal writes (#285, #372, #378), default off
(MACHINE_WRITE_ENABLED, a switch separate fromMACHINE_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-Keyis required (428idempotency_key_required) and all but
createUserneed exactly one strongentryCSNIf-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-Matchand entry uniqueness remain the second line). -
A machine write whose target no longer exists now returns the cookie path's
404 not_foundinstead of a retryable503 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 newoutcome=target_not_found(reason=request_rejected) rather than
reason=upstream_error. -
Chart: new optional
ui.machineAuth.write.groupSeedDNrendersMACHINE_WRITE_GROUP_SEED_DN
(empty by default; the default render is byte-identical). The chart'sallowedClients
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:
withMACHINE_WRITE_ENABLED=true(separate switch, default off; off, all of them
stay 403scope_deniedwith 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 initialmemberis the fixed
placeholderMACHINE_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;cnis 400),deleteGroup(directory.groups.delete, allow-listed groups
only, never a privileged group) andlockUser(directory.users.lock, separate
W_lockidentity, refuses members ofMACHINE_WRITE_PRIVILEGED_GROUPS; nested
group membership is an accepted residual risk, see the change package). Unlock
stays human-only:unlockUser/directory.users.unlockare not opened and a
machinePOST /api/users/unlockis 403 with no bind (D379-7).updateUser,
updateGroup(PUT) and every password operation stay closed. OpenAPI gains the
additivemachineBearer/x-machine-scopeon 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 optionalMACHINE_WRITE_GROUP_SEED_DN(read only
withMACHINE_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 theW_dataDN. The group pipeline rows
(createGroup,patchGroupdescription only,deleteGroup) exist as catalog data
and are exercised in tests only. ACL (operators): theW_datafragment
(scripts/test/fixtures/machine-acl/write-data.ldif, rule{5}) now lists
description; re-apply it whereW_datais installed. Because the ACL cannot tell
groups from users,W_datacan then writedescriptionon user entries too (the
machinepatchUserbody still excludes it). See
change package. -
Groundwork for machine user locking (#379, v2b), opened by the entry above: the
lockUserpipeline row (W_lock, closed body{dn}, strongIf-Match,
inetOrgPerson) was catalog data first. A lock
refuses a target that is a direct member of aMACHINE_WRITE_PRIVILEGED_GROUPS
group (anMread ofmemberOf, before theW_lockbind), and a lock ceiling
withMACHINE_WRITE_LOCK_ENABLEDoff fails startup. Unlocking stays human-only:
unlockUseranddirectory.users.unlockremain unopened (D379-7), so a machine
token still gets 403scope_deniedwith no bind onPOST /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 forX-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 forgeX-Forwarded-Forand 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 (privateandnone
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 with503
(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-source429only
when all slots are blocked, which costs cap x limit failed binds). Thresholds,
window,Retry-Afterand 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
theMACHINE_*environment, a Keycloak service-account token verifier, the JWKS key
source (refresh budget, single flight, bounded stale use, discovery retry), the pure
selectAuthprecedence rules, and the static operation-to-scope allowlist with a
deny-by-default guard. Additive only: withMACHINE_AUTH_ENABLEDunset no request,
response or route changes. OpenAPI gainssecuritySchemes.machineBearerplus
security/x-machine-scopeon exactly the eight allowed GET operations, and three
new stable error codestoken_invalid,token_expired,scope_denied.
Enabling it did not expose any data in unit 1: an authorized machine request
ended in a fixed503until 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 accountMACHINE_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_TIMEOUTdeadline bounds dial, bind and every search, and a bind
failure is a503with no fallback to another identity. Machine-only boundaries:
getEntry/listTreerefuse any DN outsideLDAP_BASE_DN(so cn=accesslog,
cn=config and cn=Monitor) with403 scope_deniedbefore any LDAP connection is
opened,listTreerefuses a listing of more than 1000 children with422 size_limit_exceeded, andgetMonitorreads the accesslog only with theaudit.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 is400 cursor_invalid). Every request that carries anAuthorizationheader while the
feature is on is exactly oneevent=machine_accesslog line (see
docs/audit-event-schema.md). The Go interfaceClient.MonitorStatsgained an
includeAccessLogparameter (human sessions passtrue; unchanged behaviour).
WithMACHINE_AUTH_ENABLEDunset 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 guidedocs/machine-ldap-account.mdfor the dedicated read-only
LDAP account (strong password, the finalolcAccessLDIF 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.pyrun inapi-credentials-e2e: three
freshly initialised slapd containers (anonymous read base unset, set, operator leading
allow),olcAccessread 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 originalolcAccessbyte 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,oathEncKeyand
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 withLDAP_REPLICATION_IDENTITY=prepareonly 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:Authorizationgrammar, 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 afterMACHINE_REQUEST_TIMEOUT;429 machine_rate_limitedwith an exactRetry-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 codemachine_rate_limited(429, retryable); the audit
line usesreason=rate. Helm gainsui.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_credentialsaccess token) to call eight read-only
GEToperations (listUsers,listGroups,listTree,getEntry,
listPasswordPolicies,getMonitor, and the opt-inlistAuditActions,
getServerSettings) as one dedicated read-only LDAP account. Writes, passwords,
backups, application profiles,entry/moveandgetMeare never available to a
machine. Compatibility: withMACHINE_AUTH_ENABLEDunset (the default, Helm
ui.machineAuth.enabled=false) nothing changes: noMACHINE_*variable is read, the
chart renders byte-identical manifests, andAuthorizationis 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 indocs/machine-keycloak-client.md(audience mapper, lightweight access
token off), the LDAP account and ACL on every LDAP node
(docs/machine-ldap-account.md), andUI_TRUSTED_PROXIESset to CIDRs ornone.
Rollback:MACHINE_AUTH_ENABLED=false(orui.machineAuth.enabled=false) and
replace every replica; there is no persisted state. Emergency revocation: remove
the client fromMACHINE_ALLOWED_CLIENTSor 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 mostMACHINE_TOKEN_MAX_TTLplus 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=preparein 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 inmachine-keycloak-e2e.ymland gate releases (see the unit 5a note below). Not verified: installing the chart with machine auth on a cluster, a real ingress in front ofX-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 now400with the new stable codecurrent_password_rejectedand a
fixed text (#264,D264-1..D264-3); it used to be500 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 passwordon a request that carriesoldPasswordmaps
to it; result 53 for any other reason (for exampleoperation restrictedon a
read-only database), result 53 withoutoldPasswordand every other
unclassified error stay a redacted500 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 readerrorkeep working
either way and older clients just see the text. -
Optional
Idempotency-Keyon the core user/group writes, entry move and backup
start (change packageapi-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 409idempotency_key_conflict, an
unknown result is 409idempotency_outcome_unknown. New codes
idempotency_key_conflict,idempotency_key_reused,
idempotency_outcome_unknown,idempotency_capacityand
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 andkey_idonly) and survive restarts; they
needUI_IDEMPOTENCY_KEY_FILE, which the chart sets when backups are enabled.
GET /api/server-settingsgainsidempotencyEnabled. -
Every
/apierror is now one JSON envelope
{"error", "message", "code", "requestId", "retryable"}(change package
docs/changes/api-error-envelope, #218).errorkeeps its text and
messageis an identical, deprecated alias, so existing clients keep
working; new keys arecode(a closed, append-only set — see
docs/api.md),requestId(=X-Request-Id) andretryable. 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
examplecould not save application profileis nowinternal error; the
cause is in the server log under the samerequestId). 429 and retryable
503 responses carryRetry-After. OpenAPI now has oneErrorschema 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
fromppolicy/ppmstill 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
errorormessage; 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 carryetagandGET /api/entryanETagheader (the entry's
entryCSN);If-MatchonPUT/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 412revision_conflictwith nothing
written. Requests without the header behave as before. New
PATCH /api/usersandPATCH /api/groups(JSON Merge Patch) keep fields
they do not mention;PUTis 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 500partial_failure(the one envelope
that also carriesstateanddn) with the entry to check. The
ETagalso 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:memberOfand 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 isstate: 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 anOriginheader is now refused with 403origin_mismatchbefore
any handler runs unless thatOriginis the server's own origin (change
packageapi-error-envelope, D218-16, #218). An empty,nullor repeated
Originis refused too. Requests without anOriginheader (curl, scripts,
services) are unaffected, as areGET/HEAD/OPTIONS. Clients whose HTTP
library adds anOriginheader on its own must stop sending it. Behind a
reverse proxy the server compares against its own scheme (from
X-Forwarded-Protoand friends) and the requestHost; a proxy that
rewritesHostmakes ordinary browser writes 403, one that keepsHostand
forwards the scheme is fine. There is no switch to turn the gate off;
rollback is reverting the change. -
Opt-in
/metricslistener (#218): setMETRICS_ADDR(host:port, chart
ui.metrics.enabled) to serve theldapium_ui_*process metrics (request
rate and latency, API errors percode, 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 onlyGET /metrics. On the public portGET /metricsis now a
404 error envelope; the single-page-app fallback used to answer it with
index.html. Adds the Go dependencygithub.com/prometheus/client_golang.
RemoveMETRICS_ADDRto turn it off again. -
Opt-in CORS for reads (#218):
CORS_ALLOWED_ORIGINS(comma-separated exact
scheme://host[:port], chartui.cors.allowedOrigins) lets the listed
origins readGET/HEADresponses with credentials and answers their
preflight forGET, HEAD, OPTIONSonly. Off by default, in which case no
Access-Control-*header is sent; when on,Vary: Originis on every
response. It never opens a write path: the Origin gate above ignores the
list, and the session cookie staysSameSite=Lax. The OpenAPI description
andllms.txtno longer claim unconditionally that there are no CORS
headers. -
Compatibility of the error envelope's
message: it stays a copy oferror
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 readerror. -
Cursor pagination for
GET /api/usersandGET /api/groups(change package
api-cursor-pagination, #215). Sending any oflimit(1-200, default 50),
cursor,qorsortselects cursor mode, which walks a directory past the
5000-entry cap in a fixed order; the response gainshasMoreand
nextCursor(continue whilehasMoreis 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 andq,
and a re-login invalidates them. New codes: 400cursor_invalid, 422
size_limit_exceededandscan_limit_exceeded, 503scan_timeout
(retryable, withRetry-After). Each page costs a scan of the candidates
(100000 per request, 30 s deadline); a non-root identity needs read access
toentryUUIDon every listed entry. Items carry the sameetagas in the
legacy list. Seedocs/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 withjob_idand aLocation
header (the body still haskindandstatus). 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].statuscan now becancelled, so clients that treat it as a
closed set need updating (job records addabandoned, for a run whose
result could not be established after a restart); the 409backup_busy
body gainsactive_job_idandactive_kind; a failure to record a start or
cancel request is now 503persistence_unavailable(nothing started, no
signal sent) where it used to be a 422 that also covered other failures,
which stay 422validation_failed; thebackup_startedlog line carries
actor_fp(a one-way fingerprint) instead ofactor(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.jsonnext 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 readsbackup-jobs.jsonor
.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-allto * by self writecoveredpwdPolicySubentry, so any bound user could
point its own entry at anypwdPolicysubentry (e.g. one without lockout, quality
check or history). A new rule before the catch-all gives the entry itself only
readonpwdPolicySubentryand passes every other identity through unchanged
(by * break); the admin still assigns policies. The catch-all moves from{2}to
{3}({4}to{5}withLDAP_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 (oneldapmodify, 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'sattrs=list now also coverspwdReset,
pwdStartTime,pwdEndTimeandpwdAccountLockedTime, so the entry itself gets
readonly: 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,
pwdLastSuccessandpwdHistorywere 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).dedicatedno
longer reports it as an unreadable storedolcSyncrepl(#387). Behaviour is
otherwise unchanged: exit non-zero, nothing created. LDAP_REPLICATION_IDENTITY(admindefault /prepare/dedicated), unit 1
of the staged replication-identity change (#229,docs/changes/replication-identity,
T-010). Onlyadminis effective and it changes nothing (cn=configand
olcSyncreplbyte-identical). Any other value is validated and then (until
unit 2 below madeprepareeffective) the
container refuses to start ("not implemented in this image yet"): an invalid
value, a non-admin mode withoutLDAP_REPLICATION_ENABLED,LDAP_ADMIN_DN
equal tocn=replicator,<root>,preparewith an explicit replication
password, anddedicatedwith 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 butadminuntil 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 FIRSTolcAccessrule
({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 anolcLimitsrule (size=unlimited time=unlimited) with one
offlineslapmodify -n 0, verified by readingcn=configback; 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, storedolcAuthzRegexp/olcAuthIDRewrite,
olcAuthzPolicyother thannone,olcTLSVerifyClientother thannever
(soprepareandLDAP_TLS_MUTUAL_AUTHdo not combine), any entry with
authzTo/authzFrom, a storedolcRootDN(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'solcLimitsrule is always
placed FIRST (limits are first-match) and any other stored rule for the identity
is replaced, so an earliersize=1rule cannot cap it.
adminstays byte-identical;dedicatedwas still refused at this unit
(unit 3 below). Rollback: an older image oradminleaves 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 ascn=replicator,<root>(password from
LDAP_REPLICATION_PASSWORD(_FILE)) withtls_reqcert=demandand
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_ENABLEDoff, no readableLDAP_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 smuggledprovider=ldap://...cannot send the identity's bind in
clear text; re-checked beforeolcSyncreplis 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; adedicatedstart now removes such an unreadable stored
olcSyncrepland 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 containingprovider=starts normally), a_FILEsecret that is
read once (never twice, never substituted by the admin password),
LDAP_TLS_MUTUAL_AUTH, a customLDAP_REPLICATION_BIND_DN; stored-config
refusals (olcAuthzRegexp,olcAuthIDRewrite,olcAuthzPolicy,
olcTLSVerifyClient,authzTo/authzFrom, rootDN = reserved DN, existing
entry without the ACL) leave the volume untouched.adminandpreparestay
byte-identical. Not yet a supported mode: the identity entry is created by an
administrator by hand (theensure/rotate/retirecommands, restore.sh
total-loss, Kubernetes recovery, credential rotation, the checks and the chart
are later units). A new cluster cannot start indedicated; migrate
admin->prepare-> entry ->dedicated. Rollback: restart the node in
admin(re-rendersolcSyncreplwith the admin DN);preparerefuses 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, whichLDAP_SIZE_LIMITotherwise caps
at 10000 however small the pages are (the admin DN is already exempt). Unset
leavescn=configalone; a number up to 2147483647 orunlimitedconverges
to oneolcLimits: users size.prtotal=<value>rule;offremoves it. It
weakensLDAP_SIZE_LIMITas a bulk-dump backstop, so leave it unset unless an
API client must enumerate more than 10000 entries without the admin DN.
Rollback: setoffand restart once before moving to an image that predates
the setting; the rule lives incn=configand 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, andcn=config/olcSyncreplstay byte-identical (a custom
LDAP_REPLICATION_BIND_DNwith 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
indocs/ha-profile.md.rotate,rollback-adminand the periodic check
remain unimplemented.
Helm chart
- New values, all off or empty by default:
ui.metrics.enabledwith
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).
Enablingui.metrics.enabledadds a NetworkPolicy that selects the UI pods,
which makes them default-deny for ingress: the HTTP port stays open to every
source unlessui.networkPolicy.httpFromnarrows it. The metrics port is
exposed only by a separate<release>-ldapium-ui-metricsService, 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'smetricsjob,replication-chaos-e2e.yml,
security-e2e.yml,sssd-e2e.yml,ui-e2e.yml,upgrade-e2e.yml, and
offline-bundle.yml'soffline-e2ejob. 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) viaqueue: max. Required status check names are unchanged. - Machine bearer authentication, unit 5a (#214; tests, CI and drills only, no product
change): the newmachine-keycloak-e2e.ymlworkflow (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.ymlnow also requires the jobmachine 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 viaworkflow_dispatchor tag a newer commit; skipped, cancelled or other-SHA
runs never satisfy it (seeRELEASING.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
cf6fca6to8a5910fin /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