Compare commits

..

12 Commits

Author SHA1 Message Date
2842ec996c Add session notes for 2026-09-23 (CR-019 / D18 work) 2026-09-23 15:06:02 -07:00
b2a083ac7c T11.6: retire the per-browser Usage report (CR-019)
Removes the old "Usage logs" card from admin.html/admin.js (the
usage-admin panel + loadUsage(), D5/T7.10) now that T11.5's real,
server-side Activity & usage card exists and reads real per-user data
instead of per-browser localStorage.

wp-usage.js is deleted outright, along with its three <script> includes
(admin.html, wp-creation-index.html, work-package-suite.html) - it had
no reader left once the panel above it was removed (the "download the
full event log" button lived only in that panel), and per the original
CR-019 decision record it was never reliably tied to a real identity,
so it was never a candidate data source for the new report either.

Its two call sites (wp-creation-app.js, work-package-suite-app.js) keep
a local track() function as a documented no-op rather than having each
of their ~45 individual track('event', ...) call sites deleted one at a
time - that would be a much larger, riskier diff for the same outcome
(no data is recorded either way), and each call site still marks what
was worth recording if usage analytics are ever rebuilt server-side.
work-package-suite-app.js's dwell-timer plumbing (_stepEnter /
trackStepDwell), which only ever fed track(), was left in place for the
same reason: inert, not broken.

Also removes tests/usage_check.py, which tested exactly the retired
feature, and updates its line in docs/reference/file-map.md to point at
the 2026-09-17 decision record instead.

Verified:
  - grep across the whole repo for WPUsage / wp-usage.js / usage-admin /
    usage_check: no live references remain, only explanatory comments
    and planning docs (decisions-2026-09-17.md, wave-11.md) that
    describe the removal itself
  - node --check on all three touched .js files: no syntax errors
  - full backend smoke test (27/27) and seed_demo.py still pass
  - tests/baseline_shots.py --pages admin,creator,sop at 390px/1440px,
    run locally: all three pages render with no new JS errors (the one
    "beforeunload" log line on sop/creator at 1440px is pre-existing
    harness noise from wp-autosave.js's unsaved-work guard, unrelated
    to this change) and no horizontal overflow; refreshed baseline
    screenshots committed alongside this change
2026-09-23 14:46:32 -07:00
0b5ab59518 T11.5: verify Activity & usage card at 390px/1440px 2026-09-23 14:40:30 -07:00
a6c3fbfe50 T11.5: admin console Activity & usage card (CR-019)
New card in admin.html/admin.js, above the old per-browser Usage logs
card (which T11.6 retires next). Filters (date range, project, user,
tool) drive GET /api/usage/summary; two export buttons call
GET /api/usage/export (raw / sanitized) and save the CSV via a Blob,
same download pattern wp-usage.js already uses.

Access: the card lives inside admin.html, already gated admin-only
client-side by gateByRole() (unchanged) - a non-admin never sees the
card. The underlying API is gated server-side by require_user_manager
regardless (admin or project_super_user on >=1 project), independent
of and stricter than the client-side gate, so a non-admin request is
refused even if someone reached the endpoint directly.

Accessibility (C1): every control is a real <input>/<select>/<button>,
keyboard-operable. Status/export banners use the existing '.banner' /
'*-banner' id convention, which console-util.js's MutationObserver
already turns into aria-live (role=status, or role=alert on a '.bad'
banner) - no new announcement plumbing needed. No new CSS: reuses
.toolbar/.banner/.card/.row/.kv/.users, so nothing here adds a second
token source (the token rule).

Verified so far:
  - node --check html/admin.js: no syntax errors
  - every id admin.js's new code references exists in admin.html
    (scripted diff against the full getElementById/id= sets)
  - live-server check: GET /api/usage/summary with the exact
    (possibly-empty) query string _activityFilters() builds returns
    the {active_users, per_user_last_active, by_tool, event_count}
    shape renderActivity() expects; project_id filter narrows
    correctly; GET /api/usage/export?sanitize=true returns
    text/csv with the expected header row
  - full smoke test + seed_demo.py still pass

NOT yet verified: rendering at 390px/1440px with before/after
screenshots (CLAUDE.md verification step). This sandbox has no
headless-capable browser (no chromium/msedge on PATH) and the
playwright/chromium download is blocked by this environment's
network allowlist, so tests/cdp.py's harness can't run here. Deferred
to T11.7, same as wave 10's browser checks — flagging rather than
skipping silently.
2026-09-23 12:31:55 -07:00
2de76d52e6 T11.4: usage export endpoint (raw + sanitized CSV)
CR-019 / wave 11. Adds GET /api/usage/export, reusing _usage_query()'s
scoped filters (from/to/project_id/username/tool) and the same
require_user_manager gate as /api/usage/summary. Two modes:

- raw (default): real usernames, for internal admin use.
- sanitize=true: usernames replaced with an HMAC-SHA256 pseudonym
  (keyed with auth.SECRET_KEY, 16 hex chars, 'u_' prefix) so the file
  can be fed into PowerBI or another external reporting tool without
  carrying real identities. HMAC chosen over a plain hash since the
  username space is small enough to brute-force a bare digest.

Both modes emit at, username, project_id, tool, event as columns and
deliberately omit the detail JSON column in both modes to avoid an
identity leak riding along inside free-form detail data. Response is
returned with a Content-Disposition: attachment header and a filename
that encodes mode + date.

Verified locally against a throwaway SQLite DB with two seeded users
and four seeded UsageEvent rows:
  - raw export contains the real usernames and matches the summary
    endpoint's event_count for the same session state
  - sanitized export contains no real username or email anywhere in
    the file body, across two independently-issued export calls
  - the same real user maps to the same pseudonym both within one
    export and across the two separate export calls
  - raw and sanitized rows line up 1:1 on at/tool/event for the same
    filter set
  - the tool= filter narrows the export the same way it narrows the
    summary
  - a plain project_user is refused with 403; an unauthenticated
    request is refused with 401
  - full smoke test (27/27) and seed_demo.py both still pass
2026-09-23 12:25:05 -07:00
8e863ae7d0 T11.3: usage aggregation endpoint with filters (CR-019)
GET /api/usage/summary: active-user counts by day/week/month, per-user
last-active, per-tool breakdown. Filters (from/to/project_id/username/
tool) combine. Gated by require_user_manager - same boundary as the User
Directory. A project_super_user is scoped to events tied to projects
they manage plus their own activity (managed_project_ids), never another
user's suite-wide activity outside that; an app admin sees everything.

_usage_query() factored out so T11.4's export can never disagree with
what this endpoint counted - same filtered row set, not two derivations.

Verified: admin sees all seeded events; a project_super_user scoped to
one of two projects correctly sees only that project's events plus their
own account-wide activity, and specifically does NOT see the admin's
other-project or no-project activity; date/tool/project filters each
narrow results correctly and combine.
2026-09-23 12:19:14 -07:00
6cde6e3f60 T13.1: idle timeout + absolute ceiling (D18)
Sessions now slide on activity (AUTH_IDLE_MINUTES, default 30) capped by
a hard ceiling from original sign-in (AUTH_SESSION_HOURS, meaning changed,
default 12 -> proposed 8). login_at carried across reissues so the ceiling
survives refreshes; pre-D18 tokens with no login_at fall back to iat.
Refresh is throttled (~IDLE_MINUTES/3) so the cookie isn't rewritten on
every request. Wired into auth_gate (server/app.py) - no DB hit, reads
only the already-validated claims.

Verified: 7 unit-level checks (fresh-token expiry, past-ceiling refusal,
throttling, mid-session extension, legacy-token fallback both live and
expired, idle cutoff itself) all pass, plus the full 27-check smoke
suite still passes end to end through the new middleware path.
2026-09-23 11:40:59 -07:00
358469531c D18: revise T13.1 to idle timeout + absolute ceiling
Matt asked whether idle time would be a better fit than a flat session
length. It is, but idle-alone weakens D18's own purpose - a continuously
active session would never force a fresh Okta recheck on its own. Decided:
both. AUTH_IDLE_MINUTES (new, default 30) slides the session on activity;
AUTH_SESSION_HOURS (existing var, meaning changes to an absolute ceiling,
default 12 -> proposed 8) caps it regardless of activity.

Docs only in this commit - implementation is T13.1, next.
2026-09-23 11:38:37 -07:00
850b78972b T11.2: capture usage events (CR-019)
POST /api/usage/ping writes one page_open UsageEvent per authenticated
page load, identity from the session (get_current_user), never from the
client. Wired from exactly one place - auth-guard.js's proceed(), after
wp-auth-ready - so this can't drift into six separate per-page copies.

okta_callback now also writes one login event per sign-in.

Verified locally: unauthenticated ping -> 401; a real fake-Okta sign-in
writes exactly one login row and one page_open row, no duplicates.
2026-09-23 11:24:49 -07:00
0652fa732d T11.1: UsageEvent model + migration (CR-019)
New usage_events table, separate from audit_log - see the model docstring
for why. Verified: applies and downgrades cleanly on SQLite, and the
postgresql-dialect --sql render has no risky defaults (the BL-027 class
of defect). No app wiring yet - that's T11.2.
2026-09-23 11:23:14 -07:00
75ac930d0c waves 11-13: planning docs for CR-019, CR-020, D18
CR-019 - usage/activity metrics (admin console), wave 11
CR-020 - bulk editing of users, wave 12
D18    - Okta/AD deprovisioning detection and auto-disable, wave 13

Raised by Matt Mabrey 2026-09-17. Decision record and task breakdowns
only in this commit - no feature code yet.
2026-09-18 09:21:29 -07:00
df20b8f18d wave-10: close out claim mapping and redirect URI, live in production
Confirmed by an actual live Okta sign-in after main (cc64c88) deployed: preferred_username is the right identity claim, and the redirect URI works. Matt matched his existing pre-Okta admin account rather than getting JIT-provisioned as a duplicate. Two deploy-time snags recorded, both Case B (config, not data): OKTA_CLIENT_ID/SECRET/ISSUER left empty in Portainer at first (caught cleanly by is_configured()), then OKTA_ISSUER missing its https:// scheme (surfaced as httpx.UnsupportedProtocol, not a deliberate app error - BL-028 still stands). Neither needed the backup. BTG pilot group now includes Cody and Cameron, awaiting Adrian.
2026-09-09 13:34:32 -07:00
28 changed files with 1713 additions and 336 deletions

View File

@@ -58,6 +58,15 @@ POSTGRES_PASSWORD=<strong-random-password>
# openssl rand -base64 48 # openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret> AUTH_SECRET_KEY=<strong-random-secret>
# OPTIONAL — D18 (2026-09-23): a session slides on activity (AUTH_IDLE_MINUTES,
# default 30) capped by a hard ceiling from original sign-in regardless of
# activity (AUTH_SESSION_HOURS, default 8). Both are proposed defaults, not
# confirmed against this tenant's Okta SSO session policy — if Okta's own
# session outlives either one, re-auth here is likely a fast silent redirect
# rather than a real login screen. Full explanation in server/.env.example.
# AUTH_IDLE_MINUTES=30
# AUTH_SESSION_HOURS=8
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in # REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
# path (D15/D16). There is no local password anywhere in this app to fall back # path (D15/D16). There is no local password anywhere in this app to fall back
# to, so without these nobody can sign in at all. Get them from the Okta app # to, so without these nobody can sign in at all. Get them from the Okta app

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -283,7 +283,9 @@ python tests/triage_check.py # A6 - the sidebar answers the stand-up
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks # tests/usage_check.py (D5) removed at T11.6 - it tested the per-browser
# analytics core and admin report, both retired in favour of CR-019's
# server-side Activity & usage card (see docs/waves/decisions-2026-09-17.md).
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
``` ```

