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.

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.

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 oneAny active member of the org. No special role.
Which roles they may grantThe roles they hold in that org — or, if they can manage users there, any role the org allows.
Which modulesThe 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:

{ "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: trueThis 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

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:
{ "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:

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 sessiontawa session
Session isa test personayou
Lifetime3 hours24 hours
Rolesthe persona'syours in that org
Can writeyesyes, as you — everything targets prod
Needsmembership in the persona's orgmembership 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:

"portal-editor": { "kind": "persona", "org": "insureco", "persona": "portal-editor" }
# 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

# ❌ 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

Last updated: October 6, 2026