Test Personas
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…
Ruleprovision
About
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.
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# 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.
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-personas.mdComing soon via CLI:
tawa chaac install tawa-personasDetails
- Format
- Rule
- Category
- provision
- Version
- 1.0.86491
- Tokens
- ~3,212
- Updated
- 2026-10-13
platformpersonastestingauthbio-idwalkthroughplaywrightcli