# BW Auth → Scout: per-project access levels & in-app user management

**Handoff from the id-auth build session, 2026-08-18.** Everything on the BW-Auth side is
built, deployed to the gateway, and verified live against Scout's real app-client. This is
what exists, what changed in your tree, and what's left for you to build.

Kit docs (read these): `/srv/system/id-auth/app-auth/ACCOUNTS.md` (the model),
`README.md` (index), `PATTERN-B.md` (protocol). This file is the Scout-specific delta.

---

## 1. What changed in your tree (already done)

| File | Change |
|---|---|
| `main/app/bw_auth.py` | **Updated** to the current kit version. Was a pure subset (no local edits) — now adds `report_access`, `report_instances`, `invite_user`, `send_reset`, `user_access`, `profile_html`. |
| `main/app/bw_accounts.py` | **New.** The standard account-management kit (levels/permissions/instances/guards/admin UI). |

Nothing else in Scout was touched. Your `Account` / `Project` / `ProjectMember` models,
routers and services are untouched.

## 2. The gap you're filling

Scout today: a **global** 2-value role (`Role.ADMIN` / `Role.USER`) on `app_accounts`, and
`project_members` = membership with **no per-project level**. The target model:

- **levels** — named, app-defined, each with a permission set (not a hardcoded enum)
- **per-project levels** — the same user can be `admin` on project A and `reviewer` on B
- **assignability** — a level declares which levels its holders may hand out (this is what
  stops an admin minting another admin)
- **super admin** — `rian`, immutable, holds everything, is not a row

## 3. The storage decision (important — this was built FOR you)

`bw_accounts.py` originally hardcoded SQLite. Scout is Postgres/SQLAlchemy, so the kit is
now **storage-pluggable**: the permission model, guards, admin UI and central reporting are
the standard; *where rows live* is yours.

```python
import bw_accounts as bwa
bwa.init(owner="rian", has_instances=True, store=ScoutAccountsStore(SessionLocal))
```

Implement ~16 small methods over your existing tables (full contract in the `bw_accounts.py`
docstring; a working reference implementation is the `DictStore` in its self-check —
`python3 main/app/bw_accounts.py` runs it):

```
levels:    get_level(name) list_levels() add_level(name,perms,assignable)
           update_level(...) delete_level(name) count_level_usage(name)
members:   get_member(username) -> {username, level, all_instances, grants:{iid:level}}
           list_members() add_member(username,level,all_instances,added_by)
           remove_member(username) set_member_level(username,level)
           set_all_instances(username,value)
instances: list_instances() -> [{id,label}]  has_instance(iid)
           add_instance(iid,label,created_by)  set_grant(username,iid,level)
           remove_grant(username,iid)
```

Mapping suggestion (yours to decide):
- `levels` → a new `app_levels` table (name PK, permissions JSONB, assignable JSONB)
- `members` → your existing `app_accounts` (add `level` + `all_instances`; `Role` becomes a
  seeded level name, so existing rows migrate as `admin`/`user`)
- `instances` → your existing `projects` (id → `str(project.id)`, label → name)
- `grants` → your existing `project_members` + a new `level` column ← **the actual gap**

Usernames arrive already normalized (lowercased/stripped). No method does authorization —
the kit already did it before calling you.

## 4. Semantics you get for free

- `can(actor, perm)` — owner always True; else the actor's level's permission set.
- `effective_level(username, instance_id)` — **resolution order**: per-instance grant →
  else the app-wide level *if* `all_instances` → else `None` (no access to that project).
- `assignable_by(actor)` / assignability enforced in every mutation.
- Owner immutability: no mutation can modify/demote/remove `rian`.
- No self-escalation: a holder of `levels.edit_permissions` cannot edit **their own** level.
- Standard permission vocabulary (11 names, `accounts.*` / `levels.*` / `instances.*`) —
  you may add Scout-specific permission strings freely alongside them.
- Templates: `adopt_template(actor, "admin"|"manager"|"team"|"collaborator")`.

