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.
10 KiB
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_gatemiddleware),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_atto the JWT payload increate_token()— the original sign-in time, distinct fromiat, which becomes "when THIS token was issued" once tokens start getting reissued.login_atnever changes across reissues; it's what the absolute ceiling is measured from. - Add
AUTH_IDLE_MINUTES(default 30).AUTH_SESSION_HOURSstays the name for the absolute ceiling, default changing from 12 to a proposed 8 — update its docstring/comment inauth.pyand.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: ifnow < login_at + AUTH_SESSION_HOURS(the ceiling hasn't passed), computenew_exp = min(now + AUTH_IDLE_MINUTES, login_at + AUTH_SESSION_HOURS). Ifnew_expis meaningfully later than the current token'sexp(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 forwardsub/username/role/ver/login_atunchanged, 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-requestis_active/token_versioncheck 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_HOURSceiling 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_atclaim (simulating a session issued before this shipped) still gets a hard ceiling, via theiatfallback .env.exampleandDEPLOYMENT.mdexplain 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 extendserver/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 whereverlog_eventlives)
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 thebackupcontainer'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
D18acceptance criteria indecisions-2026-09-17.mdare 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.pyboth still pass - full test suite passes
Wave 13 exit criteria
AUTH_SESSION_HOURSdefaults 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
D18fully accounted for, no open acceptance criteria