# Account security and self-service Milestone 19 is implemented on the `milestone/19-account-security` development branch for the planned `0.3.0` release. It must not be merged into `main` or enabled in production until the `0.2.0` Gate B candidate is approved and tagged. ## Roles and authorization GuestOps uses fixed server-enforced roles. Hiding a control in the browser is not an authorization boundary. | Role | Access | | --- | --- | | Owner | All hotel, integration, provider approval, automation, privacy, team, and security operations. | | Manager | Inbox and reviewed Gmail sending; knowledge and FAQ test-mode management; hotel display settings; activity and health; management of Agent and Auditor accounts. | | Agent | Inbox, drafts, status changes, reviewed Gmail sending, PMS/payment lookups, and preparation of proposals. | | Auditor | Read-only activity and aggregate workspace health. No inbox bodies, guest workflow, team, or provider controls. | Existing `Staff` records are normalized to Agent in sessions and permission checks. New invitations write `Agent`, `Manager`, or `Auditor`. Managers can manage only Agent and Auditor accounts. Only Owners can manage Managers, reset another user's MFA, enable integrations or writes, approve PMS/payment actions, enable live FAQ sending, or change security controls. Role, password, disable/restore, and MFA changes rotate the security stamp and invalidate affected sessions. Tenant scope is always derived from the authenticated session; client-provided hotel identifiers are not authorization inputs. ## TOTP MFA MFA uses six-digit, 30-second TOTP with SHA-1 compatibility, a one-step clock window, and atomic last-step replay prevention. Enrollment creates ten single-use recovery codes. The data-protection key ring protects TOTP secrets; only SHA-256 recovery-code hashes are stored. Enrollment and regeneration return recovery codes once. When `Identity__RequireMfa=false`, current password login remains available. When it is `true`, successful password verification creates only a five-minute, HttpOnly, SameSite=Strict protected challenge cookie. A normal eight-hour session is created only after authenticator or recovery-code verification. Existing sessions without `mfa=true` are rejected on their next request. Unenrolled users are routed through enrollment after password verification. Do not enable enforcement until every user has enrolled, recovery-code storage has been verified, and an independent security review has passed. Password recovery preserves MFA. An Owner may reset MFA for a Manager, Agent, or Auditor after identity verification. Owner MFA reset is deliberately excluded from web controls. A server administrator performs the audited recovery: ```sh read -r -p 'Verified owner email: ' RECOVERY_EMAIL export RECOVERY_EMAIL docker compose run --rm --no-deps -e RECOVERY_EMAIL api --reset-owner-mfa unset RECOVERY_EMAIL ``` This clears the Owner's enrollment, rotates the security stamp, ends existing sessions, and writes an audit event. It never prints a secret or recovery code. The administrator must verify identity using the approved procedure before running it. ## Transactional email and self-service recovery `POST /api/auth/recovery` always returns the same accepted response. Known and unknown addresses receive the same response. Requests are limited independently by client IP and a SHA-256 partition of the normalized address. Production invitation and reset APIs return only `userId`, `deliveryState`, and `expiresAt`. Development preview may return a direct link for interface testing. Issuing another link invalidates the previous account token. The API encrypts recipient, subject, body, and token link into an `AccountMail` outbox record. The worker atomically claims each record with a lease and sends it with a stable Message-ID. Clearly pre-submission failures receive at most five bounded retries. Any exception after SMTP submission begins is treated as ambiguous and moved to `NeedsReview`; it is never automatically resent. Expired records are not submitted. Workspace health exposes aggregate counts only. SMTP configuration is private environment state: ```text SMTP_ENABLED=false SMTP_HOST=smtp.example.invalid SMTP_PORT=587 SMTP_USERNAME=... SMTP_PASSWORD=... SMTP_FROM_ADDRESS=guestops@example.invalid SMTP_FROM_NAME=GuestOps ``` The transport requires authenticated STARTTLS and normal platform certificate validation. There is no insecure-certificate option. Do not put credentials in JSON acceptance records, API responses, screenshots, logs, or source control. Mandatory security notices are queued for password, MFA, role, disable/restore, and recovery events. They have no preference switch. ## User preferences `GET/PUT /api/me/preferences` stores `displayTimeZone` and `defaultInboxFilter` with optimistic concurrency. Timezones are validated by server timezone data. Supported filters are `All`, `NeedsAttention`, `DraftReady`, and `Completed`. An empty display timezone uses the hotel's timezone. The session response includes effective permissions, MFA state, stored preferences, and the effective timezone. ## Safe rollout 1. Deploy with `SMTP_ENABLED=false` and `IDENTITY_REQUIRE_MFA=false`. 2. Configure SMTP through private environment values and send only to synthetic accounts. 3. Verify success, expiration, retry, restart, duplicate-claim, revocation, and ambiguous-outcome evidence; then enable SMTP. 4. Enroll every user and verify their recovery-code storage procedure. 5. Complete independent security review and record its evidence. 6. Set `IDENTITY_REQUIRE_MFA=true`. Confirm password-only sessions are rejected and unenrolled users enter enrollment. 7. Run the full role matrix, tenant-isolation, preferences, monitoring, audit, backup/restore, and restart checks. SMTP or MFA failure must never weaken authorization or create a password-only bypass. A lost Owner authenticator uses only the server command above. ## Acceptance Copy `deploy/account-security-acceptance.example.json` to the restricted evidence store, replace all placeholders, and keep guest data, addresses, tokens, secrets, raw mail, and screenshots outside Git. Validate the completed record with: ```sh python3 deploy/account_security_acceptance.py /restricted/path/account-security-acceptance.json ``` Retain the record, validator output, checksum, independent security approval, and evidence references against the same full release commit and release-record SHA-256. The validator checks structure and approval separation; it does not inspect or prove the underlying evidence.