API key expiry sweep and reminders

Task 9371 · Done · Coder · created · updated

Part of epic 9326 (Jaah API keys). Child C3 of the Architect's decomposition: hourly sweep, expiry reminders, expiry audit, disabled-owner backstop and the in-app inbox kind. Depends on the backend child (the `api_keys` columns, `api_keys.py` rules module) and the choke-point child (`user_notify.deliver`). Adds NO migration: the reminder/expiry columns already exist. ## Original requirement (verbatim) > Implement Jaah API keys workflow. > - Permissions: same as we have internally: subject:action. Access to object is limited to the owner of the key. Should be very easy to configure on the UI. By default all is max, but user can remove some > - API Keys can only be created by at least Org Admin > - Keys have expiration > Do a web research and suggest the most modern approach to this for this type of systems ## Requirements this child delivers (PRD, verbatim) - **R13 Expiry reminders** to the owner 7 days and 1 day before expiry (none for "No expiration" keys). **In-app and email mandatory, plus every other channel the user has configured.** Sent through the **single standard notification choke point** with an **"important" flag that forces the email channel**; the notifier does not know or choose the user's channels. - **R12** (expire part): audit event for expire. - **R3** (backstop): Removed from org / user deleted / disabled → all their keys permanently revoked. ## Approach (Architecture §8) - Register `api-keys-hourly` in `apps/api/app/cron_system_activities.py` with `_system(API_KEYS_HOURLY, …, interval=3600)` (pattern `:881-915`) → `api_keys.sweep(now)`. Hourly, not daily, so a key created with < 1 day left still gets its reminder within the hour. - `sweep(now)`: 1. Each live key with `expires_at − now ≤ 7d` and no `reminded_7d_at`, or `≤ 1d` and no `reminded_1d_at` (a 1-day send also stamps 7d), calls `user_notify.deliver(session, owner, event, event_name="api_key_expiry", important=True)` — no channel named. The event carries the key name, display id, expiry and the console link `/settings/api-keys/<id>`. 2. Each key past `expires_at` with no `expiry_recorded_at` gets an `api_key_expired` audit row and the stamp. 3. Each unrevoked key whose owner is disabled is revoked with `owner_disabled` (+ audit), via the backend's `revoke_for_owner`. Keys with `expires_at` NULL never trigger a reminder. Idempotent across runs. - **Inbox kind `api_key_expiring`** in `apps/api/app/routes/inbox.py` (derived, no notifications table, G6): from `api_keys` where `owner_id = caller`, a reminder stamp later than `reminder_seen_at`, and not revoked. `seen` stamps `reminder_seen_at`. - **Web**: `MentionsBell.tsx` renders the server's sentence and links to `/settings/api-keys/<id>`. The sentence must read like a person wrote it, e.g. "API key ‘ci-deploy’ expires in 1 day (Oct 30)" — name, relative + absolute expiry — never ids or raw timestamps. Tests: API tests for 7d/1d windows, double-run idempotence, no reminder for never-expiring keys, `important=True` passed and no channel named, expiry audit row once, disabled-owner backstop, inbox derivation + `seen`; a web unit test for the MentionsBell `api_key_expiring` row (text + link). Credentials & reach: - AC9 on dev | `me/jaah/debug/main/operator-login` on `https://app.dev.jaah.ai` (control plane `dev.jaah.ai`) | **verified**: `credential_get` succeeded; login probe answered 200 (role owner). - AC14 email | The `notification_sent` row on dev `/audit` + the choke-point child's API test | **verified** for the delivery record. **Inbox arrival deferred**: no mailbox credential on the roster (D-A5). - AC14 other channels (e.g. Slack) | The same `notification_sent` row (per-channel outcome) + API test | **deferred** for arrival (D-A5): no credential to read a person's Slack/Telegram DMs. Out of scope: Do NOT add a migration or a notifications table. Do NOT change `user_notify` channel logic (choke-point child) or the auth path (backend child). Do NOT migrate existing notifiers (`alert_record.py`, `governance/notify.py`, `comment_notify.py`, `task_notify.py`) — deferred. No org-level max-lifetime policy. No per-key rate limits. Done means: - AC14 Expiry reminders — Key expiring in ≤7 days (and ≤1 day) → owner receives in-app notification AND email, plus any other channel they configured (e.g. Slack if linked). Notifier code calls the one choke point with `important=True` and names no channel (code review check). Keys with "No expiration" → no reminder. Oracle on dev (D-A5): the in-app bell shows a readable reminder (key name + expiry) linking to the key; `/audit` shows the `notification_sent` row with `important` and email `sent` (or `skipped: no_email_config` if dev has no sender); API tests prove the rest. - AC9 (sweep half) — after a key expires, `/audit` shows one `api_key_expired` row for it. - Disabled owner backstop: a key whose owner is disabled outside the users route is revoked (`owner_disabled`) by the next sweep (test). - New API tests and the MentionsBell web test green; `./dc check quick` green.

Outcome

Built the hourly api-keys-hourly sweep: 7d/1d reminders through user_notify (important, no channel), one api_key_expired row per key, disabled-owner revoke, bell kind api_key_expiring. Quick gate green; review lanes pass; #6739 merged, deploy green. Visual check done: the bell row reads cleanly on desktop and phone. Assumed a data-only cron seed revision. Memory: used mem-24480, mem-15203; proposed 0.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)