Security plugin¶
Read-only inspection of a DHIS2 instance's security posture. Starts deliberately
small — one command, d2w security settings — and is built to grow one command
at a time.
d2w security settings # password policy, credential expiry, registration, lockout
d2w --json security settings # the same data as a typed JSON object
The plugin is CLI-only today: the descriptor's register_mcp is a no-op and
there is no mcp.py. That is a valid plugin shape (see
plugin runtime), and the extension recipe
below covers adding an MCP surface when a command warrants one.
Layout¶
packages/dhis2w-core/src/dhis2w_core/v{41,42,43}/plugins/security/
├── __init__.py # plugin descriptor — name "security", register_cli, no-op register_mcp
├── cli.py # Typer sub-app + register(app); render helpers (_number / _months / _flag)
├── service.py # async pure functions taking a Profile, returning typed models
└── models.py # SecuritySettings view-model
The three version trees are mechanical copies that differ only by import path
(dhis2w_core.v42 -> v41 / v43). The /api/systemSettings keys and endpoint
are identical across v41/v42/v43, so there is no per-version divergence to carry.
settings — security slice of /api/systemSettings¶
service.get_security_settings(profile) does one GET /api/systemSettings and
validates the response into SecuritySettings. The model declares only the
security-relevant fields; Pydantic's default extra="ignore" drops the rest of the
~100-key settings object, so the slice stays focused in both the table and under
--json (no need to filter with ?key= query params).
SecuritySettings is a deliberate projection of the generated OAS
SystemSettings model (dhis2w_client.generated.v{41,42,43}.oas), which already
declares every field here. We don't reuse the full generated model for this read
because it can't validate a live /api/systemSettings response: the endpoint returns
keyAnalysisDisplayProperty lowercase ("name"), which the OAS DisplayProperty
enum rejects (BUGS.md #42).
The projection omits that one field, so it parses. The clean long-term fix is an OAS
spec-patch widening that enum, then a client.system.settings() -> SystemSettings
accessor the plugin can call — at which point this projection can shrink to a render
concern. Per CLAUDE.md rule 7, always grep the generated trees before hand-writing a
model; this is the documented exception, not a license to duplicate.
| Field (wire key) | Meaning |
|---|---|
minPasswordLength |
Minimum password length the server enforces |
maxPasswordLength |
Maximum password length |
credentialsExpires |
Password-expiry interval in months; 0 renders as 0 (never) |
credentialsExpiresReminderInDays |
Days before expiry the user is reminded |
credentialsExpiryAlert |
Whether expiry alerts are sent |
keyAccountRecovery |
Self-service account recovery enabled (accountRecovery in the table) |
keySelfRegistrationNoRecaptcha |
Self-registration without reCAPTCHA |
keyLockMultipleFailedLogins |
Lock accounts after repeated failed logins |
enforceVerifiedEmail |
Require a verified email address |
Example output¶
security settings
┌─────────────────────────────────┬──────────┐
│minPasswordLength │ 8 │
│maxPasswordLength │ 72 │
│credentialsExpires │ 0 (never)│
│credentialsExpiresReminderInDays │ 28 │
│credentialsExpiryAlert │ no │
│accountRecovery │ enabled │
│selfRegistrationNoRecaptcha │ disabled │
│lockMultipleFailedLogins │ disabled │
│enforceVerifiedEmail │ disabled │
└─────────────────────────────────┴──────────┘
Library API¶
from dhis2w_core.profile import profile_from_env
from dhis2w_core.v42.plugins.security import service
settings = await service.get_security_settings(profile_from_env())
if (settings.minPasswordLength or 0) < 12:
print("weak password floor")
Adding the next security command¶
The plugin is a teaching-sized template. Adding a command — say
d2w security whoami — is the same loop every plugin follows. Do the work in the
v42 tree first, verify it against a live server, then sweep the two siblings.
- Service function — add an
async deftov42/plugins/security/service.pythat takes aProfile, opens a client withopen_client, and returns a typed model. Reuse the typed client surface where it exists (client.system.me(),client.resources.<x>.list(...)) and fall back toclient.get(path, model=..., params=...)for raw endpoints.
async def whoami(profile: Profile) -> Me:
"""Return the authenticated user (username, roles, authorities)."""
async with open_client(profile) as client:
return await client.system.me()
-
Model — if the command returns a new shape, add a
BaseModeltomodels.py(never adictacross module boundaries — see the typed-data rule inCLAUDE.md). Keepextra="ignore"unless you genuinely want to surface unknown keys. Reuse an existing client model (likeMe) when one fits. -
CLI command — add a
@app.command(...)tocli.py. Branch onis_json_output(): dump the model under--json, otherwise buildDetailRows and callrender_detail(orrender_listfor collections). Keep formatting in small helpers like the existing_flag/_months/_number. -
Sweep to v41 + v43 (hard requirement —
CLAUDE.mdrule 15). Every new symbol/command lands in all three trees. The trees differ only by import path, so:
base=packages/dhis2w-core/src/dhis2w_core
for V in v41 v43; do
for f in cli.py service.py models.py __init__.py; do
sed -e "s/dhis2w_core\.v42/dhis2w_core.$V/g" \
-e "s/dhis2w_client\.v42/dhis2w_client.$V/g" \
"$base/v42/plugins/security/$f" > "$base/$V/plugins/security/$f"
done
done
grep -rn "v42" $base/v41/plugins/security $base/v43/plugins/security # must print nothing
If a command's wire shape ever diverges across versions, fold that divergence into
the same PR and add a BUGS.md entry — don't ship "v42-only, others later".
-
Test — add a case to
packages/dhis2w-core/tests/security/test_security_settings_cli.py(or a sibling). Patchdhis2w_core.v42.plugins.security.service.open_clientwith anAsyncMockcontext whose fake client returns your model, then drive the command throughCliRunner. Assert both the--jsonpayload and one human-output line. -
Examples — add the invocation to
examples/v{41,42,43}/cli/security.sh(all three; they can be identical when the command is version-agnostic). -
Docs —
make docs-cliregeneratesdocs/cli-reference.mdfrom the Typer app (it picks up the new command automatically). Add a row to this page's command list and, if the command is example-worthy, todocs/examples.md. Runmake docs-buildto surface broken links. -
Bridge read-only allowlist — a new read command is refused by the CLI bridge under
DHIS2_MCP_READONLY=1until you register it. Add its leaf path toREAD_ONLY_COMMANDSinpackages/dhis2w-mcp-bridge/src/dhis2w_mcp_bridge/cli_bridge.py. The bridge's drift test derives the expected set from a verb heuristic (get/list/show/…), so if your command's verb is one of those you only update the committed set; if the verb is novel or collides with a write elsewhere in the tree (assettingsdoes — a read undersecurity, a write undercustomize), also add the full path toREAD_ONLY_LEAVESinpackages/dhis2w-mcp-bridge/tests/test_cli_bridge.py. The bridge is version-agnostic, so this is a single edit, not a three-tree sweep. -
Gate —
make lint && make testmust pass before the PR.
Adding an MCP surface¶
When a read command is worth exposing to MCP clients:
- Create
v{41,42,43}/plugins/security/mcp.pywith aregister(mcp)that wraps the sameservice.pyfunction as a FastMCP tool (model the file onuser_group/mcp.py). Tools dump the typed model at the MCP edge — never return adictfrom the service layer. - Flip the descriptor's
register_mcpin__init__.pyfrom the no-op tomcp_module.register(mcp)and importmcp as mcp_module. make docs-mcpregeneratesdocs/mcp-reference.md; the surface test inpackages/dhis2w-mcp/tests/test_mcp_surface.pywill see the new tools.
Candidate next commands¶
Read-only first; these all map to endpoints the plugin can reach without mutating state:
d2w security whoami— authenticated user, roles, and authority count from/api/me(typedMealready exists indhis2w-client).d2w security authorities— the current user's effective authorities from/api/me/authorities.d2w security password-policy --lint— turnsettingsinto pass/warn checks against a baseline (min length, expiry set, lockout on) — a thin sibling of thedoctorprobe model.d2w security sharing-defaults— default public-access settings for new metadata (keyRequireAddToView,keyCanGrantOwnUserAuthorityGroups, …).d2w security sessions— active sessions / OAuth2 clients, where the API exposes them.
Writes (rotating credentials, toggling self-registration, editing security settings) are intentionally out of scope until a concrete caller needs them — keep the plugin read-only by default, and gate any future write behind an explicit confirm like the other mutating plugins.