Skip to content

feat(web): admin roster of accounts with expiring passwords - #626

Merged
CybotTM merged 2 commits into
mainfrom
feat/password-expiry-view
Jul 23, 2026
Merged

feat(web): admin roster of accounts with expiring passwords#626
CybotTM merged 2 commits into
mainfrom
feat/password-expiry-view

Conversation

@CybotTM

@CybotTM CybotTM commented Jul 23, 2026

Copy link
Copy Markdown
Member

Closes the display half of netresearch/ldap-selfservice-password-changer#628.

Adds /password-expiry: an admin-only page listing accounts whose LDAP password is expiring, resolved live through simple-ldap-go v1.13.0's PasswordExpiryFor / UsersWithExpiringPasswords. No scheduler, no mail — this is the read-only view; whether reminders are mailed from here or a separate cron tool is deferred.

Design decisions (confirmed with the maintainer)

  • Admin-only. New RequireAdmin middleware. An admin is a member of LDAP_ADMIN_GROUP or carries AD's adminCount=1. The group exists because OpenLDAP has no adminCount — without a way to name admins there, the gate would lock everyone out. Non-admins get 403; the home-page link appears only for admins, so nobody is shown a link that 403s.
  • Relevant rows + toggle. Default shows accounts due within the window; a Show all accounts toggle adds never-expires/unknown rows with a status badge.
  • Threshold is ?days= (default 30, capped 366) — no new env var for a display filter.

Data path

Live, not cached: the background cache never holds the domain max-age, so it cannot compute expiry, and a security roster wants current truth over a 30-second-stale snapshot. The default view calls UsersWithExpiringPasswords(ctx, days) (exactly the due set); the show-all path enumerates and resolves each status. The four states map to existing badge tokens, so dark mode themes automatically (verified: drawer__badge--warn/danger and list-row__badge all carry :root[data-theme="dark"] rules).

A real-instance finding

The admin group gate reads membership from memberOf. Active Directory populates it automatically; OpenLDAP needs the memberof overlay — the compose stack's overlay does not populate it retroactively, so a deployment must have it configured. Documented in the README.

Tests

Row collection (due-only, show-all, disabled-skip, error propagation) via an interface seam; four-state row mapping; window parse/clamp; sort (deadline/name/status, undated-last); the admin rule (adminCount, group membership, case-insensitive, the OpenLDAP no-group lockout); the 403 gate through the real Fiber app; badge class/label mapping. golangci-lint 0 issues, govulncheck clean, gofumpt clean, full go test ./... green.

Notes for the reviewer

  • Generated *_templ.go are gitignored and regenerated by CI (repo convention); run make templates locally.
  • make check's format step flags bulk_handlers.go / graph_v2_handler_test.go — both are pre-existing gofumpt failures on main, untouched here.
  • The commit used --no-verify: the repo's pre-commit hook is broken (go-fmt-goimports id absent from the pinned tekwizely/pre-commit-golang), blocking all commits. Separate fix.

Review round

An adversarial multi-dimension review (security, correctness, accessibility, tests) ran against the branch; each finding was re-verified against the code before acting. 9 confirmed, 1 refuted. Fixes in 575b31b; every previously-surviving mutant is now killed.

Confirmed and fixed:

  • Handler had 0% coverage — the only route test stopped at the 403 gate, unreachable past it because a DN-addressable admin cache entry can't be built outside the LDAP package. Added nil-defaulted adminCheck / expiryResolver seams; new tests cover the 503 guard, admit path, ?days=/?show=/sort wiring and the error branch. handlePasswordExpiryV2 0% → 94.7%, RequireAdmin100%.
  • Surviving mutants — the empty-admin-group guard and the status-sort column were untested; both now have killing tests (mutation-verified).
  • Undated rows floated to the top under dir=descsortByDeadline now pins must-change/never/unknown to the bottom in both directions.
  • aria-sort missing (WCAG 4.1.2) — added to the shared tableSortHeader, so the active sort column/direction now reaches AT across every V2 table, not just this page.
  • Em-dash-only deadline cell (WCAG 1.3.1) — undated rows now pair the visual dash with an sr-only "No expiry date".
  • Sticky adminCount — documented: AD never clears it, so access via that marker outlives de-privilege; prefer LDAP_ADMIN_GROUP.

Refuted: the "N+1 live query" claim — on AD PasswordExpiryFor reads from the already-fetched entry (no round-trip); on OpenLDAP the policy read is memoised per policy DN. No amplification.

Deferred (pre-existing, out of scope): the client-side filter does not announce "no results" via an aria-live region — a gap in the shared search JS affecting every V2 list table.

golangci-lint 0 issues, govulncheck clean, gofumpt clean, full go test ./... green, codecov/patch 96%.

Closes netresearch/ldap-selfservice-password-changer#628 (the display half).

Adds /password-expiry: an admin-only page listing accounts whose LDAP
password is expiring, resolved live through simple-ldap-go v1.13.0's
PasswordExpiryFor / UsersWithExpiringPasswords. No scheduler, no mail — this
is the read-only view; whether reminders are mailed from here or from a
separate cron tool is deferred.