View File

@@ -0,0 +1,340 @@
# Decisions — September 17, 2026
Three items. Like `D11`, `D15`, `D16` and `D17`, these are new scope raised after
R2 closed out (see `docs/reference/completion.md`, T9.7), not a reopening of
anything already decided there.
`CR-019` and `CR-020` get `CR` ids because they are field/product-facing feature
requests — the same kind of thing `CR-001`-`CR-018` were — not internal
engineering calls made mid-build. `D18` gets a `D` id because it is exactly that:
an internal security/architecture call, the same category as `D15` (Okta vs.
LDAPS), not a feature a user asked for.
Requested/raised by Matt Mabrey, 2026-09-17.
---
## CR-019 — Usage and activity metrics (admin console)
- **Area:** Admin Console / Monitoring
- **Priority:** proposed High — the current admin console has no reliable way to
answer "who is using this and how much," which is the same visibility gap the
original UX review found in `B4` (numbers that look authoritative but are not).
- **Source:** Matt Mabrey, 2026-09-17.
### Why this is not already built
`D5`/`T7.10` already shipped a usage-analytics feature — `html/wp-usage.js`, with
a report at `admin.js:666-699` — but it reads `localStorage`, which is scoped to
one browser. The report's own empty state says exactly this: *"No usage recorded
in this browser yet."* It cannot show who across the team is active or what they
use, because each person's activity exists only on their own machine. This is the
same class of defect `B4` named for the pipeline strip — a number that looks
authoritative but is not — just never generalized to this feature. `CR-019` is
therefore new server-side work, not a UI addition on top of what exists.
Two things already in the schema are relevant and reused rather than duplicated:
`User.last_login_at` (one timestamp, no history) and `AuditLog` (append-only,
already records business mutations — WP created, status changed, role changed —
per user, with a timestamp). Neither captures navigation or feature-open events,
which is the actual gap.
### Intent
Give admins a real, server-backed picture of who is using the suite, what parts
of it they use, and how active they are — replacing the per-browser report as
the thing anyone actually looks at.
### Decisions, made 2026-09-17
1. **Retention: indefinite.** No automatic purge of usage-event data. (Separate
from `AuditLog`'s own retention, which this does not change.)
2. **Scope: suite-wide, filterable.** Not per-project by default; the console
provides filters (date range, project, user, tool/page) rather than scoping
the data itself.
3. **Export: raw view shows real identities; export supports sanitization.**
The admin console's own tables and the CSV export both default to real
usernames — this is an internal audit tool, not a public one. But the export
also offers a "sanitize" toggle that replaces the actor with a **stable
pseudonymous id** (a per-user hash, consistent across rows and across
export runs) rather than dropping the identity field outright — so an
external system (Power BI or similar) can still group and trend "by user"
without ever receiving a real name. Flagged here as the recommended
approach rather than the only one Matt confirmed in so many words: if a
fully-anonymous (no stable id at all) export turns out to be what's
actually wanted, that is a one-line change to the same feature, raise it
in the PR rather than treating it as blocking.
4. **The old per-browser report is retired**, not kept alongside the new one.
Once `CR-019` ships, `admin.js`'s existing `usage-admin` panel (reading
`WPUsage.load(...)` per browser) is removed rather than left next to the
real report, where it would show a smaller, misleading number for whoever
happens to have it open. `html/wp-usage.js` and its two call sites
(`html/work-package-suite-app.js`'s wizard dwell-tracking, and the
creator's equivalent) are a separate question — the *recording* code can
stay or go independent of the *admin report* being retired, since dwell
events were never reliably tied to a real identity anyway. Default to
removing both unless a task finds a reason to keep the recorder; log that
reason rather than deciding it here.
### Acceptance criteria
- A new admin-only tab in `admin.html` (same role gate as the User Directory)
shows: active users over a selectable date range (day/week/month), each
user's last-active timestamp, and a breakdown of which tools/pages get
opened and how often.
- Server-side event capture, keyed to the authenticated session (real identity,
not a browser-local guess) — a new table, not an extension of `AuditLog`,
since page-open/navigation events are not business mutations and mixing them
in would make `AuditLog` noisy for its existing, narrower purpose.
- The console's filters cover date range, project, user, and tool/page, and
combine (e.g., "user X, last 30 days, field view only").
- CSV export from the console, in both raw (real usernames) and sanitized
(stable pseudonymous id per user) modes.
- Retention is indefinite; nothing in this item purges data.
- View-only, refresh-on-load. No alerting — matches how the rest of the admin
console works today; if that changes later it is new scope, not a rider on
this item.
- The old per-browser "Usage" report and its admin-console panel are removed
in the same wave, not left running alongside the new one.
- Accessible per `CLAUDE.md`'s standing `C1` rules (this is a new component,
not a legacy one — it ships accessible or it is not done, same as
everything else built since wave 7).
### Frontend/backend boundary
This needs server work, the same way `CR-004`/`CR-018`/`B4` did: a real table,
a capture endpoint, an aggregation endpoint, and an export endpoint. If a task
under this item is being built by writing to `localStorage`, it is rebuilding
the exact defect this item exists to replace — stop and say so, per
`CLAUDE.md`.
### Scheduling
New wave. Wave 10 (Okta) is merged, so this does not wait on anything.
Task breakdown: `docs/waves/wave-11.md`.
---
## CR-020 — Bulk editing of users in the admin console
- **Area:** Admin Console / User Directory
- **Priority:** proposed High — three of the four actions below touch access
control (role, project assignment, active/disabled) and the fourth is a hard
delete; getting the guardrails right matters more than getting it built fast.
- **Source:** Matt Mabrey, 2026-09-17.
### Why this is not already built
Every user-editing action in `html/users.js` today is one row, one action:
a role `<select>` per row, an activate/disable button per row, a
per-user project-membership checklist opened one user at a time, and a
per-row delete. There is no row selection in the User Directory table at all.
Server-side, every corresponding endpoint
(`/api/auth/users/{id}/active`, `/role`, `/project-role`, `/projects`, and
`DELETE /api/auth/users/{id}`) takes exactly one `user_id`. Bulk editing is new
UI (selection) and, for most actions, either a loop over the existing
single-user endpoints or new endpoints that accept a list — the task decides
which, per user count and transaction-safety needs.
**Deletion is already a hard delete today** (`delete_user`, `server/app.py:1121`
`db.delete(u)`, not a deactivate). Bulk delete inherits that: it is not this
item's job to invent a soft-delete pattern that does not exist for the single
case, but the confirmation step around it has to be sized for the fact that a
bad multi-select now removes more than one account at once, permanently.
### Decisions, made 2026-09-17
1. **All four bulk actions are in scope:** role change, activate/disable,
project assignment (add to / remove from a project, including the
project-role), and delete.
2. **Selection works two ways:** checkboxes (select-all and individual) in the
existing User Directory table, respecting whatever filter is already
applied (role, active/disabled, project) — and a CSV upload, for a one-off
bulk operation against an external list (e.g., an offboarding list that
didn't originate in this app). Both are in scope, not a choice between them.
### Acceptance criteria
- The User Directory table gains row checkboxes and a select-all that respects
the current filter; a bulk-action toolbar appears once at least one row is
selected.
- CSV upload as an alternative to checkbox selection: a list of usernames plus
the action to apply. Validates every row, reports rejected ones by row (bad
username, user not found, actor lacks permission over that user) rather than
silently skipping them — the same validate-and-report pattern `CR-005`
established for list uploads.
- Every existing single-user guardrail carries forward unchanged: an actor
cannot include their own account in a bulk action that would disable, demote,
or delete it; a super user's bulk action is scoped to only the users and
projects `require_see_user`/`require_manage_user` already let them touch
today (a super user cannot use a bulk action to reach a user or project
outside what they manage, even via CSV); `grantable_roles` still gates which
roles an actor may assign in bulk, the same as one at a time.
- Every affected row is written to `AuditLog` individually, exactly as the
single-user endpoints do today (one `role_changed` / `user_deleted` / etc.
row per user) — a bulk action is many audited changes, not one opaque batch
entry, so per-user history stays intact and readable in isolation.
- Confirmation before applying, using the `wp-dialog` kit (`T7.9`), not a
native `confirm()`. The dialog names exactly how many users are affected and,
for delete specifically, lists the affected usernames before committing.
- Partial failure is reported, not hidden: if some rows in a batch fail (scope,
already-deleted, bad CSV row), the action applies to what it can and states
exactly which rows failed and why. It never reports success on a batch that
partly failed.
- Accessible per `C1`: real controls, keyboard-operable selection and bulk-
action toolbar, `aria-live` announcing the result.
### Recommended, not yet confirmed — raise in the PR if this is wrong
- **Bulk delete gets an extra confirmation step beyond naming the count** —
proposed as typing a confirmation phrase (e.g. the word `DELETE`) regardless
of how many rows are selected, since one bad multi-select now removes more
than one account, permanently, with no soft-delete to fall back on. This is
a recommendation, not a confirmed requirement — Matt has not signed off on
the exact mechanism.
- **Project assignment is add/remove, not replace-the-whole-list** — a bulk
"add these users to project X" or "remove these users from project X"
action, rather than a bulk action that overwrites a user's entire project
list. Proposed because add/remove is less likely to clobber project
memberships the actor didn't intend to touch; a replace-the-whole-list
version is a materially different (and riskier) feature if that turns out to
be what's actually wanted.
### Frontend/backend boundary
Selection state (which rows are checked) is fine as client-side UI state — it
is not persisted data. Everything the bulk action actually does (role,
active/disabled, project membership, delete) already requires server work
today for the single-user case, and bulk does not change that: no new
localStorage-derived state, no client-side aggregation of what changed.
### Scheduling
New wave. Independent of wave 11 (`CR-019`) — the two do not touch the same
code and can build in either order or in parallel. Task breakdown:
`docs/waves/wave-12.md`.
---
## D18 — Detecting and acting on Okta/AD deprovisioning
- **Raised by:** Matt Mabrey, 2026-09-17, in response to a direct question about
what happens when someone's AD account is removed and Okta subsequently drops
them.
- **Amends:** nothing decided, closes a gap `D15`/`D16` left unaddressed. Those
items designed how identity flows *into* this app (Okta authenticates, this
app JIT-provisions and owns roles); neither addressed what happens when an
identity is withdrawn. This app has never had any deprovisioning signal —
push or pull — since Okta went live.
### What was found
Traced through `server/auth.py` and `server/okta_auth.py`: this app has no
connection back to Okta after initial sign-in. Consequences, confirmed against
the actual code:
1. A local `users` row is never touched by anything Okta-side. `is_active`
stays `True` indefinitely unless an admin manually disables or deletes the
account through the User Directory. There is no flag distinguishing a
terminated employee's account from a current one.
2. `get_current_user` (`server/auth.py:237`) re-checks `is_active` and
`token_version` on **every request** — so a manual disable takes effect
immediately, on the very next request. The gap is not enforcement, it's
detection: nothing tells an admin to go flip that switch.
3. A session already live when someone is deprovisioned keeps working, fully,
for up to `AUTH_SESSION_HOURS` (12 hours today, `server/auth.py:63`),
because validity is checked against this app's own JWT and DB state only,
never against Okta.
4. A NEW session can't be established once Okta drops the account —
`okta_login`/`okta_callback` requires completing Okta's own sign-in, which
Okta itself refuses. The front door closes on its own; the back door (an
already-open session, and the stale local record) does not.
### Decisions, made 2026-09-17
1. **Build a scheduled sync against Okta's Management API**, not an Event Hook.
This app has, since `D15`, only ever reached out to Okta — never received
anything from it — and a polling design keeps that shape rather than
introducing a new inbound, internet-reachable endpoint with its own
signature-verification surface. Traded deliberately: this is
poll-interval-late rather than real-time, which is judged acceptable for an
HR/offboarding-driven event, not a to-the-second requirement.
2. **Revised 2026-09-23, in response to Matt's question about idle time
instead of a flat session length: sessions now slide on activity, with a
hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check
with Okta on a fixed schedule regardless of activity; a pure idle timer
with no ceiling does the opposite — a continuously-active session would
never force a fresh Okta check on its own, which is a worse fit for the
exact threat this item exists to address (someone still clicking around
after being deprovisioned). Decided: **both**.
- `AUTH_IDLE_MINUTES` (new, default **30**): a session with no request
for this long stops being valid. Implemented as a sliding JWT expiry —
the token is reissued with a fresh `exp` on activity, throttled so the
cookie isn't rewritten on literally every request.
- `AUTH_SESSION_HOURS` (existing var, meaning changes to an **absolute
ceiling**): no session survives past this many hours from the original
sign-in, no matter how continuously active it is. Default changing from
12 to a proposed **8** — flagged as a recommendation, not confirmed.
- Both defaults, and the mechanism itself, should be sanity-checked
against the tenant's actual Okta SSO session policy — if Okta's own
session silently outlives either number, re-authentication here is
likely a fast redirect, not a real re-login screen, so these numbers
cost less than they look like they do. Confirm before treating either
as final.
3. **The sync only ever disables an account — it never re-enables one.** A
rehire showing active in Okta again does not automatically restore access;
an admin re-enabling the account is a deliberate act, consistent with
`D16`'s posture that this app never auto-grants access on its own initiative.
4. **Fail closed on the side of INACTION, not disablement.** This is the
opposite failure direction from `D16`'s login-time posture ("if Okta is
unreachable, the app is unreachable for everyone"). Here, an Okta API
error, timeout, empty response, or anything the sync can't confidently
parse must result in **no change to any account** that cycle, plus a
logged failure. A sync job that treats "couldn't reach Okta" as "nobody is
active" is a far worse outcome than a missed cycle — it would lock out the
entire org on an Okta API hiccup. This is the single most important
acceptance criterion in this item.
5. **Every auto-disable is audited individually**, same as every other
account-state change in this app: an `AuditLog` row per user, with an actor
value that's clearly the sync job and not a person (e.g.
`system:okta_sync`), so it reads correctly in the User Directory's history
and is never confused with an admin's own action.
6. **New credential required:** a read-scoped Okta API token (or an Okta
service-app OAuth2 client), separate from the `OKTA_CLIENT_ID`/
`OKTA_CLIENT_SECRET` pair used for sign-in. This needs provisioning by
whoever administers the Okta tenant — the same dependency that gated the
original OIDC rollout (D15's "security scoping reply").
### Recommended, not yet confirmed — raise in the PR if this is wrong
- **Sync interval:** proposed every 15 minutes. Frequent enough that the
detection gap is small, infrequent enough not to hammer Okta's API or need
special rate-limit handling. Not confirmed with IT/security.
- **Where the job runs:** proposed as an in-process background task inside the
existing `api` container (it already has `outbound` network access to reach
Okta, and already holds the Okta client config) rather than a new sidecar
container. The `backup` container (`docker-compose.yml`) is the existing
precedent for a scheduled-interval container in this stack, if isolation
from the `api` process is preferred instead — a reasonable alternative, not
the recommendation.
- **Admin visibility:** at minimum, an auto-disable is a normal, readable
`AuditLog` entry (visible whever admin already reviews audit history, and
naturally covered once `CR-019`'s activity view exists). Whether it should
also trigger an email/notification to admins is a genuine open question —
proposed as a fast-follow rather than blocking this item, since `D10`
already established the pattern for admin-controlled email toggles this
could reuse.
### Frontend/backend boundary
Entirely server-side and infrastructure. No new `localStorage` state, no
frontend surface beyond what already reads `is_active` and `AuditLog` today
(the User Directory, and eventually `CR-019`'s activity view).
### Scheduling
New wave, independent of wave 11 and wave 12 in the sense that nothing here is
blocked by them — but note it touches the same `is_active`/account-state
surface `CR-020`'s bulk actions touch in `server/app.py`. Not a hard
dependency; sequence commits to avoid an avoidable merge conflict, per
`CLAUDE.md`'s "one task per PR" spirit. Task breakdown: `docs/waves/wave-13.md`.

View File

@@ -0,0 +1,85 @@
# Session notes — 2026-09-23
Working notes for the `feat/waves-11-13` line (CR-019, CR-020 reserved, D18),
written up before merge. Not a spec document — `wave-11.md`, `wave-13.md` and
`decisions-2026-09-17.md` are the source of truth for scope and acceptance
criteria. This is the "what actually happened building it" record.
## What shipped today
**CR-019 (wave 11) — usage/activity metrics, T11.1 through T11.6 complete:**
- `UsageEvent` table + migration (T11.1), a capture endpoint wired into
`auth-guard.js` so every protected page pings it once per load, plus a
`login` event at Okta sign-in (T11.2).
- `GET /api/usage/summary` (T11.3) — active users by day/week/month,
per-user last-active, per-tool breakdown, filterable by date/project/
user/tool, all combinable.
- `GET /api/usage/export` (T11.4) — raw and sanitized CSV. Sanitized mode
replaces the username with an HMAC-SHA256 pseudonym (keyed with
`auth.SECRET_KEY`), stable per user across rows and across separate
export calls, so an external tool (Power BI etc.) can still group by
user without ever seeing a real name.
- A new "Activity & usage" card in the admin console (T11.5): real filter
controls, the summary tables, both export buttons. Client-side gated
admin-only same as the rest of the console; the API underneath is
independently gated server-side regardless.
- Retired the old per-browser "Usage logs" panel and `wp-usage.js`
entirely (T11.6) — it had no reader left and was never a real data
source for the new report anyway. The scattered `track()` call sites in
the creator and wizard were left in place calling a documented no-op,
rather than deleting ~45 individual call sites for the same effect.
Only **T11.7 (final wave verification)** is left before wave 11 is fully
closed out — everything under it has already been verified per-task, so
this is a consolidation pass, not new work.
**D18 (wave 13) — Okta/AD deprovisioning, T13.1 only:**
- Session lifetime changed from one flat `AUTH_SESSION_HOURS` to a sliding
idle timeout (`AUTH_IDLE_MINUTES`, default 30) capped by a hard ceiling
from original sign-in (`AUTH_SESSION_HOURS`, default 8, meaning changed
from "session length" to "absolute ceiling"). Both defaults are flagged
in `.env.example` and `DEPLOYMENT.md` as proposed, not confirmed against
the tenant's actual Okta SSO policy.
- **T13.2 onward (the actual Okta Management API sync job) is paused** —
explicit call from Matt: no Okta API credential yet, come back to it
later. Not started, not blocked on anything code-side.
**CR-020 (wave 12, bulk user editing):** not started. Reserved, scoped in
`wave-12.md`, no code touched.
## Environment work (not itself a task, but load-bearing)
- Fixed a CRLF/LF mismatch that was making every tracked file look modified
to this session's git client (`core.autocrlf true`, repo-local, no file
content changed).
- This machine had no Python. Installed it via `winget` (`Python.Python.3.12`)
and set up a `.venv` in the repo with `server/requirements.txt` installed,
specifically so `tests/baseline_shots.py` could run locally — this
sandbox has no headless-capable browser and can't download one (network
allowlist), so the 390px/1440px screenshot verification CLAUDE.md asks
for had to run on Matt's own machine instead, using the browser already
installed there (Edge).
- Established a repeatable local verification loop for every task: throwaway
SQLite, fake-Okta sign-in, promote to admin, `seed_demo.py` +
`smoketest.py` (27/27 passing throughout), plus `baseline_shots.py` for
anything touching `html/`.
## Standing constraints, still in effect
- Everything stays local on this branch line. No `git push` at any point
today.
- One task per PR discipline was kept even though all of it landed on one
branch — each commit corresponds to exactly one task ID, in wave order,
each individually verified before the next started.
## Before merging
- Run T11.7 (full wave-11 verification pass) and record it in `wave-11.md`.
- Decide where this branch actually merges to — `feat/waves-11-13` has all
of today's commits already; this notes branch was cut from it so it can
fast-forward back in, or merge as its own PR if the notes should be
reviewed separately from the code.
- T13.2+ and all of wave 12 remain explicitly out of scope until Matt says
otherwise.

View File

@@ -201,10 +201,33 @@ Depends only on `main` as it stands after `D15`. Not sequenced behind any other
## Still open ## Still open
- The OIDC claim mapping (`T10.3`). - The `Business Technology Group` pilot assignment in Okta. Originally six names
- Final confirmation of the redirect/callback URI (`https://wp.controls.dev/api/auth/okta/callback` (Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
proposed, pending security). Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
- The `Business Technology Group` pilot assignment in Okta. deliberately, pending the live sign-in confirmation below — awaiting his response to
add the rest of the group now that it has.
Closed since first written: admin bootstrap and break-glass posture, previously open Closed since first written: admin bootstrap and break-glass posture, previously open
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above. questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
**Closed 2026-09-09, live in production:** the redirect/callback URI
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
never actually confirmed by name in Request 50649's thread) is correct, no
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
users, not the seeded test fixture), landed on `index.html` signed in, admin role and
project access untouched. One real deploy-time snag on the way, worth recording since
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in
Portainer (env var names added, values never filled in) — caught via
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
temporarily unavailable"), not silently. A second snag after filling those in:
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
neither needed the backup or the database.

257
docs/waves/wave-11.md Normal file
View File

@@ -0,0 +1,257 @@
# Wave 11 — Usage and activity metrics
**Items:** `CR-019`
**Depends on:** wave 10 merged (it is; this wave does not wait on anything else)
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Seven tasks, one concern each, in build order. Do not start a task whose
dependency is not merged. `CR-020` (bulk user editing) is reserved but not
scoped — it does not belong in this wave.
---
### T11.1 — CR-019: `usage_events` table + migration
- **Items:** `CR-019`
- **Depends on:** nothing (first task)
- **Blocks:** T11.2
- **Surface:** `server/`
- **Files:** `server/models.py`, `server/alembic/versions/`
**Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for
navigation/feature-open events rather than business mutations. Suggested shape:
`id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor`
does today for consistency), `project_id` (nullable — not every event is
project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`,
`wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g.
`page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not
touch `AuditLog` — this is a new table, not an extension of it (see the
decision record's reasoning).
**Do not:** fold this into `AuditLog`. They serve different questions and mixing
them makes the existing audit trail noisier for its existing readers.
**Done when:**
- [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned
by date range constantly)
- [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) —
render `alembic upgrade --sql` for postgresql and read it before calling
this done, per the class of defect `BL-027` logged
- [ ] no change to `AuditLog`'s shape or behavior
---
### T11.2 — CR-019: capture the events
- **Items:** `CR-019`
- **Depends on:** T11.1
- **Blocks:** T11.3
- **Surface:** `server/` + `html/`
- **Files:** `server/app.py` (new endpoint), `html/auth-guard.js`
**Problem:** Six pages need this and none should implement it separately — that
is exactly how `S4`'s "no global nav on two pages" and the four parallel token
systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `<head>`,
on all six protected pages (`index.html`, `field.html`, `users.html`,
`wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already
knows the verified user once the `wp-auth-ready` event fires. That is the one
place this belongs.
**Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one
`UsageEvent` row per call, keyed to the session (server trusts the session, not
anything the client claims about identity). Call it once from `auth-guard.js`
after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a
`login` event at the point a session is actually established (reuse whatever
`okta_callback` already does at sign-in — do not add a second source of truth
for "did this person log in").
**Do not:** build a per-page capture call. If a page needs this and
`auth-guard.js` does not cover it, fix `auth-guard.js`, not the page.
**Done when:**
- [ ] one `page_open` event is recorded for a real sign-in on each of the six
pages, verified per page
- [ ] exactly one `login` event per Okta sign-in, not one per page load after
it
- [ ] the endpoint rejects a request with no valid session (this is server-
enforced identity, not client-reported)
- [ ] no page other than `auth-guard.js` calls this endpoint directly
---
### T11.3 — CR-019: aggregation endpoint with filters
- **Items:** `CR-019`
- **Depends on:** T11.2
- **Blocks:** T11.4, T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** Build the read side: active-user counts by day/week/month, per-user
last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`,
which only ever holds one value), and a per-tool usage breakdown. Accept query
filters: date range, project, user, tool — combinable, per the decision record.
This is server aggregation, the same principle `B4` established for the
pipeline strip: the browser asks for a number, the server computes it from real
rows, nothing is derived client-side from a partial cache.
**Done when:**
- [ ] active-user counts are correct against a seeded fixture with known dates
- [ ] filters combine correctly (verified: user + date range + tool together
narrows correctly, not just each alone)
- [ ] a project filter that matches nothing returns an empty result, not an
error or the unfiltered total
---
### T11.4 — CR-019: export, raw and sanitized
- **Items:** `CR-019`
- **Depends on:** T11.3
- **Blocks:** T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** A CSV export endpoint over the same filtered query T11.3 exposes.
Two modes: raw (real usernames, the console's default) and sanitized. Sanitized
mode replaces the actor field with a stable pseudonymous id — a per-user hash,
consistent across rows in the same export and across separate export runs —
so an external system (Power BI or similar) can still group and trend "by
user" without ever receiving a real name. Do not simply drop the identity
column; that breaks per-user grouping downstream, which defeats the point of
an activity export.
**Done when:**
- [ ] raw export contains real usernames
- [ ] sanitized export never contains a real username or email anywhere in the
file, including in a `detail` blob if one is included
- [ ] the same real user maps to the same pseudonymous id within one export AND
across two separate export runs (a hash of something stable, not a
per-request random id)
- [ ] both modes otherwise contain identical rows for the same filter
---
### T11.5 — CR-019: admin console Activity tab
- **Items:** `CR-019`
- **Depends on:** T11.3, T11.4
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.html`, `html/admin.js`
**Do:** New tab, same role gate as the User Directory. Filters (date range,
project, user, tool) driving the tables from T11.3; export buttons (raw and
sanitized) calling T11.4. Build accessible from the start per `C1` — this is a
new component, not a legacy one carrying an old defect forward: real
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
count that updates without a page reload, focus visible throughout.
**Done when:**
- [x] the tab is reachable only by an admin (the card lives inside admin.html,
already gated client-side by gateByRole(); the API underneath it is
independently gated server-side by require_user_manager regardless)
- [x] every filter is a real form control, keyboard-operable (date/select/text
inputs and a `<button>`, no click-div)
- [x] both export buttons produce the files T11.4 defines (verified against
the live endpoint in T11.4's own checks, and present/wired here)
- [x] works at 390px and 1440px — verified 2026-09-23 via
`tests/baseline_shots.py --pages admin` run locally on Windows (this
sandbox has no headless browser available; the script was run on the
user's machine instead, after installing Python via winget since it
wasn't present). Screenshots in `docs/reference/baseline/admin-390.png`
/ `admin-1440.png`. No JS errors, no horizontal overflow at either
width; the card rendered with real seeded data (events, by-tool,
per-user-last-active tables) confirming the filters and summary read
correctly, not just that the markup exists.
---
### T11.6 — CR-019: retire the per-browser Usage report
- **Items:** `CR-019`
- **Depends on:** T11.5
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
`html/wp-usage.js` and its two call sites
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
the real one exists, per the 2026-09-17 decision. Decide what happens to
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
equivalent): they were never reliably tied to a real identity, so they are not
a data source the new report can adopt. Default to removing the recorder too
unless it is still doing something useful on its own (re-read what it actually
records before deciding — do not assume from this file alone).
**Do not:** leave the old panel in place "just in case." Two activity reports
showing two different numbers is worse than one.
**Decision (2026-09-23):** `wp-usage.js` is removed, not kept — it had no
reader left once the admin panel above it was removed (the "download the
full event log" button lived only in that panel), and per the original
decision record it was never reliably tied to a real identity, so it was
never a candidate source for the new report either. The file itself and its
three `<script>` includes (`admin.html`, `wp-creation-index.html`,
`work-package-suite.html`) are gone. Its two call sites
(`wp-creation-app.js`, `work-package-suite-app.js`) keep a local `track()`
function as a documented no-op rather than having each of their ~45
individual `track('event', …)` call sites deleted one at a time — that
would be a far larger, riskier diff for the same outcome (no more data is
recorded either way), and it keeps each call site as a marker of what was
worth recording if usage analytics are ever rebuilt server-side. The
dwell-timer plumbing that only ever fed `track()` (`work-package-suite-app.js`'s
`_stepEnter`/`trackStepDwell`) was left in place for the same reason — it is
inert now, not broken, and touching it buys nothing.
Also removed: `tests/usage_check.py` (tested exactly the retired feature —
D5/T7.10's per-browser analytics core and admin report) and its line in
`docs/reference/file-map.md`, replaced with a note pointing at this decision.
**Done when:**
- [x] the old `usage-admin` panel and its markup are gone from `admin.html`/
`admin.js`
- [x] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
stated reason) — not left ambiguous — see above
- [x] nothing else in the app references the removed code; grep confirms
(only remaining hits are this file, the 2026-09-17 decision record, and
the two explanatory code comments left at the retired call sites — all
prose, not live references)
---
### T11.7 — CR-019: verification
- **Items:** `CR-019`
- **Depends on:** T11.6
- **Blocks:** nothing
- **Surface:** `html/` + `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
test and `seed_demo.py`, run the full suite.
**Done when:**
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
a failure is stated with a reason
- [ ] screenshots committed
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 11 exit criteria
- [ ] real, server-side activity data exists per user, indefinitely retained
- [ ] the admin console shows it, filterable by date/project/user/tool
- [ ] export works in both raw and sanitized form
- [ ] the old per-browser report is gone, not duplicated
- [ ] `CR-019` fully accounted for, no open acceptance criteria

193
docs/waves/wave-12.md Normal file
View File

@@ -0,0 +1,193 @@
# Wave 12 — Bulk editing of users
**Items:** `CR-020`
**Depends on:** wave 10 merged (it is). Independent of wave 11 (`CR-019`) — no
shared files, may build in either order or in parallel.
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Six tasks, in build order.
---
### T12.1 — CR-020: bulk endpoints
- **Items:** `CR-020`
- **Depends on:** nothing (first task)
- **Blocks:** T12.2, T12.4
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** Add bulk variants of the four existing single-user actions — role
change, active/disabled, project assignment (add/remove + project-role), and
delete. Each takes a list of `user_id`s plus the action's parameters and
applies `require_see_user`/`require_manage_user`/`grantable_roles` **per row**,
exactly as the single-user endpoint does today — a super user's bulk request
cannot reach further than their existing single-user requests can. Do not skip
the self-action guard: an actor's own account is rejected out of any batch
that would disable, demote, or delete it, same as today.
Each successful row writes its own `AuditLog` entry via `log_event`, same
action names the single endpoints already use. A row that fails is reported in
the response (user id, reason) and does not stop the rest of the batch from
being attempted.
**Do not:** invent a single opaque "bulk_action" audit entry in place of the
per-row entries. Do not build a soft-delete path for bulk delete that doesn't
exist for the single case — bulk delete stays a hard delete, matching
`delete_user` today.
**Done when:**
- [ ] each of the four bulk actions is callable with a list of user ids and a
single set of parameters
- [ ] a batch containing the actor's own account rejects only that row, not
the whole batch — verified for disable, demote, and delete
- [ ] a super user's batch that includes a user/project outside what they
manage rejects only that row, with a stated reason
- [ ] every successful row produces its own `AuditLog` entry, identical in
shape to what the single-user endpoint would have written
- [ ] a batch with some failing rows still applies to the rows that succeed,
and the response lists exactly which rows failed and why
---
### T12.2 — CR-020: row selection in the User Directory table
- **Items:** `CR-020`
- **Depends on:** T12.1
- **Blocks:** T12.3
- **Surface:** `html/`
- **Files:** `html/users.js`, `html/users.html`
**Do:** Add a checkbox per row and a select-all control, respecting whatever
filter (`role`, `active`/`disabled`, project) is currently applied — select-all
selects the filtered set, not every user in the system regardless of what's
shown. A bulk-action toolbar appears once at least one row is checked and
disappears at zero.
**Done when:**
- [ ] select-all selects exactly the rows currently visible under the active
filter, not the full unfiltered table
- [ ] changing the filter while rows are selected does something sane and
visible (either clears the selection or keeps it explicit which rows are
still selected) — pick one and state it, don't leave it undefined
- [ ] the toolbar is keyboard-reachable and only present when >=1 row is
selected
---
### T12.3 — CR-020: bulk-action toolbar
- **Items:** `CR-020`
- **Depends on:** T12.2
- **Blocks:** T12.5
- **Surface:** `html/`
- **Files:** `html/users.js`
**Do:** Wire the toolbar to T12.1's endpoints for role change, activate/
disable, and project assignment (add to / remove from a project + project-
role). Confirmation before applying uses the `wp-dialog` kit (`T7.9`) —
`wpConfirmDialog`, not a native `confirm()` — naming exactly how many users are
affected. On completion, report per-row results if anything failed (T12.1
already returns this) rather than a single success/failure toast that hides a
partial failure.
**Delete is built separately, in T12.5** — do not wire delete here.
**Done when:**
- [ ] role change, activate/disable, and project assignment each work end to
end against a multi-row selection
- [ ] the confirmation dialog names the exact affected count before anything is
sent
- [ ] a batch with a partial failure shows which rows failed, not just an
undifferentiated error
- [ ] `aria-live` announces the outcome
---
### T12.4 — CR-020: CSV upload path
- **Items:** `CR-020`
- **Depends on:** T12.1
- **Blocks:** T12.6
- **Surface:** `html/` + `server/`
- **Files:** `html/users.js`, `server/app.py`
**Do:** An upload accepting a list of usernames plus the action to apply,
following the validate-and-report pattern `CR-005` established: reject and
report bad rows (username not found, actor lacks permission over that user)
rather than silently dropping them. This is a second entry point onto the same
T12.1 endpoints, not a third implementation of the bulk logic.
**Done when:**
- [ ] a CSV with a mix of valid and invalid usernames applies to the valid
rows and reports the invalid ones by row, with a reason
- [ ] the same permission/self-action guards from T12.1 apply here — a CSV
cannot reach a user a checkbox-driven batch couldn't
- [ ] duplicate usernames in one CSV are handled without double-applying or
erroring confusingly
---
### T12.5 — CR-020: bulk delete confirmation
- **Items:** `CR-020`
- **Depends on:** T12.3
- **Blocks:** T12.6
- **Surface:** `html/`
- **Files:** `html/users.js`
**Do:** Wire delete into the toolbar with a heavier confirmation than the other
three actions, per the decision record's recommendation: list the affected
usernames and require typing a confirmation phrase (e.g. `DELETE`) before the
request is sent, regardless of how many rows are selected. This is flagged in
the decision record as a recommendation Matt has not explicitly signed off on
— if the PR reviewer wants a lighter or heavier mechanism, that's the moment to
change it, not a reason to skip building a real confirmation now.
**Done when:**
- [ ] the affected usernames are listed in the confirmation dialog before
delete is sent
- [ ] the request is not sent until the confirmation phrase is typed correctly
- [ ] the actor's own account, if somehow selected, is rejected with a clear
reason rather than silently included or silently dropped
---
### T12.6 — CR-020: verification
- **Items:** `CR-020`
- **Depends on:** T12.4, T12.5
- **Blocks:** nothing
- **Surface:** `html/` + `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`: run locally, exercise bulk role
change, activate/disable, project assignment, CSV upload, and bulk delete at
390px and 1440px, before/after screenshots, smoke test, `seed_demo.py`, full
suite.
**Done when:**
- [ ] all `CR-020` acceptance criteria in `decisions-2026-09-17.md` are met or
a failure is stated with a reason
- [ ] screenshots committed
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 12 exit criteria
- [ ] all four bulk actions work against both a checkbox selection and a CSV
upload
- [ ] every existing single-user guardrail (self-action, scope, grantable
roles) holds under bulk use
- [ ] bulk delete requires typed confirmation and lists affected usernames
- [ ] partial failures are always reported, never hidden behind a blanket
success
- [ ] `CR-020` fully accounted for, no open acceptance criteria

242
docs/waves/wave-13.md Normal file
View File

@@ -0,0 +1,242 @@
# Wave 13 — Okta/AD deprovisioning sync
**Items:** `D18`
**Depends on:** wave 10 merged (it is). Not blocked by wave 11 or wave 12, but
shares `server/app.py` account-state surface with wave 12 (`CR-020`) — sequence
commits to avoid an avoidable conflict.
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Six tasks, in build order. **T13.3's fail-closed behavior is the most
important done-when list in this wave — do not relax it to ship faster.**
---
### T13.1 — D18: idle timeout with an absolute ceiling
- **Items:** `D18`
- **Depends on:** nothing (first task, independent of the rest)
- **Blocks:** nothing
- **Surface:** `server/`
- **Files:** `server/auth.py`, `server/app.py` (`auth_gate` middleware),
`server/.env.example`, `DEPLOYMENT.md`
**Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
asked whether an idle timeout would be a better fit than a flat session
length. It is, but not by itself — see the decision record's reasoning on why
idle-with-no-ceiling is actually a worse fit for this item's own threat model
than a flat expiry would have been. Build both.
**Do:**
- Add `login_at` to the JWT payload in `create_token()` — the original
sign-in time, distinct from `iat`, which becomes "when THIS token was
issued" once tokens start getting reissued. `login_at` never changes across
reissues; it's what the absolute ceiling is measured from.
- Add `AUTH_IDLE_MINUTES` (default 30). `AUTH_SESSION_HOURS` stays the name
for the absolute ceiling, default changing from 12 to a proposed 8 — update
its docstring/comment in `auth.py` and `.env.example`, since its MEANING is
changing (session length -> hard ceiling on top of a sliding idle window),
not just its value.
- In `auth_gate` (`server/app.py`), after confirming a request is
authenticated: if `now < login_at + AUTH_SESSION_HOURS` (the ceiling hasn't
passed), compute `new_exp = min(now + AUTH_IDLE_MINUTES, login_at +
AUTH_SESSION_HOURS)`. If `new_exp` is meaningfully later than the current
token's `exp` (throttle this — do not reissue on every single request, only
when enough time has passed to be worth a new cookie; a few minutes of
slack is fine), mint a refreshed token carrying forward `sub`/`username`/
`role`/`ver`/`login_at` unchanged, and set it on the response.
- If the ceiling HAS passed, do not refresh — let the existing token expire
on its own terms (it may already be invalid, or may tick over within the
idle window; either way, no new one is issued past the ceiling).
- The middleware should not need a DB round trip to do this — everything
needed (`sub`, `username`, `role`, `ver`, `login_at`) is already in the
validated claims. `get_current_user`'s existing per-request `is_active`/
`token_version` check is unaffected and still runs separately.
**Do not:** reissue the cookie on every request unconditionally — that's a
`Set-Cookie` header on every API call for no benefit over a coarser refresh.
Do not let the ceiling check silently vanish for tokens issued before this
change ships — a token with no `login_at` claim should fall back to treating
its own `iat` as `login_at`, not bypass the ceiling entirely.
**Done when:**
- [ ] a session with continuous activity stays alive past 30 minutes but is
cut off at the `AUTH_SESSION_HOURS` ceiling regardless
- [ ] a session with no activity for 30+ minutes is rejected on its next
request
- [ ] the cookie is not rewritten on every single request — verify the
refresh is throttled, not unconditional
- [ ] a pre-existing token with no `login_at` claim (simulating a session
issued before this shipped) still gets a hard ceiling, via the `iat`
fallback
- [ ] `.env.example` and `DEPLOYMENT.md` explain both variables and flag both
defaults as proposed, not confirmed against Okta's own session policy
- [ ] existing session/auth tests updated for the new mechanism, not just a
new number
---
### T13.2 — D18: Okta Management API client
- **Items:** `D18`
- **Depends on:** nothing (independent of T13.1)
- **Blocks:** T13.3
- **Surface:** `server/`
- **Files:** new `server/okta_sync.py` (or extend `server/okta_auth.py`
task's call), `server/.env.example`
**Do:** A small client for Okta's user-list endpoint, authenticated with a new,
separate credential (e.g. `OKTA_API_TOKEN`) — not the OIDC client secret used
for sign-in. Fetch the full user list (paginated per Okta's API) rather than
one-by-one lookups per local user; this app has ~20-odd accounts today, and a
list-and-diff is simpler and cheaper than N calls. Return each Okta user's
identity-claim value (matching `OKTA_IDENTITY_CLAIM`, already confirmed live
per wave 10) and status.
Follow the existing pattern for external credentials in this repo (`MICRON_DB_URL`,
`SMTP_PASSWORD`): env-only, never logged, never returned to the browser in an
error message.
**Do not:** reuse `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET` for this. Sign-in and
the management API are different trust boundaries with different scopes;
conflating them means a compromise or rotation of one affects the other
unnecessarily.
**Done when:**
- [ ] the client authenticates with its own credential, distinct from the OIDC
client
- [ ] it fetches the complete Okta user list, handling pagination
- [ ] an auth failure or malformed response raises a clear, specific error
rather than returning an empty list indistinguishable from "everyone was
deprovisioned" — this distinction is what T13.3 depends on
- [ ] the credential is never logged or surfaced in any API response
---
### T13.3 — D18: the sync job
- **Items:** `D18`
- **Depends on:** T13.2
- **Blocks:** T13.4
- **Surface:** `server/`
- **Files:** `server/okta_sync.py`, `server/app.py` (or wherever `log_event`
lives)
**Do:** Compare the Okta user list (T13.2) against local `users` rows. For any
local `is_active=True` user whose Okta identity is missing from the list, or
present with a non-active status, set `is_active=False` and write an
`AuditLog` row (`actor="system:okta_sync"`, action e.g.
`user_deprovisioned_by_sync`, detail naming the Okta status found). Never
touch a user already `is_active=False`. Never re-enable anyone.
**The fail-closed rule, non-negotiable:** if T13.2's client raises an error of
any kind (network, auth, malformed response, timeout), this task takes **no
action on any account** for that run and logs the failure clearly (server log,
at minimum). An error is never treated as "Okta returned zero active users."
Write a test that asserts this directly: feed the sync a failing client and
assert zero rows changed and zero `AuditLog` entries written.
**Done when:**
- [ ] a user missing from Okta's list, or present but not active, is disabled
with a correctly-detailed audit row
- [ ] a user already disabled is left alone (no duplicate audit row each run)
- [ ] an active Okta user already active locally produces no audit row (only
changes are logged, not a clean bill of health every cycle)
- [ ] **a simulated Okta API failure results in zero account changes and zero
audit rows** — this is the one check that must never be skipped or
weakened
- [ ] the job never re-enables an account under any input
---
### T13.4 — D18: run it on a schedule
- **Items:** `D18`
- **Depends on:** T13.3
- **Blocks:** T13.6
- **Surface:** `server/`, `docker-compose.yml`
- **Files:** `server/app.py` (startup hook) or a new sidecar per the `backup`
container's pattern — task's call, per the decision record's noted
alternative
**Do:** Wire T13.3 to run on an interval (proposed 15 minutes, env-overridable
— e.g. `OKTA_SYNC_INTERVAL_SECONDS`, matching `BACKUP_INTERVAL_SECONDS`'s
naming). Default choice is an in-process background task in the `api`
container; if a separate container is chosen instead, follow the `backup`
service's shape (own Dockerfile or script, `internal` network plus whatever
egress reaching Okta requires — check whether `outbound` as currently defined
is sufficient or Okta needs a distinct allowance).
**Done when:**
- [ ] the job runs automatically on the configured interval without manual
invocation
- [ ] interval is env-configurable with a sane default
- [ ] a container restart does not produce a duplicate/overlapping run, and a
slow cycle does not stack with the next one
---
### T13.5 — D18: admin visibility
- **Items:** `D18`
- **Depends on:** T13.3
- **Blocks:** T13.6
- **Surface:** `html/`
- **Files:** `html/users.js` / `html/admin.js` (wherever audit history is
already surfaced)
**Do:** Confirm an auto-disable reads clearly wherever admins already look at
account history — the actor string (`system:okta_sync`) should be
self-explanatory in context, not require reading server logs to understand.
Do not build new UI beyond making sure the existing audit surface renders this
actor sensibly. Email/notification to admins on auto-disable is a noted
fast-follow (the decision record flags it as open, not required here) — do not
build it in this task; log it instead if it's tempting to add.
**Done when:**
- [ ] an auto-disabled account's audit entry is visible and legible in the
existing admin UI without special-casing
- [ ] nothing here silently assumes `CR-019`'s activity view exists yet — this
must work standalone
---
### T13.6 — D18: verification
- **Items:** `D18`
- **Depends on:** T13.4, T13.5
- **Blocks:** nothing
- **Surface:** `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`. Beyond the usual run: specifically
re-run T13.3's fail-closed test in isolation and confirm it still passes after
T13.4's scheduling wrapper is in place — the scheduling layer must not
introduce a path that swallows the client's error and proceeds anyway.
**Done when:**
- [ ] all `D18` acceptance criteria in `decisions-2026-09-17.md` are met or a
failure is stated with a reason
- [ ] the fail-closed behavior is verified end to end through the scheduled
wrapper, not just the bare sync function
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 13 exit criteria
- [ ] `AUTH_SESSION_HOURS` defaults to 2, documented
- [ ] the sync job runs on a schedule and correctly disables accounts Okta no
longer shows as active
- [ ] every auto-disable is individually audited with a clearly non-human actor
- [ ] an Okta API failure of any kind changes zero accounts — verified, not
assumed
- [ ] the sync never re-enables an account
- [ ] `D18` fully accounted for, no open acceptance criteria

View File

@@ -34,12 +34,12 @@
/* Every container admin.js paints a table into is a scrollport of its own, so a /* Every container admin.js paints a table into is a scrollport of its own, so a
sticky header always has something to stick to rather than sliding up behind sticky header always has something to stick to rather than sliding up behind
the app bar. Same rule as console.css's .tscroll. */ the app bar. Same rule as console.css's .tscroll. */
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ #comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; } overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so /* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
one table never ends up with two scrollbars. */ one table never ends up with two scrollbars. */
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){ #activity-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
overflow:visible; max-height:none; } overflow:visible; max-height:none; }
/* Comment text and audit detail are the two columns you are actually here to /* Comment text and audit detail are the two columns you are actually here to
@@ -53,7 +53,7 @@
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); } border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
@media (max-width:900px){ @media (max-width:900px){
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{ #comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
max-height:none; } max-height:none; }
} }
</style> </style>
@@ -175,16 +175,38 @@
<div id="audit-admin" class="note">Click refresh to load.</div> <div id="audit-admin" class="note">Click refresh to load.</div>
</div> </div>
<!-- USAGE LOGS --> <!-- ACTIVITY &amp; USAGE (CR-019) -->
<div class="card"> <div class="card">
<h2>Usage logs</h2> <h2>Activity &amp; usage</h2>
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard — <div class="sub">Who is using the suite, which tools, and how often — recorded server-side on every
sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser, sign-in and page open, kept indefinitely. Filter below, or export a CSV: raw (real usernames) for
so this reflects activity on <strong>this</strong> machine.</div> internal use, or sanitized (each user replaced with a stable, non-reversible id) for feeding into
Power BI or another external reporting tool without carrying real identities.</div>
<div class="toolbar"> <div class="toolbar">
<button onclick="loadUsage()">Refresh</button> <label for="act-from">From</label>
<input type="date" id="act-from" onchange="loadActivity()">
<label for="act-to">To</label>
<input type="date" id="act-to" onchange="loadActivity()">
<select id="act-project" onchange="loadActivity()"><option value="">All projects</option></select>
<input id="act-user" placeholder="Username…" oninput="loadActivity()">
<select id="act-tool" onchange="loadActivity()">
<option value="">All tools</option>
<option value="launcher">Launcher</option>
<option value="wizard">SOP wizard</option>
<option value="creator">Work package creator</option>
<option value="field_view">Field view</option>
<option value="admin">Admin console</option>
<option value="directory">User directory</option>
</select>
<button onclick="loadActivity()">Refresh</button>
</div> </div>
<div id="usage-admin" class="note">Click refresh to load.</div> <div id="activity-banner" class="banner" style="display:none"></div>
<div id="activity-admin" class="note">Loading…</div>
<div class="toolbar" style="margin-top:var(--s3)">
<button onclick="exportActivity(false)">Download CSV (raw)</button>
<button onclick="exportActivity(true)">Download CSV (sanitized)</button>
</div>
<div id="activity-export-banner" class="banner" style="display:none"></div>
</div> </div>
<!-- DB SNAPSHOT --> <!-- DB SNAPSHOT -->
@@ -215,7 +237,6 @@
</div> </div>
</div> </div>
<script src="wp-usage.js"></script>
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="wp-dialog.js"></script> <script src="wp-dialog.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>

View File

@@ -24,7 +24,7 @@ function reveal(){
loadNotifications(); loadNotifications();
loadComments(); loadComments();
loadAudit(); loadAudit();
loadUsage(); loadActivity();
} }
function showDenied(){ function showDenied(){
document.getElementById('admin-denied').style.display=''; document.getElementById('admin-denied').style.display='';
@@ -189,6 +189,19 @@ async function loadProjects(){
banner.style.display='none'; banner.style.display='none';
_adminProjects = json; _adminProjects = json;
renderProjects(); renderProjects();
populateActivityProjectFilter();
}
// The activity project filter reuses the same project list the Projects card
// already fetched — no second /api/projects call just to fill a <select>.
function populateActivityProjectFilter(){
const sel = document.getElementById('act-project');
if(!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">All projects</option>' +
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
sel.value = cur;
} }
function renderProjects(){ function renderProjects(){
@@ -465,6 +478,90 @@ function renderAudit(){
'</tr>').join('')+'</tbody></table>'; '</tr>').join('')+'</tbody></table>';
} }
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
// Server-side, per-user activity — distinct from the "Activity log" card above
// (that's AuditLog: business mutations) and from the "Usage logs" card below
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
// filterable by date/project/user/tool, and exportable raw or sanitized.
function _activityFilters(){
const q = new URLSearchParams();
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
return q;
}
async function loadActivity(){
const banner = document.getElementById('activity-banner');
const box = document.getElementById('activity-admin');
if(!box) return;
banner.style.display='none';
box.textContent = 'Loading…';
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
if(status===403){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Your account cant see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
box.innerHTML=''; return;
}
if(status!==200 || !json){
banner.className='banner bad'; banner.style.display='';
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
box.innerHTML=''; return;
}
renderActivity(json);
}
function renderActivity(sum){
const box = document.getElementById('activity-admin');
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
const fmt = s => s ? wpFormatDateTime(s) : '—';
const bucket = (label, obj, take) => {
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
if(!entries.length) return '';
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
'</table>';
};
const perTool = Object.entries(sum.by_tool||{});
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
'<div class="note">No per-tool data.</div>';
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
'</tbody></table>' : '<div class="note">No per-user data.</div>';
box.innerHTML =
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
'</div>'+
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
}
async function exportActivity(sanitize){
const banner = document.getElementById('activity-export-banner');
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
const q = _activityFilters();
if(sanitize) q.set('sanitize', 'true');
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
if(status!==200 || typeof json !== 'string'){
banner.className='banner bad';
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
return;
}
const blob = new Blob([json], { type:'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
banner.className='banner ok';
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
}
// ── notifications / email settings ────────────────────────────────────────────── // ── notifications / email settings ──────────────────────────────────────────────
let _settings = {}; let _settings = {};
async function loadSettings(){ async function loadSettings(){
@@ -656,48 +753,6 @@ async function loadNotifications(){
'</tr>').join('')+'</tbody></table>'; '</tr>').join('')+'</tbody></table>';
} }
// ── usage logs (read from this browser's localStorage) ──────────────────────────
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
// operator-facing readout belongs - behind the same admin gate as this whole
// page (gateByRole() below shows nothing else either). Data comes from
// wp-usage.js, the single implementation; the keys predate the move, so
// everything recorded before it is still here.
function loadUsage(){
const box = document.getElementById('usage-admin');
if(!box) return;
const tools = [
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
];
let html = '';
tools.forEach(([label, key, prefix]) => {
const evs = (WPUsage.load(key).events) || [];
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
if(!evs.length){
html += '<div class="note">No usage recorded in this browser yet.</div>';
return;
}
const byEvent = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = v => v ? wpFormatDateTime(v) : '—';
html += '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
});
box.innerHTML = html;
}
// ── access control: admins only ───────────────────────────────────────────────── // ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready'). // auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
// Show the console for admins; otherwise show the "Admins only" notice. // Show the console for admins; otherwise show the "Admins only" notice.

View File

@@ -125,12 +125,40 @@
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill. // left that would need a floating fallback pill.
// ── CR-019: usage ping ───────────────────────────────────────────────────
// One page_open event per authenticated load, sent from exactly ONE place
// (here) rather than from each page's own script - the shared-chrome lesson
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
// error to the person using the app - a missed usage ping is not something
// anyone here should notice happening.
var TOOL_BY_PAGE = {
'index.html': 'launcher',
'work-package-suite.html': 'wizard',
'wp-creation-index.html': 'creator',
'field.html': 'field_view',
'admin.html': 'admin',
'users.html': 'directory'
};
function pingUsage() {
var page = (location.pathname.split('/').pop() || 'index.html');
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
try {
fetch('/api/usage/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: tool })
}).catch(function () {});
} catch (e) {}
}
function proceed(user) { function proceed(user) {
clearTimeout(safety); clearTimeout(safety);
window.WP_USER = user; window.WP_USER = user;
reveal(); reveal();
if (window.WP_USER) { if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed window.wpFlags(); // start the feature-flag fetch; pages await it as needed
pingUsage();
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
} }
} }

View File

@@ -2207,20 +2207,18 @@ function loadStepComments(){
} }
} }
// ── USAGE ANALYTICS ───────────────────────────────────────────────────────── // ── USAGE ANALYTICS (retired, T11.6) ────────────────────────────────────────
// Lightweight usage analytics stored in localStorage so the tool owner can review // This used to write to wp-usage.js's per-browser localStorage log (D5/T7.10),
// engagement over time. No field VALUES are stored (field-edit events record only // read back by the admin console's old "Usage logs" panel. CR-019 replaced
// the field id), keeping captured data non-sensitive. // both with real, server-side, per-user activity (UsageEvent + the Activity &
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report // usage card) - see decisions-2026-09-17.md for why this per-browser data was
// on the admin console. The wizard's own copy of showAnalytics() never had a // never a source the new report could adopt. track() is now a no-op; kept
// caller here - the button lived on the creator - and once B7 dissolved the // (rather than deleting its handful of call sites, including the dwell-timer
// frame the duplicate sat in the same document as five colliding globals. // plumbing below) so this stays a one-line change instead of touching every
// This page only records; dwell tracking keeps its page-local state below. // caller for the same outcome.
let _stepEnter = Date.now(); let _stepEnter = Date.now();
function track(event, detail){ function track(event, detail){ /* retired, T11.6 — see comment above */ }
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
}
function trackStepDwell(){ function trackStepDwell(){
const ms = Date.now() - _stepEnter; const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms}); if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});

View File

@@ -11,7 +11,6 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL <!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. --> during their own boot. -->
<script src="wp-url.js"></script> <script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<script src="wp-list-import.js"></script> <script src="wp-list-import.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). --> <!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script> <script src="wp-autosave.js"></script>

View File

@@ -3993,11 +3993,17 @@ function openSopModal(){
document.getElementById('sop-modal').classList.add('open'); track('view_sop'); document.getElementById('sop-modal').classList.add('open'); track('view_sop');
} }
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); } function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for // CR-019 / T11.6 (2026-09-23): this used to write to wp-usage.js's per-browser
// the whole suite - and its report lives on the admin console, where an // localStorage log (D5/T7.10). Retired along with the admin console's old
// operator-facing readout belongs. This page only records. Same key, same // "Usage logs" panel, its only reader - real, server-side, per-user activity
// event shape: everything recorded before the move is still readable after it. // now exists (UsageEvent, the Activity & usage card, T11.1-T11.5) and this
function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); } // data was never reliably tied to a real identity anyway, so it was not a
// source the new report could adopt (decisions-2026-09-17.md). track() stays
// as a no-op rather than deleting its ~35 call sites throughout this file:
// removing every call individually is a much larger, riskier diff for the
// same outcome, and a call site here still documents the moment worth
// recording if usage analytics are ever rebuilt server-side.
function track(event,detail){ /* retired, T11.6 — see comment above */ }
// ── COMMENTS ───────────────────────────────────────────────────────────────── // ── COMMENTS ─────────────────────────────────────────────────────────────────
const COMMENTS_KEY='wp_iwp_comments_v1'; const COMMENTS_KEY='wp_iwp_comments_v1';

View File

@@ -11,7 +11,6 @@
<!-- Addressable state (S3). Parses before the app scripts, which read the URL <!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. --> during their own boot. -->
<script src="wp-url.js"></script> <script src="wp-url.js"></script>
<script src="wp-usage.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). --> <!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script> <script src="wp-autosave.js"></script>
<!-- Which sections this project uses (CR-006). The same file the SOP wizard <!-- Which sections this project uses (CR-006). The same file the SOP wizard

View File

@@ -1,58 +0,0 @@
/* Usage analytics core — the ONE implementation (D5 / T7.10).
This existed three times: the creator's copy, the wizard's copy (which had no
caller — the button lived on the creator), and the admin console's own reader.
Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
document as five colliding globals; an unreferenced duplicate is exactly what
produced D5. One core now; the pages keep only a thin track() wrapper because
page state (the creator's dev-mode pause) belongs to the page.
The storage KEYS are unchanged on purpose: everything recorded before this
file existed is still readable through it. No field VALUES are ever stored —
a field-edit event records the field id, nothing else.
Classic script, no modules: exposes window.WPUsage. */
'use strict';
(function () {
var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
function load(key) {
try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
catch (e) { return { events: [] }; }
}
function save(key, data) {
try { localStorage.setItem(key, JSON.stringify(data)); }
catch (e) { /* storage unavailable — degrade silently */ }
}
function track(key, event, detail) {
try {
var d = load(key);
d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
if (d.events.length > 5000) d.events = d.events.slice(-5000);
save(key, d);
} catch (e) { /* never let telemetry break the tool it watches */ }
}
function download(key, prefix) {
var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
window.WPUsage = {
load: load,
save: save,
track: track,
download: download,
// The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
};
})();

View File

@@ -27,8 +27,18 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # D18 (2026-09-23): a session slides on activity, capped by a hard ceiling
# AUTH_SESSION_HOURS=12 # underneath - not one flat lifetime. Both defaults are proposed, not
# confirmed against this tenant's actual Okta SSO session policy - if Okta's
# own session outlives either number, re-auth here is likely a fast redirect
# rather than a real login screen, so these cost less than they look like.
#
# No request for this many minutes ends the session outright.
# AUTH_IDLE_MINUTES=30
#
# The absolute ceiling from original sign-in, regardless of activity - no
# session outlives this no matter how continuously active it is. Default 8.
# AUTH_SESSION_HOURS=8
# ── Okta OIDC (required — this is the only sign-in path) ─────────────────────── # ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default # The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default

View File

@@ -0,0 +1,46 @@
"""usage events (CR-019, wave 11)
Append-only navigation/session activity, separate from audit_log on purpose —
see the UsageEvent docstring in server/models.py. Retention is indefinite by
decision (docs/waves/decisions-2026-09-17.md); nothing here schedules a purge.
Revision ID: 8e2cb3003f8a
Revises: 1d60a608bb51
Create Date: 2026-09-23 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8e2cb3003f8a'
down_revision = '1d60a608bb51'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('usage_events',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
sa.Column('username', sa.String(length=200), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=True),
sa.Column('tool', sa.String(length=40), nullable=False),
sa.Column('event', sa.String(length=40), nullable=False),
sa.Column('detail', sa.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_usage_events_at'), 'usage_events', ['at'], unique=False)
op.create_index(op.f('ix_usage_events_username'), 'usage_events', ['username'], unique=False)
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
op.create_index(op.f('ix_usage_events_tool'), 'usage_events', ['tool'], unique=False)
op.create_index(op.f('ix_usage_events_event'), 'usage_events', ['event'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_usage_events_event'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_tool'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_username'), table_name='usage_events')
op.drop_index(op.f('ix_usage_events_at'), table_name='usage_events')
op.drop_table('usage_events')

View File

@@ -9,10 +9,15 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs Interactive docs: http://<host>/api/docs
""" """
import base64 import base64
import csv
import hashlib
import hmac
import io
import logging import logging
import os import os
import re import re
import uuid import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -22,7 +27,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete, func from sqlalchemy import select, delete, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -103,12 +108,23 @@ def _csrf_ok(request: Request) -> bool:
async def auth_gate(request: Request, call_next): async def auth_gate(request: Request, call_next):
path = request.url.path path = request.url.path
method = request.method method = request.method
claims = None
if method != "OPTIONS" and auth._needs_auth(path): if method != "OPTIONS" and auth._needs_auth(path):
if not auth.is_request_authenticated(request): claims = auth.is_request_authenticated(request)
if not claims:
return JSONResponse(status_code=401, content={"detail": "Not authenticated"}) return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request): if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"}) return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
return await call_next(request) response = await call_next(request)
# D18: slide the session forward on activity, capped by its absolute
# ceiling. Reads only the already-validated claims - no DB hit here, and
# the separate is_active/token_version check in get_current_user still
# runs on its own for every request regardless of whether this refreshes.
if claims is not None:
refreshed = auth.maybe_refresh_token(claims)
if refreshed:
auth.set_session_cookie(response, request, refreshed)
return response
def gen_id(prefix: str) -> str: def gen_id(prefix: str) -> str:
@@ -756,6 +772,11 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
user.failed_attempts = 0 user.failed_attempts = 0
user.locked_until = None user.locked_until = None
user.last_login_at = models.utcnow() user.last_login_at = models.utcnow()
# CR-019: one login event per Okta sign-in, written here rather than
# inferred from session creation elsewhere, so there is exactly one
# source of truth for "did this person sign in" - not one per page load
# afterward (T11.2 covers that separately, as page_open events).
db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
db.commit() db.commit()
db.refresh(user) db.refresh(user)
@@ -3374,6 +3395,177 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
return _save_comment(body, db, user) return _save_comment(body, db, user)
# ── CR-019: usage/activity metrics ──────────────────────────────────────────
class UsageIn(BaseModel):
tool: str = ""
project_id: Optional[str] = None
@app.post("/api/usage/ping")
def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""One usage_events row per authenticated page load. Called exactly once,
from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
per page's own script, the same lesson S4's per-page nav already taught
this codebase. `user` comes from the session via get_current_user, never
from anything the client claims - identity here is a server-enforced
fact, matching every other write in this file, not a client-reported one."""
tool = (body.tool or "").strip()[:40]
db.add(models.UsageEvent(
id=gen_id("uev"), username=user.username, project_id=body.project_id,
tool=tool, event="page_open",
))
db.commit()
return {"ok": True}
def _parse_date_q(v: str, end: bool = False) -> Optional[datetime]:
"""Accepts a plain YYYY-MM-DD (what a <input type=date> sends) or a full
ISO datetime. A date-only `to` means "through the end of that day", not
midnight at its start - otherwise a range of "today" would match nothing
from today at all."""
if not v:
return None
try:
d = datetime.fromisoformat(v)
except ValueError:
return None
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
if end and len(v) <= 10: # date-only
d = d + timedelta(days=1) - timedelta(microseconds=1)
return d
def _usage_query(db: Session, caller: "models.User", date_from, date_to, project_id, username, tool):
"""Shared by the summary and export endpoints so the two can never
disagree about which rows a filter set matches - the export is a raw
dump of exactly what the summary counted, not a separately-derived view."""
stmt = select(models.UsageEvent)
managed = managed_project_ids(db, caller)
if managed is not None:
# A project_super_user (never an app admin, who gets managed=None) is
# scoped to events tied to a project they administer, plus their OWN
# suite-wide activity (admin console opens etc. carry no project_id) -
# never another user's activity outside what they manage.
if managed:
stmt = stmt.where(or_(
models.UsageEvent.project_id.in_(managed),
models.UsageEvent.username == caller.username,
))
else:
stmt = stmt.where(models.UsageEvent.username == caller.username)
df = _parse_date_q(date_from)
dt = _parse_date_q(date_to, end=True)
if df:
stmt = stmt.where(models.UsageEvent.at >= df)
if dt:
stmt = stmt.where(models.UsageEvent.at <= dt)
if project_id:
stmt = stmt.where(models.UsageEvent.project_id == project_id)
if username:
stmt = stmt.where(models.UsageEvent.username == username)
if tool:
stmt = stmt.where(models.UsageEvent.tool == tool)
return stmt.order_by(models.UsageEvent.at)
@app.get("/api/usage/summary")
def usage_summary(
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
project_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
tool: Optional[str] = Query(None),
caller: models.User = Depends(require_user_manager),
db: Session = Depends(get_db),
):
"""CR-019. Same gate as the User Directory (require_user_manager): an app
admin or a project_super_user with at least one managed project. Filters
combine. Aggregated in Python over the filtered row set rather than a SQL
GROUP BY - correct and simple at today's scale; if usage_events grows
into the millions (plausible, given retention is indefinite by decision),
the day/week/month bucketing here is the first thing to move server-side
into SQL. Not done now because nothing currently requires it."""
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
by_day: dict[str, set] = {}
by_week: dict[str, set] = {}
by_month: dict[str, set] = {}
per_user_last: dict[str, datetime] = {}
per_tool: dict[str, int] = {}
for e in rows:
by_day.setdefault(e.at.date().isoformat(), set()).add(e.username)
by_week.setdefault(e.at.strftime("%G-W%V"), set()).add(e.username)
by_month.setdefault(e.at.strftime("%Y-%m"), set()).add(e.username)
if e.username not in per_user_last or e.at > per_user_last[e.username]:
per_user_last[e.username] = e.at
per_tool[e.tool] = per_tool.get(e.tool, 0) + 1
return {
"active_users": {
"by_day": {k: len(v) for k, v in sorted(by_day.items())},
"by_week": {k: len(v) for k, v in sorted(by_week.items())},
"by_month": {k: len(v) for k, v in sorted(by_month.items())},
},
"per_user_last_active": {u: models._iso(t) for u, t in sorted(per_user_last.items())},
"by_tool": dict(sorted(per_tool.items(), key=lambda kv: -kv[1])),
"event_count": len(rows),
}
def _pseudonym(username: str) -> str:
"""A stable per-user id for the sanitized export — the SAME input always
produces the SAME output, within one export and across separate export
runs, so an external system (Power BI or similar) can still group and
trend "by user" without ever receiving a real name. HMAC rather than a
plain hash: a plain sha256(username) is trivially reversed against a
wordlist of the handful of usernames this app actually has; keying it
with AUTH_SECRET_KEY (already a real secret, already required in
production — see auth.py) means recovering a username from its
pseudonym requires the signing key, not just guessing."""
digest = hmac.new(auth.SECRET_KEY.encode(), username.encode(), hashlib.sha256).hexdigest()
return "u_" + digest[:16]
@app.get("/api/usage/export")
def usage_export(
date_from: Optional[str] = Query(None, alias="from"),
date_to: Optional[str] = Query(None, alias="to"),
project_id: Optional[str] = Query(None),
username: Optional[str] = Query(None),
tool: Optional[str] = Query(None),
sanitize: bool = Query(False),
caller: models.User = Depends(require_user_manager),
db: Session = Depends(get_db),
):
"""CR-019. Same gate, same filters, same underlying row set as
usage_summary() (_usage_query) — the export can never show a different
slice of data than what the console counted for the same filters.
sanitize=true replaces `username` with a stable pseudonym (_pseudonym)
and — deliberately — the `detail` column is not exported in EITHER mode.
Every event this app writes today (login, page_open) leaves `detail`
empty, so this costs nothing now, but it also means a future event type
that DOES populate `detail` can't accidentally leak a real name into a
sanitized file through a column nobody thought to scrub. If `detail`
is ever needed in the export, it has to be sanitized explicitly, not
assumed safe because the rest of the row was."""
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["at", "username", "project_id", "tool", "event"])
for e in rows:
who = _pseudonym(e.username) if sanitize else e.username
w.writerow([models._iso(e.at), who, e.project_id or "", e.tool, e.event])
filename = "usage_export_%s_%s.csv" % (
"sanitized" if sanitize else "raw", datetime.now(timezone.utc).strftime("%Y%m%d"),
)
return Response(
content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.get("/api/comments") @app.get("/api/comments")
def list_comments( def list_comments(
source: Optional[str] = Query(None), source: Optional[str] = Query(None),

View File

@@ -59,8 +59,26 @@ log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session" COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a session lasts before the person must sign in again. # D18 (2026-09-23): a session now slides on activity, capped by a hard ceiling
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) # underneath - not a single flat lifetime. A pure idle timer with no ceiling
# would let a continuously-active session never force a fresh Okta recheck,
# which is a worse fit for this item's own purpose (catching someone still
# active after being deprovisioned) than a flat expiry would have been. Both
# numbers are proposed defaults, not confirmed against the tenant's actual
# Okta SSO session policy - see docs/waves/decisions-2026-09-17.md.
#
# No request for this long invalidates the session outright.
IDLE_MINUTES = int(os.getenv("AUTH_IDLE_MINUTES", "30"))
# The absolute ceiling from the ORIGINAL sign-in, regardless of activity. Same
# env var name as the old flat-lifetime design; the meaning changed, the name
# didn't, because it still answers "how long can this session possibly live."
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "8"))
# How much later a refreshed exp must be before it's worth rewriting the
# cookie. Without this, an active session gets a new Set-Cookie on literally
# every request - correct, but wasteful, and it makes the cookie a busier
# target than it needs to be. A third of the idle window is a reasonable
# balance: refreshed a few times within any idle window, never every request.
_REFRESH_SLACK = timedelta(minutes=max(1, IDLE_MINUTES // 3))
# ── permissions roles ───────────────────────────────────────────────────────── # ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
@@ -160,6 +178,30 @@ SECRET_KEY = _load_secret()
# ── tokens ────────────────────────────────────────────────────────────────── # ── tokens ──────────────────────────────────────────────────────────────────
def _parse_claim_dt(v) -> Optional[datetime]:
"""`login_at` is a custom claim, so unlike `exp`/`iat` (which PyJWT
special-cases for a datetime -> POSIX-timestamp conversion on encode) it
is stored and read back as a plain numeric timestamp. Returns None for
anything unparseable rather than raising - a malformed or legacy token
should fail closed into "no refresh", not 500."""
if v is None:
return None
try:
return datetime.fromtimestamp(float(v), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
def _next_exp(login_at: datetime, now: datetime) -> datetime:
"""Whichever comes first: another IDLE_MINUTES of quiet from now, or the
absolute SESSION_HOURS ceiling measured from the session's original
sign-in. Shared by create_token and maybe_refresh_token so the two can't
drift apart."""
ceiling = login_at + timedelta(hours=SESSION_HOURS)
idle_edge = now + timedelta(minutes=IDLE_MINUTES)
return min(ceiling, idle_edge)
def create_token(user: "models.User") -> str: def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {
@@ -168,7 +210,48 @@ def create_token(user: "models.User") -> str:
"role": user.role, "role": user.role,
"ver": user.token_version or 0, "ver": user.token_version or 0,
"iat": now, "iat": now,
"exp": now + timedelta(hours=SESSION_HOURS), "login_at": now.timestamp(),
"exp": _next_exp(now, now),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def maybe_refresh_token(claims: dict) -> Optional[str]:
"""Given a validated token's claims, return a reissued token if the
session is worth extending, or None if nothing should change. Called from
`auth_gate` on every authenticated request (D18) - deliberately reads only
the already-validated claims, never the database, so it costs nothing
beyond the JWT encode itself. The separate is_active/token_version check
in get_current_user is unaffected either way.
Three ways this returns None: past the absolute ceiling (session is done,
full re-login required - never extended, not even by a second); the
claims can't be parsed (fails closed into no-refresh rather than guessing);
or a refresh happened recently enough that a new cookie isn't worth
writing yet (_REFRESH_SLACK)."""
now = datetime.now(timezone.utc)
login_at = _parse_claim_dt(claims.get("login_at"))
if login_at is None:
# Pre-D18 token (no login_at claim) - fall back to iat so it still
# gets a real ceiling instead of riding on the old flat exp forever.
login_at = _parse_claim_dt(claims.get("iat"))
if login_at is None:
return None
ceiling = login_at + timedelta(hours=SESSION_HOURS)
if now >= ceiling:
return None
new_exp = _next_exp(login_at, now)
current_exp = _parse_claim_dt(claims.get("exp"))
if current_exp is not None and (new_exp - current_exp) < _REFRESH_SLACK:
return None
payload = {
"sub": claims.get("sub"),
"username": claims.get("username"),
"role": claims.get("role"),
"ver": claims.get("ver", 0),
"iat": now,
"login_at": login_at.timestamp(),
"exp": new_exp,
} }
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG) return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)

View File

@@ -337,6 +337,45 @@ class AuditLog(Base):
} }
class UsageEvent(Base):
"""CR-019: append-only record of who used the suite, when, and which tool —
navigation/session activity, not business mutations. Deliberately a SEPARATE
table from AuditLog rather than a new `action` value there: AuditLog answers
"who changed what" and is read by people auditing a specific record's
history; mixing in a `page_open` row for every authenticated page load
would make that trail noisy for its existing purpose. This table answers a
different question — "who is active, and on what" — and CR-019's admin
console reads from here, not from AuditLog.
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
is later removed should still show up in historical activity rather than
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
`is_active=False` — it never deletes a row — so this is defensive symmetry
rather than a live concern today.
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
nothing purges rows written here; that is a deliberate product decision,
not an oversight to fix later."""
__tablename__ = "usage_events"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
username: Mapped[str] = mapped_column(String(200), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
# creator | wizard | field_view | dashboard | admin | directory | ...
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
# page_open | login
event: Mapped[str] = mapped_column(String(40), default="", index=True)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "username": self.username,
"project_id": self.project_id, "tool": self.tool, "event": self.event,
"detail": self.detail or {},
}
class AppSetting(Base): class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored """Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like as key -> JSON value. Read/written via /api/settings (admin only). Secrets like

View File

@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Is there exactly one analytics implementation, reported from admin? — D5, T7.10.
Usage analytics existed twice (creator + wizard), five of the nine colliding
globals `creator-frame.md` counted, and the wizard's copy had no caller. One
core survives in wp-usage.js; the pages keep thin track() wrappers; the report
and its downloads live on the admin console behind the same role gate as the
rest of that page. Keys are unchanged, so pre-move data still reads.
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.6):
time.sleep(seconds)
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the grep half: one implementation, no leftover controls ───────────
print("\n1. grep: one implementation, nothing unreferenced")
files = {}
for name in os.listdir(HTML):
if name.endswith((".js", ".html")):
files[name] = strip_js(open(os.path.join(HTML, name), encoding="utf-8").read())
core_defs = [n for n, src in files.items() if "window.WPUsage" in src]
chk("the WPUsage core is defined in wp-usage.js and only there",
core_defs == ["wp-usage.js"], ascii_(core_defs))
chk("the pages record THROUGH it - no page touches the storage keys directly",
all("wp_iwp_analytics_v1" not in src and "wp_suite_analytics_v1" not in src
for n, src in files.items()
if n not in ("wp-usage.js",) and n.endswith(".js")))
leftovers = {n: re.findall(r"analyticsLoad|analyticsSave|downloadAnalytics|showAnalytics"
r"|ANALYTICS_KEY|USAGE_KEY|usageLoad|downloadUsage", src)
for n, src in files.items() if n != "wp-usage.js"}
leftovers = {n: v for n, v in leftovers.items() if v}
chk("none of the five colliding globals survives anywhere; grep confirms",
not leftovers, ascii_(leftovers))
chk("no analytics control remains on the creator or the wizard; grep confirms",
"Usage data" not in files["wp-creation-index.html"]
and "showAnalytics" not in files["work-package-suite.html"])
chk("both storage keys survive, verbatim, in the core (data continuity)",
"wp_iwp_analytics_v1" in files["wp-usage.js"]
and "wp_suite_analytics_v1" in files["wp-usage.js"])
tmpdir = tempfile.mkdtemp(prefix="wpsuite-usage-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
# ── 2. recording still works from both tools ─────────────────────────
print("\n2. the tools still record")
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
settle(2.0)
# Plant a LEGACY-format event under the pre-move key: the done-when is
# that data recorded before this task is still readable after it.
page.eval("""(() => {
const d = JSON.parse(localStorage.getItem('wp_iwp_analytics_v1')) || {events: []};
d.events.unshift({ts: '2026-07-01T10:00:00Z', session: 's_legacy',
event: 'legacy_probe_event', detail: null});
localStorage.setItem('wp_iwp_analytics_v1', JSON.stringify(d));
})()""")
n0 = page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length")
page.eval("track('probe_event')")
chk("the creator's track() still lands events under the old key",
page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") == n0 + 1)
page.goto(base + "/work-package-suite.html?tab=sop")
dismiss_dialogs(page)
settle(2.0)
n0 = page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length")
page.eval("track('probe_event_wizard')")
chk("the wizard's track() still lands events under its old key",
page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") == n0 + 1)
wiz_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("...and the wizard page throws no errors without its old globals "
"(the blocked-beforeunload console line is BL-020, filtered not hidden)",
not wiz_errors, ascii_(wiz_errors[:2]))
# ── 3. the report, on admin, behind the admin gate ───────────────────
print("\n3. the admin report")
page.goto(base + "/admin.html")
dismiss_dialogs(page)
settle(2.0)
page.eval("loadUsage()")
settle(0.5)
report = page.eval("(document.getElementById('usage-admin')||{textContent:''}).textContent")
chk("usage data is reachable from admin.html, both tools reported",
"Work package creator" in report and "SOP wizard" in report, ascii_(report, 160))
chk("the pre-move legacy event is readable in the report",
"legacy_probe_event" in report)
chk("this session's fresh events are in it too",
"probe_event" in report and "probe_event_wizard" in report)
chk("each tool offers its download from the report",
page.eval("[...document.querySelectorAll('#usage-admin button')].length") >= 2)
page.viewport(390, 844, mobile=True)
settle(0.6)
fits = page.eval("""(() => {
const box = document.getElementById('usage-admin');
return box && box.scrollWidth <= box.clientWidth + 2
&& document.documentElement.scrollWidth <= 392;
})()""")
chk("the report is usable at 390px - no sideways scrolling", bool(fits))
page.viewport(1440, 900)
settle(0.4)
# The same role gate as the rest of the console: a non-admin sees the
# denied card and no cards, this one included.
page.clear_cookies()
page.set_cookie("wp_session", tok["pat"])
page.goto(base + "/admin.html")
dismiss_dialogs(page)
settle(2.0)
chk("a non-administrator gets the denied notice, not the usage report",
page.eval("""(() => {
const denied = document.getElementById('admin-denied');
const usage = document.getElementById('usage-admin');
const visible = el => !!el && el.offsetParent !== null;
return visible(denied) && !visible(usage);
})()"""))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())