Identity
Who is talking to the station? A room starts anonymous. It can then be identified by data the person gives, then authenticated when a provider proves that this data really is theirs. Permissions and policies read the result; they do not know how it was obtained.
Declared, verified
An identity datum carries a name the station chooses — phone, email, customer_id… —
and a value. It has one of two levels.
| declared — the room is identified | verified — the person is authenticated | |
|---|---|---|
| comes from | a form field marked identifies: true: the person typed it | a provider that proved it: a code received, an SSO login, the tenant's login |
| proves | nothing about the person | that they control this number, this e-mail, this account |
| holds for | this room only | the person: the room belongs to them |
| gives | the datum is not asked again, and goes to the tenant with the submit | in addition: their other rooms, their history, and submitted_by: owner |
| finds other rooms | never | yes, for them alone |
A typed number never finds anything: anyone can type someone else's number, and that must show neither their orders nor their address. Finding it from one room to another requires the same verified datum.
Identifying a room: identifies: true
A form field identifies the room; the datum carries the name of the field.
skills:
- name: takeDeliveryOption
select:
guards:
- form:
phone: {type: phone, required: true, identifies: true}
address: {type: text}
The room keeps the number at the declared level: the room's next forms no longer ask for it — Core answers it —, and it goes out with the submit. The model knows the room has a phone, never which one. Two fields that declare the same name with different types are refused at load time.
Verifying: the providers
The station declares the providers it accepts, in station.identity.providers. Each one is
a mechanism that Convoy prepares: you configure it, you do not code it. Each one states the datum
it verifies (verifies:).
| mechanism | proves | configured with |
|---|---|---|
otp | the person receives the codes sent to this datum (a phone, an e-mail) | verifies, then via: tenant (the tenant's send and check operations, in its apis) or via: convoy (the service Convoy operates, channel: sms or email) |
oidc | the person signed in with an identity provider: Okta, Auth0, Keycloak, Entra ID, Google… | verifies (the claim that names the person), issuer, client_id_env, scopes, attributes |
assertion | the tenant's site has already signed the person in | verifies (the tenant's customer identifier), secret_env, attributes, login_url (its sign-in page, for an authentication request) |
The current version already has otp with via: tenant: see Auth.
attributes says which other fields of the provider the rules can read, and under which name:
{roles: groups, teams: teams, manager: manager} for a directory.
station:
identity:
providers:
sms: {mechanism: otp, verifies: phone, via: tenant, send: sendOtp, check: checkOtp}
salaries: {mechanism: oidc, verifies: email, issuer: "https://acme.okta.com",
client_id_env: OKTA_CLIENT_ID, attributes: {roles: groups, teams: teams, manager: manager}}
compte: {mechanism: assertion, verifies: customer_id, secret_env: SHOP_IDENTITY_SECRET,
attributes: {addresses: addresses}}
At load time, a configuration that cannot work is refused: via: tenant with
operations the tenant does not declare, an oidc without client_id_env, an auth: that names a
missing provider.
Convoy never keeps a secret of the person: no password, no code, no login page of its own. It keeps the result — this datum is verified, by this provider, at this time.
Each portal says how it proves who is talking
A portal names the provider it offers, or anonymous:
agent:
portals:
web:
widget: {identity: {auth: compte}} # on the tenant's site, its login is enough
pwa: {identity: {auth: sms}} # off the site, a code by SMS
teams: {app: commandes, identity: {auth: salaries}}
The portal is described in The agent and its portals. When a skill requires a proof, this is the provider Core offers: see Asking for authentication.
What the rules read: identity
Profiles, permissions and policies read an identity object. It gives names and
attributes, never the values of identity data: a rule tests that a phone is
verified, not which number it is.
| field | meaning | comes from |
|---|---|---|
identity.type | person or agent | the door |
identity.authenticated | at least one verified datum | the providers |
identity.verified | the names of the verified data: 'phone' in identity.verified | the providers |
identity.declared | the names of the data declared in this room | the forms |
identity.provider | the provider that verified | the providers |
identity.<attribute> | roles, teams, manager, addresses… | the provider's attributes |
identity.org, identity.station, identity.roles, identity.limits | for an agent: its organisation, the calling station, the roles and limits set on its sub-identity | the delegated sub-identity |
profiles:
anonyme: {when: "identity.authenticated == false"}
client: {when: "'phone' in identity.verified"}
manager: {when: "'support-manager' in identity.roles"}
The room and its owner
When a datum is verified, the room is claimed: it now belongs to this
person, its owner (owner). Their other rooms are given back to them, to them alone.
submitted_by: [{who: owner}] requires this verified owner at submit. Without this rule, a human
in the room submits, even anonymous: the customer's click. With it, holding the room's code is
no longer enough — you must receive the code, or be signed in. See The submit.
Recommended for every station that places orders or requests with the merchant. Without a
sign-in, nothing tells the customer on another device from a stranger holding the code: both can
finalise. Where the station can sign people in but does not require submitted_by: owner, the
selection invites the customer to sign in before finalising, without making them.
Identity data, protected
- The value is kept because the tenant needs it in its submit, and for nothing else.
- Lookups go through a fingerprint (HMAC, one key per tenant, the tenant included in the fingerprint): no index holds a number in clear. The provisioner derives each tenant's key from the deployment's; changing that one signs everyone out.
- It follows the room's lifetime; erasure requested by the owner erases it.
Three examples
Simple — the room identified by a number, without proof. The tenant receives the number at submit, the room remembers it; submits stay anonymous.
skills:
- name: takeDeliveryOption
select:
guards:
- form:
phone: {type: phone, required: true, identifies: true}
Medium — the number verified by a code, and only the owner submits.
station:
identity:
providers:
sms: {mechanism: otp, verifies: phone, via: tenant, send: sendOtp, check: checkOtp}
skills:
- name: takeDeliveryOption
select:
guards:
- form:
phone: {type: phone, required: true, identifies: true}
agent:
portals:
web:
widget: {identity: {auth: sms}}
playbooks:
- name: commande
submit_guard:
submitted_by: [{who: owner}]
Advanced — one path per portal. The customer signed in on the site is verified without typing anything; off the site, they receive a code; an employee goes through the company's SSO, which gives their roles.
station:
identity:
providers:
sms: {mechanism: otp, verifies: phone, via: convoy, channel: sms}
compte: {mechanism: assertion, verifies: customer_id, secret_env: SHOP_IDENTITY_SECRET}
salaries: {mechanism: oidc, verifies: email, issuer: "https://login.microsoftonline.com/acme/v2.0",
client_id_env: ENTRA_CLIENT_ID, attributes: {roles: groups}}
agent:
portals:
web:
widget: {identity: {auth: compte}}
pwa: {identity: {auth: sms}}
teams: {app: commandes-internes, identity: {auth: salaries}}
profiles:
acheteur: {when: "'purchasing' in identity.roles"}
playbooks:
- name: commande
submit_guard:
submitted_by: [{who: owner}]