---
type: plan
milestone: M2 — per-project access levels
---

# M2 — per-project access levels (BW Auth accounts kit)

Replaces Scout's app-wide two-value role with the standard BW-Auth model: named
levels carrying permission sets, assigned **per project**. The kit
(`app/bw_accounts.py`) supplies the model, guards and central reporting; Scout
supplies storage over its existing tables and its own React screens.

Source material: `notes/bw-auth-accounts-handoff.md`,
`/srv/system/id-auth/app-auth/ACCOUNTS.md`.

## Locked decisions (D17–D24)

**D17 — Four tiers, three of them rows.** `rian` is the **owner**: synthesized by
the kit, immutable, holds everything, and is deliberately not a level row.
Seeded levels:

| Level | all_instances | App-wide permissions | Per-project meaning | Can assign |
|---|---|---|---|---|
| `admin` | true | accounts.*, instances.*, `scout.projects.create`, `scout.project.manage`, `scout.results.view`, `scout.review` | manages everything | `lead`, `reviewer` |
| `lead` | false | `scout.review`, `scout.results.view` | reviews **and** sees the rollup | — |
| `reviewer` | false | `scout.review` | reviews only | — |

`admin` deliberately does **not** hold `levels.create` / `levels.edit_permissions`
— only the owner may rewrite the permission model — and cannot assign `admin`, so
an admin can never mint another admin. Both are kit guards, not Scout code.

**D18 — Two different questions, two different calls.** `bwa.can(user, perm)` reads
the **app-wide** level; `bwa.effective_level(user, str(project_id))` reads the
**per-project** level (per-project grant → app-wide level if `all_instances` →
else `None`). Scout adds one helper, `project_can(user, project_id, perm)`, which
resolves the per-project level and checks that level's permission set. Getting
these two confused is the whole failure mode of this milestone: "can create
projects" is app-wide, "can see this project's results" is per-project.

**D19 — The owner is special-cased in every helper.** `member("rian")` synthesizes
level `"super admin"`, for which `level_def()` returns `None`. Any code that goes
level → permissions must short-circuit on `bwa.is_owner()` first, or the owner
silently loses every per-project permission.

**D20 — Storage maps onto existing tables; only levels are new.**

| Kit concept | Scout storage |
|---|---|
| levels | **new** `app_levels` (name PK, permissions JSONB, assignable JSONB) |
| members | existing `app_accounts` + **new** `level`, `all_instances` |
| instances | existing `projects` (id = `str(project.id)`, label = name) |
| grants | existing `project_members` + **new** `level` |

**D21 — `role` is dropped, not shadowed.** One source of truth: `app_accounts.level`.
Keeping both would guarantee they diverge. The API renames `role` → `level`
throughout; the generated TypeScript client turns every missed frontend usage into
a build error, which is the migration's safety net.

**D22 — Deactivated means invisible to the model.** The store's `get_member`
returns `None` for an inactive account, so a deactivated person is a non-member:
`can()` false, `effective_level()` None, everywhere, immediately. `list_members`
returns only active accounts so `sync_reports` never re-publishes access for
someone who was switched off; deactivation instead reports `report_access(user,
None)` explicitly.

**D23 — Projects are created by Scout, never by the kit.** `bwa.create_instance`
is never called; the store's `add_instance` raises, because reaching it means
something is wired wrong. Project rows appear through Scout's own create flow and
the store simply reads them as instances.

**D24 — Central stays read-only (rian's call, 2026-08-18).** Scout remains the sole
authority for its assignments; `report_access` / `report_instances` are display
only. No inbound write-back endpoint. Relay this answer to the auth side.

## Build order

1. **Migration `0003_per_project_levels`** — `app_levels`; `level` + `all_instances`
   on `app_accounts`; `level` on `project_members`; seed the three levels; map
   `role='admin'` → `level='admin', all_instances=true`, `role='user'` →
   `level='reviewer'`; existing memberships → `reviewer`; drop `role`.
2. **`app/services/accounts_store.py`** — `ScoutAccountsStore`, the ~16 methods.
3. **`app/services/levels.py`** — `init_accounts_kit()` at startup, `project_can`,
   `app_can`, and the reporting wrappers.
4. **`app/services/authz.py`** — dependencies resolve through the kit.
5. **Routers** — rename role→level; add `PATCH /api/projects/{id}/members/{username}`
   for the per-project level; gate results on `scout.results.view` per project.
6. **Reporting** — `report_role` → `report_access` + `report_instances`; one-shot
   `sync_reports` backfill after the migration.
7. **React** — People (app-wide level + all-projects toggle), project members
   (per-project level), Results gated by level.
8. **Tests** — the new authz asymmetries, per-project resolution order, the guards.

## Verification bar

Through the deployed app, not the service layer alone:

- Resolution order: a per-project grant beats the app-wide level; no grant and no
  `all_instances` → no access (404).
- A `lead` reaches `/api/projects/{id}/results`; a `reviewer` on the same project
  gets 403; a non-member gets 404.
- An `admin` cannot assign `admin` (`NOT_ASSIGNABLE`), and cannot modify `rian`
  (`OWNER_IMMUTABLE`).
- The owner retains every per-project permission despite having no level row.
- Deactivating removes access on the next request and clears the central report.
- Existing data survives: rian/adi admin, darren reviewer on League Law, the
  matrix from the test seed intact.