Data path. Expiry is resolved live from the directory, not the background
cache: the cache never holds the domain max-age, so it cannot compute expiry,
and a security roster wants current truth rather than a 30-second-stale
snapshot. The default view calls UsersWithExpiringPasswords(ctx, days), which
already returns exactly the due set. The "show all" toggle instead enumerates
every account and resolves each status so never-expires and unknown rows can
be shown muted. The four states (expires / must-change / never-expires /
unknown) map to existing badge tokens, so dark mode themes automatically.

Authorization. The route is gated by a new RequireAdmin middleware. An admin
is a member of the configured LDAP_ADMIN_GROUP or carries AD's adminCount=1.
The group exists because OpenLDAP has no adminCount: without a way to name
admins there, the gate would lock everyone out — the group is that way.
Membership is read from memberOf (AD populates it; OpenLDAP needs the memberof
overlay). A non-admin gets 403; a discoverable link appears on the home page
only for admins, so no one is shown a link that would 403.

Threshold is a ?days= query param (default 30, capped at 366) — no new env
var for a display filter.

Tests cover the row collection (due-only, show-all, disabled-skip, error
propagation) via an interface seam, the four-state row mapping, window
parsing and clamping, sorting, the admin rule (adminCount, group membership,
case-insensitive, the OpenLDAP no-group lockout), the 403 gate through the
real Fiber app, and the badge class/label mapping. Verified visually by
rendering the real template with the real stylesheet.

The generated *_templ.go files are gitignored and regenerated by CI, per the
repo convention; run `make templates` locally to build.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Copilot AI review requested due to automatic review settings July 23, 2026 21:10
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
See the Details below.

License Issues

go.mod

PackageVersionLicenseIssue Type
github.com/netresearch/simple-ldap-go1.13.0NullUnknown License

OpenSSF Scorecard

PackageVersionScoreDetails
gomod/github.com/netresearch/simple-ldap-go 1.13.0 UnknownUnknown

Scanned Files

  • go.mod

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests frontend labels Jul 23, 2026
github-actions[bot]
github-actions Bot previously approved these changes Jul 23, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Mutation Testing Results

Mutation Score: 0% (threshold: 60%)

⚠️ Score is below threshold. Consider improving test coverage or test quality.

What is mutation testing?

Mutation testing measures test quality by introducing small changes (mutations) to the code and checking if tests detect them. A higher score means better test effectiveness.

  • Killed mutants: Tests caught the mutation (good!)
  • Survived mutants: Tests missed the mutation (needs improvement)

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.24060% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.92%. Comparing base (ab81688) to head (575b31b).

Files with missing lines Patch % Lines
internal/web/password_expiry_v2_handler.go 95.87% 2 Missing and 2 partials ⚠️
internal/web/admin.go 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #626      +/-   ##
==========================================
+ Coverage   69.94%   70.92%   +0.97%     
==========================================
  Files          34       36       +2     
  Lines        3437     3570     +133     
==========================================
+ Hits         2404     2532     +128     
- Misses        873      876       +3     
- Partials      160      162       +2     
Flag Coverage Δ
e2e 58.99% <ø> (ø)
unittests 71.40% <96.24%> (+1.00%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Addresses the confirmed findings of the adversarial review of #626.

Testability seams. RequireAdmin now resolves its admin check per request, and
the App carries two nil-defaulted overrides — adminCheck and expiryResolver —
so tests can drive the handler behind the gate. Without them the handler had
0% coverage: the only route test stopped at the 403, because a DN-addressable
admin cache entry cannot be built outside the LDAP package. New handler tests
cover the 503 no-service-account guard, the admit path, the days/show/sort
query-param wiring, and the resolver-error branch. handlePasswordExpiryV2 goes
from 0% to 94.7%, RequireAdmin to 100%.

Untested guards. Added the empty-admin-group case (a directory returning an
empty group entry must not match an unset admin group) and the status-sort
column, both previously surviving mutants.

Undated rows now sort to the bottom in BOTH directions. The old sentinel kept
must-change/never/unknown last only under ascending; under descending they
floated to the top above the furthest concrete deadline. sortByDeadline pins
them at the bottom regardless of direction, and the dead expiryOrder sentinel
is gone.

Accessibility. Sort headers now carry aria-sort (ascending/descending/none) so
the active column and direction reach assistive technology rather than living
only in an aria-hidden arrow — a fix in the shared tableSortHeader helper, so
every V2 table gains it. The deadline cell for undated rows pairs the visual
em-dash with an sr-only "No expiry date" instead of announcing a lone dash.

Documented that AD's adminCount is sticky, so admin access via that marker
outlives de-privilege; prefer LDAP_ADMIN_GROUP where access should track
current privilege.

Not changed: the client-side filter does not announce "no results" via a live
region — a pre-existing gap in the shared search JS affecting every V2 list
table, out of scope here.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval for maintainer PR

All automated quality gates passed. See SECURITY_CONTROLS.md for compensating controls.

@CybotTM
CybotTM added this pull request to the merge queue Jul 23, 2026
Merged via the queue into main with commit 2f67596 Jul 23, 2026
30 checks passed
@CybotTM
CybotTM deleted the feat/password-expiry-view branch July 23, 2026 21:40
This was referenced Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation frontend tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants