0015. Users and accounts¶
Supersedes the login part of ADR 0007.
Context¶
ADR 0007 gives each hiring system that needs an account one session per data directory, signed in by a person in a window. Some hiring systems keep one account per employer (Workday: one per tenant), so one session per system cannot serve them, and a window per employer does not scale. One computer account may also apply for more than one person, and the project needs a fictional user of its own whose accounts development can create freely.
Decision¶
User and home. A user is the person applications are sent for: their accounts and attempts. Answers stay the consumer's file, passed with --answers. The home is the operating-system account atsfill runs under, through its home directory: the config file and the data directory. One home serves one or more users; the person who owns the home writes the config and answers the send prompt. Commands pick a user with --user <name> (lowercase letters, digits and -), default without it. The Test user is the fictional user the project tests with, --user test.
Data directory per user.
identity.json, lock the home's
users/<user>/attempts.jsonl the double-send guard is per user
users/<user>/accounts.json each account's email
users/<user>/sessions/<adapter>.json
users/<user>/sessions/<adapter>/<scope>.json
users/<user>/passwords.json 0600, only without a secret store
A data directory from before users has its sessions/ and attempts.jsonl moved to users/default/ once, on first use.
Config per user. Keys are kebab-case. Top-level keys are the default user's; [users.<name>] holds another user's, complete, nothing inherited: the Test user gets no submit-allowed, so it cannot send.
submit-allowed = ["ashby"]
mute-side-effect-warnings = ["all"]
[password-commands]
wttj = "rbw get welcometothejungle.com {email}"
[otp-commands]
all = "mailbox wait {email} --code --since {since}"
[users.test]
mute-side-effect-warnings = ["all"]
The keys submit and ack_side_effects are an error naming their replacement.
Account scope. A user has one account per hiring system and scope. An adapter's account_scope(url) gives the scope: None where an account covers the whole system (Welcome to the Jungle), the employer where each employer keeps its own (Workday: the tenant, whatever the URL form). A form command derives the scope from the posting URL; login and logout take it from --url <posting>.
Login. atsfill login <adapter> [--user u] [--url posting] signs in with the account's password source, or opens the window for a person when there is none (--window forces it). --create makes the account where the adapter supports sign-up: atsfill generates the password and stores it before clicking, and never prints it or takes it on the command line. With a password already stored the account may exist (a rerun, or a second posting on the same tenant), so --create signs in with it instead, keeping the account's email: a generated password never replaces a working one. A sign-up that ends blocked keeps its password, whatever step it stopped on: the account may already exist once a link or an OTP was sent, and a dropped password would strand it. A sign-in on that kept password refused wrong_password or no_account is then a sign-up that never finished, so --create signs up again with the same password; a password kept from an earlier run is never deleted, email_in_use included. That sign-in takes the --details too, refused the same way when one in asks lacks, and an adapter whose system can leave an account half made (a step the sign-up shows after the account exists, unfinished) gives them to that step; a plain sign-in that lands on it is refused: sign_up_details. What a sign-up asks of the user (an adapter's asks: name, phone, street, city, state, zip, country) comes from --details <file.json>, {"name", "phone", "address": {"street", "city", "state", "zip", "country"}}, every key optional, the phone in E.164, the country in ISO 3166 alpha-2 and the state by its name, as the country's own lists write it (Illinois, not IL): hiring systems list states by name, and no standard maps codes to the names they use; a file of another shape is a usage error. A detail the adapter asks that the file lacks is refused: sign_up_details: no <keys>, before the browser opens. A detail only some tenants require, once the page shows it, is refused the same way by the adapter, before it sends anything, so the generated password goes; one a tenant asks only after the account exists belongs in asks. atsfill keeps none of the details: the consumer passes them on each --create, and they go to the hiring system under the adapter's sign_up side effect (ADR 0008). Optional consents a sign-up offers, such as text messages, stay declined. An account without a password, on a system that emails an OTP at every sign-in, signs in through the OTP command in place of a password source: atsfill generates, stores and takes no password for it, --password is a usage error and --create reports password: none. Such a system turns an unknown email's sign-in into a sign-up, so a sign-in without --create, a form command's included, stops before that click: refused: no_account names the login --create command to run. --create signs in where the email already has an account. Outcomes: signed_in; refused with sign_up_details, no_account, email_in_use, email_domain_rejected, password_rule; blocked with email_link, email_otp, sms_otp, no_otp_command, otp_not_received, otp_rejected, captcha, rate_limited; unsupported with unhandled_rendering.
Account creation is a side effect (ADR 0008) and never implicit: never part of fill or send. A consumer's harness asks before login --create for a real user. Development creates Test user accounts freely.
Verification links. The user's mailbox is outside atsfill. A verification link is a one-time click in the mailbox: blocked: email_link names the address and the login command to run again once it is clicked; atsfill takes nothing back.
OTPs. A system sends a fresh OTP each time its step shows, so a command that stopped there and ran again later would meet a new one: the page stays open while atsfill gets the OTP from the user's otp-commands entry for the adapter, else its all entry, and types it in. The entry is a command, split into arguments without a shell like a password command, with {email} (the address the OTP went to, taken to be the one atsfill typed: no system has been seen sending it elsewhere, #176), {scope} and {since} (when the step asked for it, ISO 8601 in UTC, so an older OTP is not taken) filled per argument. atsfill runs it until it exits 0 printing the OTP, its first word, within OTP_TIMEOUT_S (90 seconds); a command that waits for the mail itself runs once, stopped at the timeout. Without an entry the command ends blocked: no_otp_command, with no OTP in time blocked: otp_not_received, naming the step and the address, and with one the page does not take blocked: otp_rejected. email_otp, like sms_otp, is an OTP step an adapter does not type into. It serves sign-ins, sign-ups and form steps alike, and is the whole sign-in of an account without a password, in the core once: an adapter only finds its system's OTP step and the input it takes, and says what the page did with it. An SMS OTP (sms_otp) stays a known gap. Typing an OTP is not a side effect: the system sent it for the step atsfill was already on.
Password sources. Where atsfill gets an account's password, first found wins: --password - on stdin for one sign-in, the user's password-commands entry for the adapter, the secret store for passwords atsfill generated. A command is split into arguments, without a shell; {email} and {scope} are filled per argument. With a source, or the OTP command for an account without a password, a form command that finds the session signed out signs in again on its own, which is not a side effect. A page that asks to sign in although the adapter's check found the session holding (the system ended it its own way, as Jobstreet's sites do once a refreshed token is refused) is signed in again the same way, once, and read again. A form command saves its session again when it ends: a system may rotate the session's tokens as the page uses them, and the stored copy would go stale. Where Firefox cannot gather a page's storage, the session is its cookies. Unlocking a password manager for an agent trusts the agent with the password: atsfill never prints it, and cannot stop the agent from fetching it.
Secret store. Generated passwords go to the system's secret store through keyring, service atsfill, entry <user>/<adapter>[/<scope>]; to users/<user>/passwords.json (0600) when no backend is available, status naming which. A generated password is known only to atsfill: once deleted, the way back is the hiring system's password reset through the user's mailbox.
Removing stored data. logout deletes a session. Passwords are removed with the secret store's own tools, a whole user by deleting users/<user>/. A command for it waits for a need.
Consequences¶
A hiring system with an account per employer is served without a person at each employer's window. A user's double-send guard, sessions and submit permission are theirs alone; a home sending for two users keeps them apart. Every user of a home shares its device identity (ADR 0004). A generated password lost with the secret store costs a password reset per account.