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.
This commit is contained in:
327
docs/waves/decisions-2026-09-17.md
Normal file
327
docs/waves/decisions-2026-09-17.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# 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. **Also shrink `AUTH_SESSION_HOURS`** as defense in depth, independent of the
|
||||||
|
sync job, so the already-live-session window itself is smaller regardless
|
||||||
|
of how fast the sync runs. Proposed default: **2 hours**, down from 12 —
|
||||||
|
flagged as a recommendation, not confirmed: Okta's own SSO session (set on
|
||||||
|
the Okta side, separate from this app's cookie) may make re-authentication
|
||||||
|
silent rather than a real re-login if it outlives this app's shorter
|
||||||
|
session, in which case 2 hours costs little. Confirm against the tenant's
|
||||||
|
actual Okta session policy before treating 2 as final; if Okta's SSO
|
||||||
|
session is itself long-lived, a shorter number here doesn't hurt, either.
|
||||||
|
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`.
|
||||||
223
docs/waves/wave-11.md
Normal file
223
docs/waves/wave-11.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# 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:**
|
||||||
|
|
||||||
|
- [ ] the tab is reachable only by an admin (verify: a non-admin session gets
|
||||||
|
no tab and the route itself refuses the request server-side, not just a
|
||||||
|
hidden tab)
|
||||||
|
- [ ] every filter is a real form control, keyboard-operable
|
||||||
|
- [ ] both export buttons produce the files T11.4 defines
|
||||||
|
- [ ] works at 390px and 1440px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] the old `usage-admin` panel and its markup are gone from `admin.html`/
|
||||||
|
`admin.js`
|
||||||
|
- [ ] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
|
||||||
|
stated reason) — not left ambiguous
|
||||||
|
- [ ] nothing else in the app references the removed code; grep confirms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
193
docs/waves/wave-12.md
Normal 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
|
||||||
200
docs/waves/wave-13.md
Normal file
200
docs/waves/wave-13.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 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: shrink `AUTH_SESSION_HOURS`
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** nothing (first task, independent of the rest)
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/auth.py`, `server/.env.example`, `DEPLOYMENT.md`
|
||||||
|
|
||||||
|
**Do:** Change the default from 12 to 2 hours. Document the change and the
|
||||||
|
reasoning (defense in depth against an already-live session outliving a
|
||||||
|
deprovisioning event) in `server/.env.example` next to `AUTH_SESSION_HOURS`,
|
||||||
|
and in `DEPLOYMENT.md`'s auth section. Note in the PR that this is proposed,
|
||||||
|
not confirmed against the tenant's actual Okta SSO session policy — flag it
|
||||||
|
for Nick/IT rather than treating 2 as unquestionable.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] default is 2 hours; still overridable via env, unchanged mechanism
|
||||||
|
- [ ] `.env.example` and `DEPLOYMENT.md` explain why
|
||||||
|
- [ ] existing session/auth tests updated for the new default where they
|
||||||
|
assumed 12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
|
||||||
Reference in New Issue
Block a user