## 5. The admin UI — optional for you

`bwa.admin_page_html(actor, action_url=..., csrf_token=...)` +
`bwa.handle_admin_action(actor, form, bw_auth=bw_auth, expected_csrf=...)` give a working
server-rendered admin screen in two routes. **Scout is a React SPA**, so you'll more likely
want your own screens — that's fine and expected. Use the kit for the *model* (`can`,
`effective_level`, the guarded mutations) and expose your own JSON endpoints; the mutations
already enforce permissions, so you can pass request input straight in and surface
`AccountsError.code` (`FORBIDDEN` / `NOT_ASSIGNABLE` / `OWNER_IMMUTABLE` /
`NO_SUCH_MEMBER` / `NO_SUCH_INSTANCE` / `EXISTS` / `BAD_INPUT` / `ESCALATION`) to the UI.

If you *do* use the HTML page: CSRF is required — pass a per-session token both ways.

## 6. Central reporting (what makes it show up in BW Auth)

Call after every access change (`handle_admin_action` does it automatically if you use it):

```python
bw_auth.report_instances([{"id": "12", "label": "Acme redesign"}, ...])   # full catalog, replaces
bw_auth.report_access(username, "admin", instances=[                      # authoritative per call
    {"id": "12", "label": "Acme redesign", "level": "admin"},
    {"id": "13", "label": "Other",         "level": "reviewer"}])
bw_auth.report_access(username, None)      # user removed from Scout
```

Display-only — it grants nothing anywhere; Scout stays the authority. It's what populates
`auth.bowden.works/access` (each user's own view) and the owner's
`auth.bowden.works/admin/user?u=<name>` page. **Verified live end-to-end today** with
Scout's real client: two demo projects with different per-project levels round-tripped
correctly into the central view, then were cleaned up.

`bwa.sync_reports(bw_auth)` pushes the whole current state (catalog + every member) in one
call — good for a backfill after your migration.

## 7. Invites & resets (optional, if you want them in-app)

```python
bw_auth.invite_user(username, email, first, last)   # creates the BW account if new
bw_auth.send_reset(username)                        # emails a reset link
bw_auth.user_access(username)                       # their access across ALL BW apps
```

Guardrails you should know about:
- A user Scout invites is **always** class `external` (an app can never mint staff).
- Scout **never sees the token/link** — the gateway emails it directly (48h, single-use).
- An existing BW account that already has a password gets **no email**; the response has
  `existing_with_password: True` → just add them to Scout.
- `send_reset` / `user_access` only work for a user **who has actually signed into Scout**
  (unforgeable OAuth-completion signal — an app can't self-assert users). Your two existing
  reported users (`adi`, `rian`) were **already backfilled today** via a new owner-only
  command, so they work now. If a user ever hits `NOT_YOUR_USER`, either they sign in once,
  or rian runs:
  `srv-gw app-link-user --client-id scout --user <name>` (or `--from-reports`).

## 8. Suggested build order

1. Alembic migration: `app_levels` table; `level` + `all_instances` on `app_accounts`;
   **`level` on `project_members`** (the core gap). Seed levels from the current `Role`
   values so nothing breaks.
2. `ScoutAccountsStore` implementing the 16 methods over those tables.
3. `bwa.init(owner="rian", has_instances=True, store=...)` at startup.
4. Swap authz reads to `bwa.can(...)` / `bwa.effective_level(user, str(project_id))`.
5. React screens for people + per-project levels, calling your own endpoints that wrap the
   kit's guarded mutations.
6. Replace `bw.report_role(...)` with `bw_auth.report_access(...)` (+ `report_instances`
   when projects change), and run `bwa.sync_reports(bw_auth)` once after the migration.

## 9. Open question for rian (not blocking)

The central console currently shows app/instance access **read-only** — apps own their
assignments (decision D4). If you'd rather rian could change a Scout project level from
`auth.bowden.works`, that needs a new write-back API and a decision to cross that line.
Flag it if you want it.
