# Proposal: fold the `with` app's view-as hardening back into the BW Auth standard

**From:** the `with` app (with.bowden.works) build. **To:** the BW Auth /
id-auth standard work (`/srv/system/id-auth/app-auth/`). **Date:** 2026-07-08.
**Author:** rian's build session. **Status:** reviewed (adversarial pass +
the reviewer's own break-test), all defects closed.

## Why this exists

`with` built its own app-layer view-as in M3 (before `bw_view_as.py` shipped
2026-07-10). Evaluating whether to standardize (`with` ADR #011) surfaced that
the two are mechanically identical, but **each side has guards the other
lacks**. Rian owns the standard and wants the union — so every BW app gets the
"best of both," and `with` can then adopt the standard **losslessly** by
selecting `mode='act'`.

The proposed merged drop-in is here: **[`bw_view_as_v2.py`](bw_view_as_v2.py)**
(stdlib-only, framework-agnostic, backward-compatible; passes an extended
self-check that encodes every fix below).

## What the standard already has (keep — rian's three "must retain")

All three were already present in v1 and are retained verbatim:

- **Hub oversight signal** — `bw_auth.report_impersonation(real, target, mode,
  active)`; the wiring recipe stays.
- **Full act-as option** — `mode='act'` (vs the `mode='readonly'` default) +
  `can_write(session)`.
- **`rank_of` no-escalation guard** — refuses a target of equal-or-higher rank.

## What `with` adds to the standard (the contribution)

Six hardening features, all **additive and optional** (new keyword args / new
functions), so no existing adopter breaks:

| # | feature | what it does | why (proven in `with`) |
|---|---------|--------------|------------------------|
| 1 | **Typed errors** | `ViewAsError.code` (`NOT_SIGNED_IN`/`BAD_TARGET`/`FORBIDDEN`/`TARGET_NOT_FOUND`/`ESCALATION`/`NESTED`) alongside the message | v1 raises a bare string; SPA apps must string-match. `with` returns structured `{error_code, summary, detail}` its React client maps to UX. |
| 2 | **Nesting guard** | `start()` refuses starting while already impersonating unless `allow_nested=True`; a re-point emits a `stop` audit for the abandoned target so the trail stays complete | v1 silently overwrites, leaving a confusing stacked state + a gap in the audit trail. `with` returns 409 `VIEW_AS_NESTED`. Default-refuse is the safer standard. |
| 3 | **Audit hook** | `start(..., audit=fn)` / `stop(..., audit=fn)` call `fn(action, real, target, mode)`; **audit is best-effort and can never block a stop** (the security clear happens first, on both the user-exit and the `verify()` auto-stop paths); identities are normalized so enter/exit rows always agree | v1 leaves audit to each app; `with` writes explicit both-identity rows. Promote to one standard callback — but a failing sink must never leave a user stuck impersonating (fixed here). |
| 4 | **Continuous re-auth** | `verify(session, *, can_view_as, rank_of=None, target_valid=None)` to call **per-request** in the actor resolver; **fail-closed** auto-stops if no longer allowed, never raises, never blockable | **The most security-meaningful gap.** v1 authorizes at `start()` then trusts the session flag for the session's life. `with`'s `resolve_actor` re-loads the real user and re-checks super-admin on **every request**. `verify()` standardizes that; a mid-session privilege revocation drops impersonation on the next request. |
| 5 | **Target-validity hook** | optional `target_valid(target) -> bool`, honored by **both** `start()` and `verify()`; restores a distinct `TARGET_NOT_FOUND` and is what lets `verify()` catch a **deleted target** every request | `with`'s `resolve_actor` re-loads the target row and drops to the real user when it's `None`. Without a validity signal the drop-in (no DB access) can't catch a target deleted mid-session — see the **CONTRACT** warning below. |
| 6 | **SPA identity contract** | `me_fields(session, *, label=None)` → the standard `/api/me` block | v1 ships server-rendered helpers; SPA apps copy `example-react` by hand. `with` (FastAPI+React SPA) proves the shape: `{impersonating, viewing_as, viewing_as_label, real_user, can_write, mode}`. |

## ⚠ CONTRACT warning (the one thing that must land with this)

`can_view_as(real, target)` is the authority for **both** `start()` and
`verify()`. It must return True **only when `real` is *currently* allowed to
view as `target`** — which, for a target-existence-*independent* policy like the
standard's own canonical Example A (`return role_of(real) == "admin"`),
**silently keeps a "ghost" impersonation if the target is deleted mid-session.**
The drop-in is stdlib-only and cannot check existence itself. Two acceptable
closes, at least one is **required**:

- pass `target_valid=` (the app's existence check) to `start()` **and**
  `verify()` — recommended for existence-independent policies; **or**
- make `can_view_as` existence-aware (e.g. `role_of(real)=='admin' and target in
  {u['username'] for u in viewable_targets(real)}`).

**Action for the standard:** fix `VIEW-AS.md` Example A to one of these when
landing v2 — as written it is a footgun. Relationship-scoped hooks
("target in real's clients") already fail closed and need neither.

## Backward compatibility

- Every v1 call site is unchanged; `ViewAsError('msg')` still works (`code`
  defaults to `'FORBIDDEN'`), `str(e)` unchanged.
- **One deliberate behavior change:** `start()` refuses a nested start by default
  (`allow_nested=False`). Note this is **not** purely theoretical — the shipped
  `example-flask/app.py` renders the picker to an admin *even while
  impersonating*, so a human can initiate a nested start from the shipped UI;
  under v2 that returns `NESTED` instead of silently overwriting. Update the
  example to stop-first (or pass `allow_nested=True`). Flag in the changelog.

## How `with` adopts the merged standard — losslessly

Selecting `mode='act'` means **zero behavior change** to how view-as works in
`with` today, while aligning to the standard:

1. `start(session, target, can_view_as=…, rank_of=…, target_valid=…,
   mode='act', audit=…)` — full act-as preserved (ADR #011 / judgment #7).
2. The hooks:
   - `can_view_as = lambda real, target: real_is_super_admin` (policy).
   - `target_valid = lambda target: <User row exists>` (existence) — together
     these reproduce `resolve_actor`'s "SA + target loaded" check exactly, and
     make `verify()` faithful.
   - `viewable_targets` = the candidate list `/api/me` already builds.
   - `rank_of` = super-admin high → **new** guard (SA→SA refused). Accept the
     behavior change: `with` today *allows* viewing another super-admin with
     SA capabilities stripped; v2 refuses it. (One super admin today, so moot
     in practice — but note it.)
3. Pass `with`'s audit-row writer as `audit=` → keeps both-identity audit.
4. Call `verify(...)` in `resolve_actor` → keeps per-request re-verification,
   now standardized, and now provably unblockable + fail-closed.
5. Map `ViewAsError.code` → `with`'s `{error_code, summary, detail}` → keeps
   SPA-friendly errors (incl. the distinct `TARGET_NOT_FOUND`).
6. Wire `report_impersonation` on start/stop + the `/api/me` heartbeat →
   **gains** hub oversight (`with` reports nothing today).

**Dependency:** step 6 needs `with`'s `bw_auth.py` refreshed to the version
carrying `report_impersonation` / `report_presence` (our copy predates the
2026-07-10 kit — drift already tracked in `with` BUILD-STATE). Adopting
view-as v2 and refreshing `bw_auth.py` naturally pair.

## Review status

An adversarial review found 1 HIGH (the ghost-target regression) + 5 lesser
defects; all are closed in this file and re-checked against the reviewer's own
break-test:

- **A** (audit identity enter≠exit) → fixed (normalized `_norm`), PASS.
- **B** (exit blocked by failing audit) → fixed (clear-before-audit), PASS.
- **C** (verify keeps deleted target) → fixed via `target_valid`; the CONTRACT
  warning + the `with` adoption path make it mandatory. (The reviewer's test
  predates the hook, so it still shows the ghost when `target_valid` is
  omitted — which is exactly the footgun the CONTRACT section closes.)
- **D** (verify auto-stop blocked by failing audit) → fixed (unblockable), PASS.
- **E** (raising `can_view_as` at verify leaves flag) → fixed (fail-closed).
- **F** (nested re-point loses stop audit) → fixed (re-point emits the stop row).

## Suggested rollout for the standard

1. Land `bw_view_as_v2.py` as the new `bw_view_as.py` (owner-governed change).
2. Update `VIEW-AS.md`: **fix Example A** (the ghost footgun), document the six
   additions + the `me_fields` SPA contract, note the nesting-guard default,
   add a changelog line.
3. Update `example-react` to call `verify()` per request + return `me_fields()`
   from `/api/me`; update `example-flask` picker to not offer a nested start (or
   `allow_nested=True`).
4. Existing adopters re-copy on next touch (single-source discipline). No forced
   migration — additions are opt-in except the nesting-guard default.

## Not in scope here

`with` keeps its own view-as for R1 (ADR #011). This proposal is the *standard's*
improvement so that **when** `with` (or any app) adopts it, the standard is
already a superset. Whether `with` migrates onto v2 is a separate, later call —
natural fit is R5, when relationship-scoped view-as (portals,
managers-view-their-clients) makes the two-hook shape earn its place.
