# Test Personas

## The Short Version

A **persona** is a password-less Bio-ID user in an org you already belong to. You create one
with the CLI and get a logged-in browser session as it — without anybody typing a password,
and without the access token ever reaching your terminal.

```bash
tawa persona create portal-editor --org insureco --name "Portal Editor" --roles editor
tawa persona session portal-editor --service portal --out .tawa/session.json
```

The second command writes a Playwright `storageState` file and prints the path. Hand that path
to Playwright, to the browser tooling, or to `iec-test` — nothing has to read the credential.

```js
const context = await browser.newContext({ storageState: '.tawa/session.json' })
```

This exists because everything that drives a Tawa app as a logged-in user — a
`tawa walkthrough`, a Playwright run, a coding agent checking its own work — needed either a
human at a keyboard or a service token, and a service token carries no user, no org and no
roles (and is correctly refused by any service that pins `aud` to its own client).

## What a persona is

- A **real Bio-ID user row**, flagged `isPersona`, that can never authenticate interactively.
  No password, no login form, no magic link, no reset, no invite. The only way to hold a token
  for one is to have it minted.
- It belongs to **exactly one org**, with the roles and modules its creator chose.
- Its address is synthetic and undeliverable by construction:
  `persona+<slug>@<orgSlug>.personas.invalid` (`.invalid` is reserved by RFC 2606). Relay
  refuses it.
- It is **excluded from user directories, org member lists and member counts** unless a caller
  asks for personas explicitly.
- Slugs are unique per org, and an org may hold **25** of them.
- A persona can never become a normal user, and a normal user can never be flagged a persona.

## The ⊆-own-roles rule

**You can only give a persona roles you hold yourself in that org.** That is the whole
authorization model, and it is what makes "any member may create one" safe: a persona is a
token-shaped hole, so the only question that matters is whether the person making it could have
had those powers themselves.

| | |
|---|---|
| **Who may create one** | Any active member of the org. No special role. |
| **Which roles they may grant** | The roles they hold in that org — or, if they can manage users there, any role the org allows. |
| **Which modules** | The org's own enabled modules, or the creator's. **Even for an org admin** — granting a module the organization was never given would manufacture an entitlement. |
| **Platform roles** (`super_admin`, anything platform-tier) | **Never**, by anyone, including an org admin. There is no branch in which this becomes possible. |

The rule is re-run on `update`, on the values the persona would end up with — otherwise it
would be decorative (make a viewer persona, then widen it).

### The refusals tell you what to do

A refusal is never a bare 403. Every one names exactly what was refused, exactly what **you**
can grant, and how to get the rest:

```
✗ You cannot grant a persona role(s) you do not hold yourself in 'acme': agency_admin.

  Refused role: agency_admin
  Roles you can grant: agent, viewer

  Ask an admin of 'acme' — someone who can manage users there may grant any
  role assignable in the organization. Platform-level roles (super_admin and
  anything else catalogued as platform tier) are never grantable to a persona,
  by anyone.
```

Naming an org you are not in answers the same way, by listing the orgs you *are* in — because
"403" is indistinguishable from a typo in a slug.

With `--json`, the API's structured error is passed through unchanged, so an agent can read
`error.details.grantable` and retry with something it is allowed to ask for:

```jsonc
{ "success": false,
  "error": { "code": "ROLE_ESCALATION",
             "message": "…",
             "details": { "refused": ["agency_admin"],
                          "grantable": ["agent", "viewer"],
                          "howToGetTheRest": "Ask an admin of 'acme' — …" } } }
```

Every refusal exits non-zero.

## Three hours, and no refresh

A minted token lives **3 hours** and there is **no refresh token** — none is issued, and no code
path can produce one. Re-mint freely; do not try to extend one.

This matters because tokens are verified offline against the JWKS, so **a minted token cannot be
revoked before it expires**. Deleting or disabling a persona stops the *next* mint and nothing
more. Three hours is the accepted exposure, and it is only acceptable because nothing can extend
it — in particular, **a persona cannot mint**, for itself or anyone else, or a 3-hour token would
renew itself forever.

The token carries two extra claims so a service *can* react, though it needs to do nothing:

| Claim | |
|---|---|
| `persona: true` | This is a test session. Show a banner, block destructive actions, exclude it from analytics — your choice. |
| `act: { by: … }` | Who minted it. |

Everything else — `sub`, `bioId`, `email`, `orgSlug`, `roles`, `modules`, branding — is exactly
what a real login produces, from the same claim builder. That is the point: **no service changes
anything to accept a persona.**

## Everything targets prod

There is no sandbox in use, so a persona that clicks things **writes real data**. Two
conventions follow:

- **A persona in a real org should have view-level roles.** Give it what it needs to see the
  screens, not to change records.
- **A persona that does things belongs in a dedicated demo org** with seeded records.

Services that care can enforce read-only on the `persona: true` claim.

## The CLI

```bash
tawa persona create <slug> --org <org> --name "Portal Editor" [--roles a,b] [--modules x,y]
tawa persona list [--org <org>]          # default org: the one on your own token
tawa persona show <slug> --org <org>
tawa persona update <slug> --org <org> [--name] [--roles] [--modules] [--disable|--enable]
tawa persona delete <slug> --org <org> --yes
tawa persona session <slug> --org <org> --service <svc> [--env prod] [--out …] [--print]
```

All of them take `--json`. Authentication is your own Bio-ID token from `tawa login` — the same
one the rest of the CLI uses. `--org` defaults to the org on that token.

### Logging in

`tawa login` is an OAuth login through the `tawa-cli` public client (iec-bio#153): it opens
Bio-ID in your browser and finishes on a loopback redirect, PKCE, no secret. On a machine with
no browser — SSH, CI, no TTY — it switches to the **device flow** on its own and says so: open
`https://bio.tawa.pro/oauth/device`, type the code it prints. `--device` forces that; `--password`
is the old email-and-password login, kept as a fallback and deprecated. Explicit credentials
(`--password <value>`, `TAWA_EMAIL`/`TAWA_PASSWORD`, `~/.tawa/credentials`) still select the
password flow, so an unattended runner never waits on a browser.

Only an OAuth login can be exchanged for a service-audience token (`tawa session`, below). A
password login cannot: Bio-ID refuses to exchange it.

`--roles` and `--modules` **replace** on update, they do not merge.

`delete` requires `--yes`. A token already minted for that persona keeps working until it
expires — deleting stops the next mint, not the current one.

## The session hand-off

`tawa persona session` does three things:

1. Asks the builder what a browser needs for that service and environment —
   `GET /services/:service/session-info?env=<env>` → the OAuth **client id**, the **cookie name**
   the service reads, and the **URL/host** the cookie is scoped to. Nothing is guessed.
2. Mints a token with `audience` = that client id, so it passes the service's `aud` check
   exactly as a real login's would.
3. Writes a Playwright `storageState`:

```jsonc
{ "cookies": [ { "name": "portal_access_token",
                 "value": "<the token>",
                 "domain": "portal.tawa.pro",
                 "path": "/", "httpOnly": true, "secure": true,
                 "sameSite": "Lax", "expires": 1726858400 } ],
  "origins": [] }
```

The file is written at **mode 0600**, its directory is added to `.gitignore` if nothing already
covers it, and the command says so. Default path: `.tawa/session.json`.

**The token is never printed.** Not on stdout, not on stderr, not in a log line, not in an error
message, not in `--json`. What you get is the persona, org, service, URL, cookie name, expiry
time and the file path.

`--print` is the exception, for curl. It puts the token **alone** on stdout —
`TOKEN=$(tawa persona session … --print)` — and warns on stderr. Use it knowing the value lands
in your shell history and your scrollback.

## Your own session

A persona is for a test identity. When you want to drive a service **as yourself** — to check a
screen you can already see, or run an admin flow — use your own login:

```bash
tawa session --service iec-split-pay            # → .tawa/session.json
tawa session --service portal --env sandbox --out .tawa/portal.json
```

It writes the same Playwright `storageState` as `tawa persona session` (mode 0600, directory
gitignored, the token never printed), but the cookie holds **your** token, exchanged at Bio-ID for
one whose audience is the service's own OAuth client (RFC 8693 token exchange, public client
`tawa-cli`, no secret — iec-bio#155). It lasts **24 hours**, capped at your login's own expiry,
and carries `act.sub: tawa-cli` so a service can tell it came through the CLI.

Bio-ID authorizes on the person, not on a grant table: you must be an **active member of the org
that owns the service**, or hold `super_admin` on your membership in the platform org
(`super_admin` is an org-membership role, not a user attribute). Otherwise:

```
You are not a member of `<org>`, which owns `<svc>`. Ask an admin of that org, or use a persona.
```

Requires an OAuth login. A `--password` login stores a token Bio-ID will not exchange:
`Your login predates OAuth login. Run \`tawa login\` again.`

| | `tawa persona session` | `tawa session` |
|---|---|---|
| Session is | a test persona | you |
| Lifetime | 3 hours | 24 hours |
| Roles | the persona's | yours in that org |
| Can write | yes | yes, as you — everything targets prod |
| Needs | membership in the persona's org | membership in the service's owning org, or platform super_admin |

`--print` puts the token alone on stdout (for curl) and warns on stderr; `--json` reports the file
path, never the token. Like a persona token, an exchanged token cannot be revoked before it
expires, so prefer the session file and re-run rather than sharing one.

## Walkthroughs

A walkthrough spec names an auth **profile**, never a token. `iec-test` resolves a persona
profile by name:

```jsonc
"portal-editor": { "kind": "persona", "org": "insureco", "persona": "portal-editor" }
```

```yaml
# the spec, unchanged in shape
auth: portal-editor
steps:
  - path: /collections
    expect: { h1: "Collections", "table tbody tr": "ACME" }
```

The audience and cookie name are derived from the service under test, so the same profile works
in every environment and the spec stays secret-free. This is the successor to the `staff`
profile for services that have moved; `staff` still works for those that have not.

> Deploy-time runs use `iec-test`'s own client, which mints with the `personas:mint` scope
> rather than a person's token. A platform admin grants that scope to a client; a service token
> can **mint** but can never create, change or delete a persona.

## What NOT to do

```bash
# ❌ mint a token per request — it lasts three hours
tawa persona session editor --service portal   # …in a loop

# ❌ --print into a file you then commit
tawa persona session editor --service portal --print > token.txt

# ❌ give a persona in a real org write-level roles and let it click things
tawa persona create tester --org acme --roles agency_admin

# ❌ expect deleting a persona to kill a live session
#    It stops the NEXT mint. The current token runs out its three hours.

# ✅ one session, reused, with view-level roles
tawa persona create portal-viewer --org insureco --name "Portal Viewer" --roles viewer
tawa persona session portal-viewer --service portal
#    → .tawa/session.json, 0600, gitignored, no token on screen
```

- Don't put a token in a walkthrough spec — `auth` is a profile **name**.
- Don't hand-build the persona address to email it. It is undeliverable on purpose and relay
  refuses it.
- Don't try to promote a persona to a real user, or flag a real user as one. Both are refused.

## See also

- [Walkthroughs](walkthroughs.md) — the asserted screenshot flows a persona session drives.
- [Authentication](auth.md) — how a normal login produces the same claims.
- [Passport & Branding](passport-branding.md) — what else rides in the token.
