diff --git a/.env.example b/.env.example index d385972..64260f1 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,14 @@ PAYMENTS_CONFIG_FILE_HOST=./deploy/payments.example.json # Enable only after Google delivery and FAQ test-mode acceptance. AUTO_REPLY_ENABLE_LIVE=false + +# Keep enforcement off until every user has enrolled and the security review passes. +IDENTITY_REQUIRE_MFA=false +# Authenticated STARTTLS SMTP. Keep credentials private and enable only after synthetic delivery acceptance. +SMTP_ENABLED=false +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_ADDRESS= +SMTP_FROM_NAME=GuestOps diff --git a/MILESTONES.md b/MILESTONES.md index 960b844..d35b6cb 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -43,12 +43,13 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i | 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. | | 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. | | 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. | +| 19 | Account security and self-service | Post-B | Implemented / acceptance required | Implemented on `milestone/19-account-security`: TOTP MFA and recovery codes, protected SMTP outbox and non-enumerating recovery, fixed granular roles with legacy `Staff` compatibility, per-user timezone/inbox preferences, aggregate monitoring, administrator Owner-MFA recovery, automated security tests, and a restricted acceptance validator. Keep SMTP and MFA enforcement disabled until synthetic delivery tests, full role/tenant checks, recovery and restart exercises, and independent security review pass. Merge and version as `0.3.0` only after `0.2.0` Gate B approval and tagging. | ## Delivery sequence The current critical path is: -`9 → 10 → 11 → 12 → 15 → 18` +`9 → 10 → 11 → 12 → 15 → 18 → 19` Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel provider tracks. They do not need to delay a Google-only supervised pilot, but both remain independently gated before Gate C. @@ -63,6 +64,7 @@ Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel pro - [ ] Obtain the Guestline/Rezlynx interface contract and sandbox access. - [ ] Agree the payment-provider acceptance and reconciliation plan. - [ ] Capture named owners and target dates for milestones 9–18. +- [ ] After Gate B approval, independently review milestone 19, validate its restricted acceptance record, merge the development branch, and create the `0.3.0` release candidate. ## Tracking convention diff --git a/compose.yml b/compose.yml index a69ced6..9c58794 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,14 @@ x-app-env: &app-env Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} AutoReply__EnableLive: ${AUTO_REPLY_ENABLE_LIVE:-false} Google__EnableSending: ${GOOGLE_ENABLE_SENDING:-false} + Identity__RequireMfa: ${IDENTITY_REQUIRE_MFA:-false} + Mail__Enabled: ${SMTP_ENABLED:-false} + Mail__Host: ${SMTP_HOST:-} + Mail__Port: ${SMTP_PORT:-587} + Mail__Username: ${SMTP_USERNAME:-} + Mail__Password: ${SMTP_PASSWORD:-} + Mail__FromAddress: ${SMTP_FROM_ADDRESS:-} + Mail__FromName: ${SMTP_FROM_NAME:-GuestOps} Logging__LogLevel__Default: Warning Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Warning x-logging: &logging diff --git a/deploy/account-security-acceptance.example.json b/deploy/account-security-acceptance.example.json new file mode 100644 index 0000000..82fbe5b --- /dev/null +++ b/deploy/account-security-acceptance.example.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "system": "guestops-account-security", + "milestone": 19, + "releaseCommit": "0000000000000000000000000000000000000000", + "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "environment": "https://sandbox-guestops.example.invalid", + "operator": "REPLACE OPERATOR", + "reviewedBy": "REPLACE REVIEWER", + "securityReviewedBy": "REPLACE SECURITY REVIEWER", + "startedAt": "2026-10-05T09:00:00Z", + "endedAt": "2026-10-05T12:00:00Z", + "reviewedAt": "2026-10-05T13:00:00Z", + "securityReviewedAt": "2026-10-05T14:00:00Z", + "securityReviewEvidence": [], + "productionControls": {"smtpEnabled": false, "requireMfa": false, "enabledAfterSecurityReview": false}, + "scenarios": [ + {"id":"administrator-recovery","status":"not-run","evidence":[]}, + {"id":"audit-review","status":"not-run","evidence":[]}, + {"id":"authenticator-enrollment","status":"not-run","evidence":[]}, + {"id":"legacy-staff","status":"not-run","evidence":[]}, + {"id":"monitoring","status":"not-run","evidence":[]}, + {"id":"preferences","status":"not-run","evidence":[]}, + {"id":"recovery-codes","status":"not-run","evidence":[]}, + {"id":"restart-recovery","status":"not-run","evidence":[]}, + {"id":"role-boundaries","status":"not-run","evidence":[]}, + {"id":"secret-free-interfaces","status":"not-run","evidence":[]}, + {"id":"smtp-delivery-revocation","status":"not-run","evidence":[]}, + {"id":"totp-window-replay","status":"not-run","evidence":[]} + ], + "findings": [] +} diff --git a/deploy/account_security_acceptance.py b/deploy/account_security_acceptance.py new file mode 100644 index 0000000..49018d3 --- /dev/null +++ b/deploy/account_security_acceptance.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate restricted milestone 19 account-security acceptance evidence.""" + +from __future__ import annotations +import argparse +import datetime as dt +import json +from pathlib import Path +import re +from urllib.parse import urlparse + +SCENARIOS={ + "authenticator-enrollment","totp-window-replay","recovery-codes","administrator-recovery", + "smtp-delivery-revocation","role-boundaries","legacy-staff","preferences","restart-recovery", + "monitoring","audit-review","secret-free-interfaces", +} + +def require(value:bool,message:str)->None: + if not value: raise ValueError(message) + +def timestamp(value:object,field:str)->dt.datetime: + require(isinstance(value,str) and value.endswith("Z"),f"{field} must be a UTC timestamp ending in Z.") + try:return dt.datetime.fromisoformat(value.removesuffix("Z")+"+00:00") + except ValueError as error:raise ValueError(f"{field} is not a valid timestamp.") from error + +def person(value:object,field:str)->str: + result=str(value or "").strip();require(2<=len(result)<=120 and "@" not in result,f"{field} requires a name without an email address.");return result + +def refs(value:object,field:str)->None: + require(isinstance(value,list) and 1<=len(value)<=12 and all(isinstance(item,str) and 3<=len(item)<=200 and "@" not in item and "http" not in item.casefold() for item in value),f"{field} requires safe opaque evidence references.") + +def validate(record:object)->None: + require(isinstance(record,dict),"Acceptance record must be a JSON object.") + require(record.get("schemaVersion")==1,"Unsupported account-security acceptance schema.") + require(record.get("system")=="guestops-account-security","system must be guestops-account-security.") + require(record.get("milestone")==19,"milestone must be 19.") + require(re.fullmatch(r"[0-9a-f]{40}",str(record.get("releaseCommit",""))) is not None,"releaseCommit must be a full lowercase Git SHA.") + require(re.fullmatch(r"[0-9a-f]{64}",str(record.get("releaseRecordSha256",""))) is not None,"releaseRecordSha256 must be a SHA-256 digest.") + origin=urlparse(str(record.get("environment","")));require(origin.scheme=="https" and origin.hostname and origin.path in ("","/") and not origin.query and not origin.fragment and origin.username is None,"environment must be an HTTPS origin without credentials, path, query or fragment.") + operator=person(record.get("operator"),"operator");reviewer=person(record.get("reviewedBy"),"reviewedBy");security=person(record.get("securityReviewedBy"),"securityReviewedBy") + require(len({operator.casefold(),reviewer.casefold(),security.casefold()})==3,"operator, reviewer, and security reviewer must be different people.") + started=timestamp(record.get("startedAt"),"startedAt");ended=timestamp(record.get("endedAt"),"endedAt");reviewed=timestamp(record.get("reviewedAt"),"reviewedAt");security_at=timestamp(record.get("securityReviewedAt"),"securityReviewedAt") + require(started<=ended<=reviewed and ended<=security_at,"Acceptance timestamps are out of order.") + refs(record.get("securityReviewEvidence"),"securityReviewEvidence") + controls=record.get("productionControls");require(isinstance(controls,dict),"productionControls is required.") + require(set(controls)=={"smtpEnabled","requireMfa","enabledAfterSecurityReview"},"productionControls has unexpected fields.") + require(all(isinstance(controls[key],bool) for key in controls),"productionControls values must be booleans.") + require(controls["enabledAfterSecurityReview"],"Production SMTP and MFA enforcement must be enabled only after independent security review.") + scenarios=record.get("scenarios");require(isinstance(scenarios,list),"scenarios must be a list.") + ids=[item.get("id") for item in scenarios if isinstance(item,dict)];require(len(ids)==len(scenarios)==len(set(ids)) and set(ids)==SCENARIOS,"Acceptance record requires the exact milestone 19 scenario set.") + for item in scenarios: + require(item.get("status")=="pass",f"Scenario {item['id']} has not passed.");refs(item.get("evidence"),f"Scenario {item['id']}") + findings=record.get("findings");require(isinstance(findings,list),"findings must be a list.") + for finding in findings: + require(isinstance(finding,dict) and finding.get("status") in ("resolved","accepted"),"Every finding must be resolved or explicitly accepted.") + require(3<=len(str(finding.get("id","")))<=80,"Every finding requires an opaque ID.") + person(finding.get("owner"),f"Finding {finding.get('id')} owner");refs(finding.get("evidence"),f"Finding {finding.get('id')}") + +def main()->None: + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument("record",type=Path);args=parser.parse_args();validate(json.loads(args.record.read_text(encoding="utf-8"))) + print("Milestone 19 account-security acceptance record is structurally complete. This validates the record, not its restricted evidence.") + +if __name__=="__main__": + try:main() + except (OSError,ValueError,json.JSONDecodeError) as error: + print(f"Account-security acceptance record rejected: {error}",file=__import__("sys").stderr);raise SystemExit(1) diff --git a/docs/account-security.md b/docs/account-security.md new file mode 100644 index 0000000..757aef3 --- /dev/null +++ b/docs/account-security.md @@ -0,0 +1,87 @@ +# 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. diff --git a/docs/accounts.md b/docs/accounts.md index 9dd2a3e..9ab7ef8 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -1,5 +1,7 @@ # Team access and hotel setup +> The post-pilot MFA, granular-role, SMTP-outbox, self-service recovery, and per-user preference design is documented in [account-security.md](account-security.md). This file describes the `0.2.0` Gate B account behavior retained on `main` until that release is approved. + Owners manage colleagues in **Your team**. The **Hotel setup** page shows progress derived from the hotel's saved MongoDB settings, approved answers, mailbox synchronization and active staff accounts. It does not enable any external action. Preview accounts and progress are temporary. ## Invite and recover staff diff --git a/src/GuestOps.Api/AccountMail.cs b/src/GuestOps.Api/AccountMail.cs new file mode 100644 index 0000000..7468812 --- /dev/null +++ b/src/GuestOps.Api/AccountMail.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Mail; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace GuestOps.Web; + +public sealed record AccountMailPayload(string Recipient,string Subject,string Body,string Link); +public sealed class MailPreSubmissionException(string message):Exception(message); +public interface IAccountMailTransport { Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken); } + +public sealed class SmtpAccountMailTransport(IConfiguration config):IAccountMailTransport +{ + public async Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken) + { + if(!config.GetValue("Mail:Enabled"))throw new MailPreSubmissionException("Disabled"); + var host=config["Mail:Host"];var username=config["Mail:Username"];var password=config["Mail:Password"]; + var from=config["Mail:FromAddress"];var fromName=config["Mail:FromName"]??"GuestOps"; + if(string.IsNullOrWhiteSpace(host)||string.IsNullOrWhiteSpace(username)||string.IsNullOrWhiteSpace(password)||!MailAddress.TryCreate(from,out var fromAddress))throw new MailPreSubmissionException("Configuration"); + using var message=new MailMessage{From=new MailAddress(fromAddress.Address,fromName),Subject=payload.Subject,Body=payload.Body,IsBodyHtml=false}; + message.To.Add(payload.Recipient);message.Headers.Add("Message-ID",messageId); + using var smtp=new SmtpClient(host,config.GetValue("Mail:Port",587)){EnableSsl=true,UseDefaultCredentials=false,Credentials=new NetworkCredential(username,password),DeliveryMethod=SmtpDeliveryMethod.Network}; + await smtp.SendMailAsync(message,cancellationToken); + } +} + +public sealed class AccountMailService(IStore store,IDataProtectionProvider protection,IConfiguration config) +{ + readonly IDataProtector payloads=protection.CreateProtector("GuestOps.AccountMail.v1"); + public bool Preview=>config.GetValue("Preview"); + public async Task Queue(StaffUser user,string purpose,string subject,string body,string link,DateTime expiresAt) + { + var id=Guid.NewGuid().ToString("N");var payload=new AccountMailPayload(user.Email,subject,body+"\n\n"+link,link); + var mail=new AccountMail{Id=id,HotelId=user.HotelId,UserId=user.Id,Purpose=purpose,ProtectedPayload=payloads.Protect(JsonSerializer.Serialize(payload)),MessageId=$"<{id}@account.guestops.invalid>",ExpiresAt=expiresAt}; + await store.Insert(mail);return mail; + } + public Task Notify(StaffUser user,string purpose,string detail) + =>Queue(user,purpose,"GuestOps security notification",detail,"",DateTime.UtcNow.AddDays(7)); + public AccountMailPayload Unprotect(AccountMail mail)=>JsonSerializer.Deserialize(payloads.Unprotect(mail.ProtectedPayload))??throw new InvalidOperationException("Invalid protected mail payload."); +} + +public sealed class AccountMailProcessor(IStore store,AccountMailService mail,IAccountMailTransport transport,ILogger log) +{ + readonly string owner=Guid.NewGuid().ToString("N"); + public async Task Process(AccountMail candidate,CancellationToken cancellationToken) + { + var item=await store.ClaimAccountMail(candidate.Id,owner);if(item==null)return; + var version=item.Version; + if(item.ExpiresAt<=DateTime.UtcNow){item.State="Expired";item.ErrorCategory="Expired";item.CompletedAt=DateTime.UtcNow;item.LeaseOwner="";item.LeaseUntil=null;item.Version++;await store.Replace(item.HotelId,item.Id,version,item);return;} + try + { + var payload=mail.Unprotect(item);await transport.Send(payload,item.MessageId,cancellationToken); + item.State="Sent";item.ErrorCategory="";item.CompletedAt=DateTime.UtcNow; + } + catch(MailPreSubmissionException) + { + item.AttemptCount++;item.ErrorCategory="PreSubmission"; + if(item.AttemptCount>=5){item.State="Failed";item.CompletedAt=DateTime.UtcNow;} + else{item.State="Pending";item.NextAttemptAt=DateTime.UtcNow.AddMinutes(Math.Min(30,1<Results.Ok(new{liveConfigured=!preview&&work.LiveConfigured,preview,questions=FaqMatcher.Questions,dailyLimit=20})); group.MapGet("/rules",async(HttpContext c,IStore store)=>Results.Ok(await store.List(Session.Hotel(c)))); group.MapGet("/history",async(HttpContext c,IStore store)=>Results.Ok((await store.List(Session.Hotel(c))).Where(m=>m.AutoReplyCheckedAt!=null).OrderByDescending(m=>m.AutoReplyCheckedAt).Select(m=>new{m.Id,m.Subject,m.From,m.AutoReplyCheckedAt,m.AutoReplyMatched,m.AutoReplyDetail,delivery=m.Delivery?.State}))); group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{ if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body)); - }).RequireAuthorization("Owner"); + }); group.MapPost("/evaluate",async(AutoEvaluationInput input,HttpContext c,AutoReplyWork work)=>{ if(input.Cases is not {Length:>=1 and <=100} cases||cases.Select(x=>x.Id).Distinct(StringComparer.Ordinal).Count()!=cases.Length||cases.Any(x=>!Input.Text(x.Id,1,80)||!Input.Text(x.Subject,0,200)||!Input.Text(x.Body,1,2000)||x.ExpectedKnowledgeId.Length>80))return Results.BadRequest(); var results=await work.Evaluate(Session.Hotel(c),cases);return Results.Ok(new{total=results.Length,passed=results.Count(x=>x.Passed),falsePositives=results.Count(x=>!x.ExpectedMatch&&x.ActualMatch),falseNegatives=results.Count(x=>x.ExpectedMatch&&!x.ActualMatch),results}); - }).RequireAuthorization("Owner"); + }); group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{ if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest(); var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32]; @@ -28,13 +28,14 @@ public static class AutoReplyEndpoints if(exists){if(!await store.Replace(hotel,key,input.Version,rule))return Input.Conflict();} else{try{await store.Insert(rule);}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return Input.Conflict();}} await Session.Audit(store,c,"Reviewed FAQ automatic reply rule: "+rule.Question);return Results.Ok(rule); - }).RequireAuthorization("Owner"); + }); group.MapPut("/mode",async(AutoModeInput input,HttpContext c,IStore store,AutoReplyWork work,GoogleMailbox google)=>{ if(input.Mode is not ("Off" or "Test" or "Live"))return Results.BadRequest(); + if(input.Mode=="Live"&&!Access.Has(c.User,Access.AutomationLive))return Results.Forbid(); var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound(); if(input.Mode=="Live"&&(preview||!work.LiveConfigured||!google.SendingConfigured||!hotel.StaffSendingEnabled||!input.AcceptanceConfirmed))return Results.BadRequest(new{error="Live mode requires administrator enablement, Google sending, hotel sending and confirmed test-mode acceptance."}); hotel.AutoReplyMode=input.Mode;hotel.AutoReplyEpoch=Guid.NewGuid().ToString("N");hotel.AutoReplySince=DateTime.UtcNow;hotel.Version=input.Version+1; if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Set FAQ automation mode: "+input.Mode);return Results.Ok(hotel); - }).RequireAuthorization("Owner"); + }); } } diff --git a/src/GuestOps.Api/IdentityEndpoints.cs b/src/GuestOps.Api/IdentityEndpoints.cs new file mode 100644 index 0000000..9a2359a --- /dev/null +++ b/src/GuestOps.Api/IdentityEndpoints.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace GuestOps.Web; + +public sealed record MfaCodeInput(string Code); +public sealed record RecoveryInput(string Email); + +public static class IdentityEndpoints +{ + static async Task ChallengeUser(HttpContext c,IStore store,MfaChallenges challenges) + { + var challenge=challenges.Read(c);if(challenge==null)return null; + var user=await store.Get(challenge.HotelId,challenge.UserId); + return user?.Active==true&&user.SecurityStamp==challenge.SecurityStamp?user:null; + } + public static void Map(WebApplication app,RouteGroupBuilder api) + { + app.MapPost("/api/auth/recovery",async(RecoveryInput input,HttpContext c,IStore store,TeamAccounts accounts,RecoveryRateLimits limits)=> + { + var email=(input.Email??"").Trim().ToLowerInvariant();var ip=c.Connection.RemoteIpAddress?.ToString()??"unknown"; + if(email.Length<=254&&limits.Allow(ip,email)&&Input.Email(email)) + { + var user=await store.FindLogin(email); + if(user?.Active==true&&user.PasswordHash.Length>0) + { + try{if(Access.NormalizeRole(user.Role)==Access.Owner)await accounts.RecoverOwner(user);else await accounts.ResetStaff(user,user.Version);}catch(AccountConflict){}catch(AccountInvalid){} + } + } + return Results.Accepted(value:new{accepted=true}); + }); + app.MapPost("/api/auth/mfa/enroll",async(HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=> + { + var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var enrollment=await mfa.Begin(user);return Results.Ok(new{secret=enrollment.Secret,uri=enrollment.Uri}); + }).RequireRateLimiting("accounts"); + app.MapPost("/api/auth/mfa/enroll/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=> + { + var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var codes=await mfa.Enable(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."}); + challenges.Clear(c);await Session.SignIn(c,user,true);await mail.Notify(user,"MfaEnabled","MFA was enabled for your GuestOps account.");return Results.Ok(new{recoveryCodes=codes}); + }).RequireRateLimiting("accounts"); + app.MapPost("/api/auth/mfa/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=> + { + var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);return Results.Ok(); + }).RequireRateLimiting("accounts"); + app.MapPost("/api/auth/mfa/recovery-code",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=> + { + var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.UseRecovery(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The recovery code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);await mail.Notify(verified,"MfaRecovery","A recovery code was used to sign in to your GuestOps account.");return Results.Ok(new{remaining=verified.RecoveryCodeHashes.Length}); + }).RequireRateLimiting("accounts"); + api.MapGet("/auth/mfa/status",async(HttpContext c,IStore store)=> + { + var user=await store.Get(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);return user==null?Results.NotFound():Results.Ok(new{enabled=user.MfaEnabledAt!=null,recoveryCodesRemaining=user.RecoveryCodeHashes.Length,required=c.RequestServices.GetRequiredService().GetValue("Identity:RequireMfa")}); + }); + api.MapPost("/auth/mfa/recovery-codes",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,AccountMailService mail)=> + { + var user=await store.Get(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var codes=await mfa.Regenerate(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});await c.SignOutAsync();await Session.SignIn(c,user,true);await mail.Notify(user,"RecoveryCodesRegenerated","Your GuestOps recovery codes were regenerated.");return Results.Ok(new{recoveryCodes=codes}); + }); + api.MapPost("/auth/mfa/disable",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,IConfiguration config,AccountMailService mail)=> + { + if(config.GetValue("Identity:RequireMfa"))return Results.Conflict(new{error="MFA cannot be disabled while enforcement is enabled."});var user=await store.Get(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});var version=verified.Version;MfaService.Reset(verified);verified.Version++;if(!await store.Replace(verified.HotelId,verified.Id,version,verified))return Input.Conflict();await c.SignOutAsync();await mail.Notify(verified,"MfaDisabled","MFA was disabled for your GuestOps account.");return Results.Ok(); + }); + api.MapGet("/me/preferences",async(HttpContext c,IStore store)=> + { + var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get(Session.Hotel(c),id);var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));return user==null?Results.NotFound():Results.Ok(ViewPreferences(user,hotel)); + }); + api.MapPut("/me/preferences",async(PreferencesInput input,HttpContext c,IStore store)=> + { + if(!ValidFilter(input.DefaultInboxFilter))return Results.BadRequest(new{error="Choose a supported inbox filter."});if(input.DisplayTimeZone.Length>100)return Results.BadRequest(new{error="Choose a valid timezone."});if(input.DisplayTimeZone.Length>0)try{_=TimeZoneInfo.FindSystemTimeZoneById(input.DisplayTimeZone);}catch{return Results.BadRequest(new{error="Choose a valid timezone."});} + var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get(Session.Hotel(c),id);if(user==null)return Results.NotFound();user.DisplayTimeZone=input.DisplayTimeZone;user.DefaultInboxFilter=input.DefaultInboxFilter;user.Version=input.Version+1;if(!await store.Replace(user.HotelId,user.Id,input.Version,user))return Input.Conflict();var hotel=await store.Get(user.HotelId,user.HotelId);return Results.Ok(ViewPreferences(user,hotel)); + }); + } + public static bool ValidFilter(string value)=>value is "All" or "NeedsAttention" or "DraftReady" or "Completed"; + public static object ViewPreferences(StaffUser user,Hotel? hotel)=>new{user.DisplayTimeZone,effectiveDisplayTimeZone=user.DisplayTimeZone.Length>0?user.DisplayTimeZone:hotel?.Timezone??"UTC",user.DefaultInboxFilter,user.Version}; +} diff --git a/src/GuestOps.Api/IdentitySecurity.cs b/src/GuestOps.Api/IdentitySecurity.cs new file mode 100644 index 0000000..87c6801 --- /dev/null +++ b/src/GuestOps.Api/IdentitySecurity.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; + +namespace GuestOps.Web; + +public static class Access +{ + public const string Owner="Owner", Manager="Manager", Agent="Agent", Auditor="Auditor"; + public const string Inbox="Inbox", Send="Send", Knowledge="Knowledge", HotelSettings="HotelSettings", + Audit="Audit", Operations="Operations", Team="Team", Security="Security", Integrations="Integrations", + ExternalApproval="ExternalApproval", AutomationTest="AutomationTest", AutomationLive="AutomationLive", + ProviderLookup="ProviderLookup", ProviderProposal="ProviderProposal"; + public static readonly string[] Roles=[Owner,Manager,Agent,Auditor]; + static readonly IReadOnlyDictionary Grants=new Dictionary + { + [Owner]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,Security,Integrations,ExternalApproval,AutomationTest,AutomationLive,ProviderLookup,ProviderProposal], + [Manager]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,AutomationTest,ProviderLookup,ProviderProposal], + [Agent]=[Inbox,Send,ProviderLookup,ProviderProposal], + [Auditor]=[Audit,Operations] + }; + public static string NormalizeRole(string? role)=>role=="Staff"?Agent:Roles.Contains(role,StringComparer.Ordinal)?role!:Agent; + public static IReadOnlyList Permissions(string? role)=>Grants[NormalizeRole(role)]; + public static bool Has(ClaimsPrincipal principal,string permission)=>Permissions(principal.FindFirstValue(ClaimTypes.Role)).Contains(permission,StringComparer.Ordinal); + public static bool CanManage(string actorRole,string targetRole) + { + actorRole=NormalizeRole(actorRole);targetRole=NormalizeRole(targetRole); + return actorRole==Owner?targetRole!=Owner:actorRole==Manager&&targetRole is Agent or Auditor; + } +} + +public static class Totp +{ + const string Alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + public static byte[] NewSecret()=>RandomNumberGenerator.GetBytes(20); + public static long Step(DateTime utcNow)=>new DateTimeOffset(utcNow.ToUniversalTime()).ToUnixTimeSeconds()/30; + public static string Code(byte[] secret,long step) + { + Span counter=stackalloc byte[8];System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(counter,step); + var hash=HMACSHA1.HashData(secret,counter);var offset=hash[^1]&15; + var value=((hash[offset]&127)<<24)|(hash[offset+1]<<16)|(hash[offset+2]<<8)|hash[offset+3]; + return (value%1_000_000).ToString("D6",CultureInfo.InvariantCulture); + } + public static long? Verify(byte[] secret,string? code,DateTime utcNow,long? lastUsed) + { + if(code is null||code.Length!=6||!code.All(char.IsAsciiDigit))return null; + var now=Step(utcNow); + for(var delta=-1;delta<=1;delta++) + { + var candidate=now+delta; + if(candidate<=lastUsed)continue; + if(CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Code(secret,candidate)),Encoding.ASCII.GetBytes(code)))return candidate; + } + return null; + } + public static string Encode(byte[] data) + { + var output=new StringBuilder();var buffer=0;var bits=0; + foreach(var b in data){buffer=(buffer<<8)|b;bits+=8;while(bits>=5){bits-=5;output.Append(Alphabet[(buffer>>bits)&31]);}} + if(bits>0)output.Append(Alphabet[(buffer<<(5-bits))&31]);return output.ToString(); + } + public static byte[] Decode(string value) + { + var bytes=new List();var buffer=0;var bits=0; + foreach(var c in value.Trim().TrimEnd('=').ToUpperInvariant()){var index=Alphabet.IndexOf(c);if(index<0)throw new FormatException("Invalid Base32 value.");buffer=(buffer<<5)|index;bits+=5;if(bits>=8){bits-=8;bytes.Add((byte)(buffer>>bits));buffer&=(1<("Preview"),SameSite=SameSiteMode.Strict,MaxAge=TimeSpan.FromMinutes(5),Path="/api/auth/mfa"}); + } + public MfaChallenge? Read(HttpContext c) + { + try{if(!c.Request.Cookies.TryGetValue(Cookie,out var value))return null;var result=JsonSerializer.Deserialize(protector.Unprotect(value));return result?.ExpiresUnix>=DateTimeOffset.UtcNow.ToUnixTimeSeconds()?result:null;}catch(CryptographicException){return null;}catch(JsonException){return null;} + } + public void Clear(HttpContext c)=>c.Response.Cookies.Delete(Cookie,new(){Path="/api/auth/mfa"}); +} + +public sealed class MfaService(IStore store,IDataProtectionProvider protection) +{ + readonly IDataProtector secrets=protection.CreateProtector("GuestOps.TotpSecret.v1"); + static string RecoveryHash(StaffUser user,string code)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(user.Id+":"+NormalizeRecovery(code)))); + static string NormalizeRecovery(string code)=>code.Replace("-","").Trim().ToUpperInvariant(); + public async Task<(string Secret,string Uri)> Begin(StaffUser user) + { + if(user.MfaEnabledAt!=null)throw new AccountConflict("MFA is already enabled."); + byte[] secret; + if(user.MfaPendingSecretProtected.Length==0) + { + secret=Totp.NewSecret();var version=user.Version;user.MfaPendingSecretProtected=secrets.Protect(Convert.ToBase64String(secret));user.Version++; + if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Try again."); + } + else secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected)); + var encoded=Totp.Encode(secret);var label=Uri.EscapeDataString("GuestOps:"+user.Email);var issuer=Uri.EscapeDataString("GuestOps"); + return(encoded,$"otpauth://totp/{label}?secret={encoded}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"); + } + public async Task Enable(StaffUser user,string code,DateTime? now=null) + { + if(user.MfaEnabledAt!=null||user.MfaPendingSecretProtected.Length==0)return null; + var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null; + var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray(); + var version=user.Version;user.MfaSecretProtected=user.MfaPendingSecretProtected;user.MfaPendingSecretProtected="";user.MfaEnabledAt=DateTime.UtcNow;user.LastTotpStep=step;user.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(user,x)).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++; + return await store.Replace(user.HotelId,user.Id,version,user)?raw:null; + } + public async Task Verify(StaffUser user,string code,DateTime? now=null) + { + if(user.MfaEnabledAt==null||user.MfaSecretProtected.Length==0)return null; + var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null; + var version=user.Version;user.LastTotpStep=step;user.Version++;return await store.Replace(user.HotelId,user.Id,version,user)?user:null; + } + public async Task UseRecovery(StaffUser user,string code) + { + var hash=RecoveryHash(user,code);var index=Array.FindIndex(user.RecoveryCodeHashes,x=>CryptographicOperations.FixedTimeEquals(Convert.FromHexString(x),Convert.FromHexString(hash)));if(index<0)return null; + var version=user.Version;user.RecoveryCodeHashes=user.RecoveryCodeHashes.Where((_,i)=>i!=index).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++; + return await store.Replace(user.HotelId,user.Id,version,user)?user:null; + } + public async Task Regenerate(StaffUser user,string code) + { + var verified=await Verify(user,code);if(verified==null)return null; + var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray(); + var version=verified.Version;verified.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(verified,x)).ToArray();verified.SecurityStamp=Guid.NewGuid().ToString("N");verified.Version++; + return await store.Replace(verified.HotelId,verified.Id,version,verified)?raw:null; + } + public static void Reset(StaffUser user){user.MfaSecretProtected="";user.MfaPendingSecretProtected="";user.MfaEnabledAt=null;user.LastTotpStep=null;user.RecoveryCodeHashes=[];user.SecurityStamp=Guid.NewGuid().ToString("N");} +} + +public sealed class RecoveryRateLimits +{ + readonly System.Collections.Concurrent.ConcurrentDictionary windows=new(); + bool Take(string key,int limit) + { + var now=DateTime.UtcNow;while(true){var old=windows.GetOrAdd(key,_=>(now,0));var next=now-old.Start>=TimeSpan.FromMinutes(15)?(now,1):(old.Start,old.Count+1);if(old.Count>=limit&&now-old.StartTake("ip:"+ip,10)&&Take("address:"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(email))),5); +} diff --git a/src/GuestOps.Api/MailboxManagement.cs b/src/GuestOps.Api/MailboxManagement.cs index 6ce32e8..bda1a5e 100644 --- a/src/GuestOps.Api/MailboxManagement.cs +++ b/src/GuestOps.Api/MailboxManagement.cs @@ -41,7 +41,7 @@ public static class MailboxManagement if(action is not ("disconnect" or "retry"))return Results.NotFound(); if(!await Change(store,box,input.Version,action))return Results.Conflict(new{error="The mailbox changed, needs reconnection, or is waiting for its retry time. Refresh the status."}); await Session.Audit(store,c,action=="disconnect"?"Disconnected Google mailbox from GuestOps":"Requested a fresh mailbox import pass");return Results.Ok(View(box)); - }).RequireAuthorization("Owner").RequireRateLimiting("accounts"); + }).RequireAuthorization(Access.Integrations).RequireRateLimiting("accounts"); } } diff --git a/src/GuestOps.Api/Models.cs b/src/GuestOps.Api/Models.cs index c1e99a4..c746ba8 100644 --- a/src/GuestOps.Api/Models.cs +++ b/src/GuestOps.Api/Models.cs @@ -33,7 +33,32 @@ public class StaffUser : TenantDocument public string Name { get; set; } = ""; public string PasswordHash { get; set; } = ""; public string Role { get; set; } = "Owner"; + public int RoleVersion { get; set; } = 1; public bool Active { get; set; } = true; + public string MfaSecretProtected { get; set; } = ""; + public string MfaPendingSecretProtected { get; set; } = ""; + public DateTime? MfaEnabledAt { get; set; } + public long? LastTotpStep { get; set; } + public string[] RecoveryCodeHashes { get; set; } = []; + public DateTime? EmailVerifiedAt { get; set; } + public string DisplayTimeZone { get; set; } = ""; + public string DefaultInboxFilter { get; set; } = "All"; +} +public class AccountMail : TenantDocument +{ + public long Version { get; set; } + public string UserId { get; set; } = ""; + public string Purpose { get; set; } = ""; + public string ProtectedPayload { get; set; } = ""; + public string MessageId { get; set; } = ""; + public string State { get; set; } = "Pending"; + public int AttemptCount { get; set; } + public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public string LeaseOwner { get; set; } = ""; + public DateTime? LeaseUntil { get; set; } + public DateTime? CompletedAt { get; set; } + public string ErrorCategory { get; set; } = ""; } public class KnowledgeEntry : TenantDocument { @@ -108,6 +133,7 @@ public class WorkerLease public DateTime Until { get; set; } } public record LoginInput(string Email, string Password); +public record PreferencesInput(string DisplayTimeZone, string DefaultInboxFilter, long Version); public record SettingsInput(string Name, string Timezone, string Signature, long Version); public record DraftInput(string Draft, long Version); public record StatusInput(string Status, long Version); diff --git a/src/GuestOps.Api/Operations.cs b/src/GuestOps.Api/Operations.cs index 372ffd2..4f457cb 100644 --- a/src/GuestOps.Api/Operations.cs +++ b/src/GuestOps.Api/Operations.cs @@ -22,8 +22,8 @@ public static class Operations }); api.MapGet("/operations",async(HttpContext c,IStore store)=> { - var id=Session.Hotel(c);var hotel=await store.Get(id,id);var boxes=await store.List(id);var messages=await store.List(id);var seen=await store.WorkerLastSeen(); - return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seenx.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")}}); - }).RequireAuthorization("Owner"); + var id=Session.Hotel(c);var hotel=await store.Get(id,id);var boxes=await store.List(id);var messages=await store.List(id);var accountMail=await store.List(id);var seen=await store.WorkerLastSeen(); + return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seenx.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")},accountMail=new{pending=accountMail.Count(x=>x.State is "Pending" or "Sending"),needsReview=accountMail.Count(x=>x.State=="NeedsReview"),failed=accountMail.Count(x=>x.State is "Failed" or "Expired")}}); + }).RequireAuthorization(Access.Operations); } } diff --git a/src/GuestOps.Api/PaymentEndpoints.cs b/src/GuestOps.Api/PaymentEndpoints.cs index fa6f727..338f413 100644 --- a/src/GuestOps.Api/PaymentEndpoints.cs +++ b/src/GuestOps.Api/PaymentEndpoints.cs @@ -4,29 +4,29 @@ public static class PaymentEndpoints { public static void Map(RouteGroupBuilder api,bool preview) { - var group=api.MapGroup("/payments").RequireRateLimiting("pms"); + var group=api.MapGroup("/payments").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup); group.MapGet("/status",(HttpContext c,IConfiguration config)=>{var p=preview?null:NmiProfile.Read(config,Session.Hotel(c));return Results.Ok(new{configured=p!=null,createsConfigured=p?.CreatesEnabled==true,sandbox=p?.BaseUrl=="https://sandbox.nmi.com",preview});}); group.MapGet("/requests",async(HttpContext c,IStore store)=>Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(p=>p.UpdatedAt).Select(p=>p.View()))); group.MapPost("/requests",async(PaymentInput input,HttpContext c,PaymentWork work,IStore store)=>{ if(preview)return Results.BadRequest(new{error="Real payment creation is disabled in preview."}); var p=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input);await Session.Audit(store,c,"Prepared payment request for review");return Results.Ok(p.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ProviderProposal); group.MapPost("/requests/{id}/create",async(string id,PaymentApproval input,HttpContext c,PaymentWork work,IStore store)=>{ var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Create(p,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.EmailAndAmountApproved,c.RequestAborted);await Session.Audit(store,c,"Payment creation result: "+result.State);return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPost("/requests/{id}/check",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{ var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Check(p,input.Version,c.RequestAborted);await Session.Audit(store,c,"Checked payment invoice: "+result.State);return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPost("/requests/{id}/cancel",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{ var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Cancel(p,input.Version);await Session.Audit(store,c,"Cancelled unsubmitted payment proposal");return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>{ if(input.Enabled&&(preview||NmiProfile.Read(config,Session.Hotel(c))?.CreatesEnabled!=true))return Results.BadRequest(new{error="Administrator payment enablement and sandbox acceptance are required first."}); var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PaymentsEnabled=input.Enabled;hotel.Version=input.Version+1; if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Updated payment creation control");return Results.Ok(hotel); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.Integrations); } } diff --git a/src/GuestOps.Api/PmsEndpoints.cs b/src/GuestOps.Api/PmsEndpoints.cs index d1bb79f..700135b 100644 --- a/src/GuestOps.Api/PmsEndpoints.cs +++ b/src/GuestOps.Api/PmsEndpoints.cs @@ -5,7 +5,7 @@ public static class PmsEndpoints { public static void Map(RouteGroupBuilder api,bool preview) { - var group=api.MapGroup("/pms").RequireRateLimiting("pms"); + var group=api.MapGroup("/pms").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup); group.MapGet("/status",(HttpContext c,IConfiguration config)=> { var profile=preview?null:OhipProfile.Read(config,Session.Hotel(c)); @@ -23,29 +23,29 @@ public static class PmsEndpoints if(preview)return Results.BadRequest(); var change=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,snapshot,input); await Session.Audit(store,c,"Prepared a PMS change for review");return Results.Ok(change.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ProviderProposal); group.MapPost("/changes/{id}/apply",async(string id,PmsApproveInput input,HttpContext c,PmsWork work,IStore store)=> { var change=await store.Get(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Apply(change,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.AvailabilityAndPriceChecked,c.RequestAborted); await Session.Audit(store,c,"PMS change result: "+result.State);return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPost("/changes/{id}/verify",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=> { var change=await store.Get(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Verify(change,input.Version,c.RequestAborted);await Session.Audit(store,c,"Verified PMS state: "+result.State);return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPost("/changes/{id}/cancel",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=> { var change=await store.Get(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest(); var result=await work.Cancel(change,input.Version);await Session.Audit(store,c,"Cancelled an unapplied PMS proposal");return Results.Ok(result.View()); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.ExternalApproval); group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=> { if(input.Enabled&&(preview||OhipProfile.Read(config,Session.Hotel(c))?.WritesEnabled!=true))return Results.BadRequest(new{error="Server-side PMS writes must be enabled after sandbox acceptance first."}); var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PmsUpdatesEnabled=input.Enabled;hotel.Version=input.Version+1; if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict(); await Session.Audit(store,c,"Updated PMS write controls");return Results.Ok(hotel); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.Integrations); } } diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 16938e4..eae8cf9 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -13,8 +13,9 @@ using System.Net; var bootstrap = args.Contains("--bootstrap"); var recoverOwner = args.Contains("--recover-owner"); +var resetOwnerMfa = args.Contains("--reset-owner-mfa"); var backupProbe=args.Contains("--backup-probe");var verifyBackupProbe=args.Contains("--verify-backup-probe"); -var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray()); +var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--reset-owner-mfa" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray()); if (builder.Configuration["Pms:ConfigFile"] is { Length: > 0 } pmsConfigFile) builder.Configuration.AddJsonFile(pmsConfigFile,optional:false,reloadOnChange:false); if (builder.Configuration["Payments:ConfigFile"] is { Length: > 0 } paymentConfigFile) builder.Configuration.AddJsonFile(paymentConfigFile,optional:false,reloadOnChange:false); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); @@ -38,6 +39,12 @@ builder.Services.AddHttpClient(c=>c.Timeout=TimeSpan.FromSeconds(25 builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax; @@ -49,10 +56,17 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc { var hotel = c.Principal?.FindFirstValue("hotel"); var id = c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); var user = hotel == null || id == null ? null : await c.HttpContext.RequestServices.GetRequiredService().Get(hotel, id); - if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal(); + var normalized=user==null?null:Access.NormalizeRole(user.Role);var claimed=c.Principal?.FindFirstValue(ClaimTypes.Role); + var mfaRequired=c.HttpContext.RequestServices.GetRequiredService().GetValue("Identity:RequireMfa"); + if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || normalized != claimed || mfaRequired&&c.Principal?.FindFirstValue("mfa")!="true") c.RejectPrincipal(); }; }); -builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner"))); +builder.Services.AddAuthorization(o => +{ + o.AddPolicy("Owner",p=>p.RequireRole(Access.Owner)); + foreach(var permission in new[]{Access.Inbox,Access.Send,Access.Knowledge,Access.HotelSettings,Access.Audit,Access.Operations,Access.Team,Access.Security,Access.Integrations,Access.ExternalApproval,Access.AutomationTest,Access.AutomationLive,Access.ProviderLookup,Access.ProviderProposal}) + o.AddPolicy(permission,p=>p.RequireAssertion(c=>Access.Has(c.User,permission))); +}); builder.Services.Configure(o => { // Trust scheme and client address only from the explicitly configured host proxy. @@ -90,6 +104,13 @@ if(recoverOwner) var recovery=await app.Services.GetRequiredService().RecoverOwner(user); Console.WriteLine("Private single-use recovery link (expires in 30 minutes). Share only with the verified account owner:");Console.WriteLine(recovery.Link);return; } +if(resetOwnerMfa) +{ + var email=Environment.GetEnvironmentVariable("RECOVERY_EMAIL")?.Trim().ToLowerInvariant()??"";var user=await store.FindLogin(email); + if(user==null||!user.Active||Access.NormalizeRole(user.Role)!=Access.Owner)throw new InvalidOperationException("An active owner account is required."); + var version=user.Version;MfaService.Reset(user);user.Version++;if(!await store.Replace(user.HotelId,user.Id,version,user))throw new InvalidOperationException("The owner account changed; run the command again."); + await store.Insert(new Activity{HotelId=user.HotelId,UserName="Server administrator",Action="Reset owner MFA after identity verification"});Console.WriteLine("Owner MFA reset and all existing sessions invalidated.");return; +} if (bootstrap) { var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? ""; @@ -133,11 +154,15 @@ app.Use(async (ctx, next) => await next(); }); app.MapGet("/health", () => Results.Ok(new { status = "ready" })); -app.MapGet("/api/session", (HttpContext c, IAntiforgery csrf) => Results.Ok(new +app.MapGet("/api/session", async (HttpContext c, IAntiforgery csrf) => { - preview, csrfToken = csrf.GetAndStoreTokens(c).RequestToken, - user = c.User.Identity?.IsAuthenticated == true ? new { id = c.User.FindFirstValue(ClaimTypes.NameIdentifier), name = c.User.Identity.Name, role = c.User.FindFirstValue(ClaimTypes.Role), hotelId = c.User.FindFirstValue("hotel") } : null -})); + object? view=null;if(c.User.Identity?.IsAuthenticated==true) + { + var hotelId=Session.Hotel(c);var user=await store.Get(hotelId,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);var hotel=await store.Get(hotelId,hotelId); + if(user!=null)view=new{id=user.Id,name=user.Name,role=Access.NormalizeRole(user.Role),hotelId,permissions=Access.Permissions(user.Role),mfaEnabled=user.MfaEnabledAt!=null,preferences=IdentityEndpoints.ViewPreferences(user,hotel)}; + } + return Results.Ok(new{preview,csrfToken=csrf.GetAndStoreTokens(c).RequestToken,user=view}); +}); app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPasswordHasher hasher) => { if (input.Email == null || input.Password == null || input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." }); @@ -147,18 +172,23 @@ app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPassword var hash = string.IsNullOrEmpty(user?.PasswordHash) ? Input.DummyHash : user.PasswordHash; if (hasher.VerifyHashedPassword(checkUser, hash, input.Password) == PasswordVerificationResult.Failed || user?.Active != true) return Results.Json(new { error = "Email or password is incorrect." }, statusCode: 401); - await Session.SignIn(c, user); return Results.Ok(); + if(builder.Configuration.GetValue("Identity:RequireMfa")) + { + app.Services.GetRequiredService().Write(c,user);return Results.Json(new{mfaRequired=true,enrollmentRequired=user.MfaEnabledAt==null},statusCode:202); + } + await Session.SignIn(c, user,false); return Results.Ok(new{mfaRequired=false}); }).RequireRateLimiting("login"); app.MapPost("/api/auth/logout", async (HttpContext c, CancellationToken _) => { await c.SignOutAsync(); return Results.Ok(); }).RequireAuthorization(); if (preview) app.MapPost("/api/preview/start", async (HttpContext c, CancellationToken _) => { var user = await Demo.Seed(store); await Session.SignIn(c, user); return Results.Ok(); }).RequireRateLimiting("login"); var api = app.MapGroup("/api").RequireAuthorization(); +IdentityEndpoints.Map(app,api); PmsEndpoints.Map(api,preview); PaymentEndpoints.Map(api,preview); AutoReplyEndpoints.Map(api,preview); TeamEndpoints.Map(app,api,preview); MailboxManagement.Map(api,preview); Operations.Map(app,api,preview); -api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))); +api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))).RequireAuthorization(Access.Inbox); api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => { if (!Input.Text(input.Name, 2, 120) || !Input.Text(input.Signature, 0, 2000)) return Results.BadRequest(new { error = "Enter a hotel name and a signature under 2,000 characters." }); @@ -167,10 +197,10 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => hotel.Name = input.Name.Trim(); hotel.Signature = input.Signature; hotel.Timezone = input.Timezone; hotel.Version = input.Version + 1; if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict(); await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel); -}).RequireAuthorization("Owner"); -api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))); -api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."})); -api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()); +}).RequireAuthorization(Access.HotelSettings); +api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))).RequireAuthorization(Access.Inbox); +api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."})).RequireAuthorization(Access.Inbox); +api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()).RequireAuthorization(Access.Inbox); api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) => { if (!Input.Text(input.Draft, 0, 20000)) return Results.BadRequest(new { error = "Draft must be under 20,000 characters." }); @@ -179,7 +209,7 @@ api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, Http item.Draft = input.Draft; item.Version = input.Version + 1; if (item.Status != "Completed") item.Status = input.Draft.Length > 0 ? "DraftReady" : "NeedsAttention"; if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Saved a reply draft"); return Results.Ok(item); -}); +}).RequireAuthorization(Access.Inbox); api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, HttpContext c) => { if (input.Status is not ("Completed" or "NeedsAttention")) return Results.BadRequest(); @@ -188,14 +218,14 @@ api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, Ht item.Status = input.Status; item.Version = input.Version + 1; if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, input.Status == "Completed" ? "Resolved a conversation" : "Reopened a conversation"); return Results.Ok(item); -}); -api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List(Session.Hotel(c)))); +}).RequireAuthorization(Access.Inbox); +api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List(Session.Hotel(c)))).RequireAuthorization(Access.Inbox); api.MapPost("/knowledge", async (KnowledgeInput input, HttpContext c) => { if (!Input.Knowledge(input)) return Results.BadRequest(new { error = "Enter a title and answer within the allowed lengths." }); var item = new KnowledgeEntry { HotelId = Session.Hotel(c), Title = input.Title, Category = input.Category, Answer = input.Answer, Keywords = input.Keywords, Approved = input.Approved }; await store.Insert(item); await Session.Audit(store, c, "Added a hotel knowledge entry"); return Results.Ok(item); -}).RequireAuthorization("Owner"); +}).RequireAuthorization(Access.Knowledge); api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContext c) => { if (!Input.Knowledge(input)) return Results.BadRequest(); @@ -203,9 +233,9 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex item.Title = input.Title; item.Category = input.Category; item.Answer = input.Answer; item.Keywords = input.Keywords; item.Approved = input.Approved; item.Version = input.Version + 1; if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item); -}).RequireAuthorization("Owner"); -api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))); -api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get(hotel,hotel))?.Timezone??"UTC", items = (await store.List(hotel)).Select(MailboxManagement.View) }); }); +}).RequireAuthorization(Access.Knowledge); +api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))).RequireAuthorization(Access.Audit); +api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get(hotel,hotel))?.Timezone??"UTC", items = (await store.List(hotel)).Select(MailboxManagement.View) }); }).RequireAuthorization(Access.Inbox); api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) => { if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." }); @@ -213,7 +243,7 @@ api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, Go hotel.AiDraftsEnabled = input.AiDraftsEnabled; hotel.StaffSendingEnabled = input.StaffSendingEnabled; hotel.ReplyMode = input.StaffSendingEnabled ? "StaffApproved" : "DraftOnly"; hotel.Version = input.Version + 1; if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict(); await Session.Audit(store, c, "Updated AI and staff sending controls"); return Results.Ok(hotel); -}).RequireAuthorization("Owner"); +}).RequireAuthorization(Access.Integrations); api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input, HttpContext c, AiDrafts ai) => { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); @@ -233,7 +263,7 @@ api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input item.Status = result.NeedsReview ? "NeedsAttention" : "DraftReady"; item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, result.NeedsReview ? "AI requested staff handling" : "Generated a draft for staff review"); return Results.Ok(item); -}).RequireRateLimiting("ai"); +}).RequireAuthorization(Access.Inbox).RequireRateLimiting("ai"); api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpContext c, GoogleMailbox google) => { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); @@ -247,13 +277,13 @@ api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpC item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Approved a saved reply for Gmail delivery"); return Results.Ok(item); -}); +}).RequireAuthorization(Access.Send); api.MapPost("/conversations/{id}/delivery/release",async(string id,VersionInput input,HttpContext c,AutoReplyWork work)=> { var item=await store.Get(Session.Hotel(c),id);if(item==null)return Results.NotFound(); if(preview||!await work.ReturnToStaff(item,input.Version))return Input.Conflict(); await Session.Audit(store,c,"Returned an unsubmitted automatic reply to staff review");return Results.Ok(item); -}); +}).RequireAuthorization(Access.Integrations); api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput input, HttpContext c) => { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); @@ -264,7 +294,7 @@ api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput item.Delivery.State = "Pending"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Retried a reply that had not reached Gmail sending"); return Results.Ok(item); -}); +}).RequireAuthorization(Access.Integrations); api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInput input, HttpContext c, GoogleMailbox google) => { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); @@ -275,13 +305,13 @@ api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInpu item.Delivery.ProviderId = found; item.Delivery.State = "Sent"; item.Delivery.Detail = "Verified in Gmail Sent"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Status = "Completed"; item.Version++; if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Verified uncertain delivery in Gmail Sent"); return Results.Ok(item); -}); +}).RequireAuthorization(Access.Integrations); api.MapPost("/integrations/google/connect", async (HttpContext c, GoogleMailbox google) => { if (preview || !google.Configured) return Results.BadRequest(new { error = "Google connection has not been configured by the administrator." }); var state = new OAuthRequest { Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), HotelId = Session.Hotel(c), UserId = c.User.FindFirstValue(ClaimTypes.NameIdentifier)!, ExpiresAt = DateTime.UtcNow.AddMinutes(10) }; await store.Insert(state); return Results.Ok(new { url = google.AuthorizationUrl(state.Id) }); -}).RequireAuthorization("Owner"); +}).RequireAuthorization(Access.Integrations); api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox google) => { if (preview) return Results.BadRequest(); @@ -291,7 +321,7 @@ api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString(),state.StartedAt,state.ExpectedEmail); await Session.Audit(store, c, "Connected Google mailbox"); } catch { return Results.Redirect("/settings?google=failed"); } return Results.Redirect("/settings?google=connected"); -}).RequireAuthorization("Owner"); +}).RequireAuthorization(Access.Integrations); app.UseDefaultFiles(); app.UseStaticFiles(); app.MapFallbackToFile("index.html"); app.Run(); @@ -301,7 +331,7 @@ namespace GuestOps.Web public static class Session { public static string Hotel(HttpContext c) => c.User.FindFirstValue("hotel") ?? throw new InvalidOperationException("Missing hotel membership"); - public static Task SignIn(HttpContext c, StaffUser u) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, u.Role), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp) }, CookieAuthenticationDefaults.AuthenticationScheme))); + public static Task SignIn(HttpContext c, StaffUser u,bool mfa=false) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, Access.NormalizeRole(u.Role)), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp),new Claim("mfa",mfa?"true":"false") }, CookieAuthenticationDefaults.AuthenticationScheme))); public static Task Audit(IStore store, HttpContext c, string action) => store.Insert(new Activity { HotelId = Hotel(c), UserName = c.User.Identity?.Name ?? "Staff", Action = action }); } public static class Input diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs index 5808baf..f6d4e90 100644 --- a/src/GuestOps.Api/Store.cs +++ b/src/GuestOps.Api/Store.cs @@ -29,6 +29,8 @@ public interface IStore Task ReleaseLease(string id, string owner); Task Import(Conversation message); Task> Deliveries(); + Task> AccountMails(); + Task ClaimAccountMail(string id, string owner); Task TryInsertPmsChange(PmsChange change); Task TryInsertPayment(PaymentRequest payment); Task TryAutoReplyClaim(AutoReplyClaim claim); @@ -40,6 +42,11 @@ public sealed class MongoStore : IStore public async Task WorkerLastSeen()=>(await db.GetCollection("workerheartbeat").Find(x=>x.Id=="worker").FirstOrDefaultAsync())?.At; public async Task RecordWorkerHeartbeat()=>await db.GetCollection("workerheartbeat").ReplaceOneAsync(x=>x.Id=="worker",new WorkerHeartbeat(),new ReplaceOptions{IsUpsert=true}); public Task> Deliveries() => Collection().Find(x => x.Delivery != null && (x.Delivery.State == "Pending" || x.Delivery.State == "Sending")).SortBy(x => x.Delivery!.UpdatedAt).Limit(100).ToListAsync(); + public Task> AccountMails() => Collection().Find(x => (x.State == "Pending" || x.State == "Sending") && x.NextAttemptAt <= DateTime.UtcNow).SortBy(x => x.NextAttemptAt).Limit(100).ToListAsync(); + public async Task ClaimAccountMail(string id,string owner)=>await Collection().FindOneAndUpdateAsync( + x=>x.Id==id&&(x.State=="Pending"||(x.State=="Sending"&&x.LeaseUntil.Update.Set(x=>x.State,"Sending").Set(x=>x.LeaseOwner,owner).Set(x=>x.LeaseUntil,DateTime.UtcNow.AddMinutes(2)).Inc(x=>x.Version,1), + new(){ReturnDocument=ReturnDocument.After}); private readonly IMongoDatabase db; public MongoStore(IConfiguration config) { @@ -57,6 +64,8 @@ public sealed class MongoStore : IStore public async Task Initialize() { await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.AccountLinkHash))); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.State).Ascending(x=>x.NextAttemptAt))); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.MessageId),new(){Unique=true})); foreach(var field in new[]{"ThreadKey","RecipientDay","DaySlot"})await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(field),new(){Unique=true})); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.MailboxId).Ascending(x=>x.AutoReplyCheckedAt).Ascending(x=>x.ReceivedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true})); @@ -179,6 +188,16 @@ public sealed class PreviewStore : IStore } } public Task> Deliveries() => Task.FromResult(rows.Where(x => x.Key.StartsWith("Conversation:")).Select(x => Clone(x.Value)).Where(x => x.Delivery?.State is "Pending" or "Sending").ToList()); + public Task> AccountMails()=>Task.FromResult(rows.Where(x=>x.Key.StartsWith("AccountMail:")).Select(x=>Clone(x.Value)).Where(x=>x.State is "Pending" or "Sending"&&x.NextAttemptAt<=DateTime.UtcNow).ToList()); + public Task ClaimAccountMail(string id,string owner) + { + lock(gate) + { + if(!rows.TryGetValue(Key(id),out var raw))return Task.FromResult(null); + var mail=Clone(raw);if(mail.NextAttemptAt>DateTime.UtcNow||mail.State!="Pending"&&!(mail.State=="Sending"&&mail.LeaseUntil(null); + mail.State="Sending";mail.LeaseOwner=owner;mail.LeaseUntil=DateTime.UtcNow.AddMinutes(2);mail.Version++;rows[Key(id)]=Json(mail);return Task.FromResult(mail); + } + } private readonly ConcurrentDictionary rows = new(); private readonly object gate = new(); static string Key(string id) => typeof(T).Name + ":" + id; diff --git a/src/GuestOps.Api/TeamAccounts.cs b/src/GuestOps.Api/TeamAccounts.cs index 9d9c855..a87e812 100644 --- a/src/GuestOps.Api/TeamAccounts.cs +++ b/src/GuestOps.Api/TeamAccounts.cs @@ -3,15 +3,15 @@ using System.Text; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Identity; namespace GuestOps.Web; -public sealed record InviteInput(string Name,string Email); +public sealed record InviteInput(string Name,string Email,string Role="Agent"); public sealed record AccountTokenInput(string Token); public sealed record AccountAcceptInput(string Token,string Password,string ConfirmPassword); -public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt); +public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt,string DeliveryState="Preview"); public sealed class AccountInvalid(string message):Exception(message); public sealed class AccountConflict(string message):Exception(message); -public sealed class TeamAccounts(IStore store,IPasswordHasher hasher,IConfiguration config) +public sealed class TeamAccounts(IStore store,IPasswordHasher hasher,IConfiguration config,AccountMailService? mail=null) { - public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,user.Role,user.Active,user.Version,pending=user.PasswordHash.Length==0,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt}; + public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,role=Access.NormalizeRole(user.Role),legacyRole=user.Role=="Staff",user.Active,user.Version,pending=user.PasswordHash.Length==0,mfaEnabled=user.MfaEnabledAt!=null,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt}; public static bool SessionValid(StaffUser user,string? stamp)=>user.Active&&user.SecurityStamp==(stamp??""); public static bool PasswordValid(string? password)=>password!=null&&password.Length>=14&&password.Length<=128; static string Hash(string token)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); @@ -27,19 +27,20 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher hasher, if(!Input.Text(input.Name,2,100)||!Input.Text(input.Email,3,254))throw new AccountInvalid("Enter a staff name and email address."); var email=input.Email.Trim().ToLowerInvariant();if(!Input.Email(email)||email.Any(char.IsControl))throw new AccountInvalid("Enter one plain staff email address."); _=BaseUrl(); + var role=Access.NormalizeRole(input.Role);if(role==Access.Owner||!Access.Roles.Contains(role))throw new AccountInvalid("Choose Manager, Agent, or Auditor."); var user=await store.FindLogin(email); - if(user!=null&&(user.HotelId!=hotel||user.Role!="Staff"||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator."); + if(user!=null&&(user.HotelId!=hotel||Access.NormalizeRole(user.Role)==Access.Owner||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator."); if(user==null) { if((await store.List(hotel)).Count>=50)throw new AccountInvalid("This hotel has reached the 50-account pilot limit. Contact the administrator."); - user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role="Staff",Active=false}; + user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role=role,RoleVersion=1,Active=false}; if(!await store.TryInsertStaff(user))throw new AccountConflict("The account changed elsewhere. Refresh the team list."); } - user.Name=input.Name.Trim();return await Issue(user,"Invite",TimeSpan.FromHours(48)); + user.Name=input.Name.Trim();user.Role=role;return await Issue(user,"Invite",TimeSpan.FromHours(48)); } public Task ResetStaff(StaffUser user,long version) { - if(user.Role!="Staff"||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current active staff account can receive a recovery link."); + if(Access.NormalizeRole(user.Role)==Access.Owner||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current active team account can receive a recovery link."); return Issue(user,"Reset",TimeSpan.FromMinutes(30)); } public Task RecoverOwner(StaffUser user) @@ -48,7 +49,7 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher hasher, } public Task Restore(StaffUser user,long version) { - if(user.Role!="Staff"||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current disabled staff account can be restored."); + if(Access.NormalizeRole(user.Role)==Access.Owner||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current disabled team account can be restored."); return Issue(user,"Restore",TimeSpan.FromHours(48)); } async Task Issue(StaffUser user,string purpose,TimeSpan lifetime) @@ -56,36 +57,55 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher hasher, var root=BaseUrl();var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var version=user.Version; user.AccountLinkHash=Hash(token);user.AccountLinkPurpose=purpose;user.AccountLinkExpiresAt=DateTime.UtcNow.Add(lifetime);user.Version++; if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Refresh and issue a new link."); - return new(user.Id,root+"/account#token="+token,user.AccountLinkExpiresAt.Value); + var link=root+"/account#token="+token; + if(mail!=null&&!mail.Preview) + { + var subject=purpose=="Invite"?"Your GuestOps invitation":"Your GuestOps account recovery link"; + await mail.Queue(user,purpose,subject,"This single-use link expires at "+user.AccountLinkExpiresAt.Value.ToString("O")+".",link,user.AccountLinkExpiresAt.Value); + return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Pending"); + } + return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Preview"); } public async Task Inspect(string? token) { if(token==null||!Regex.IsMatch(token,"^[A-F0-9]{64}$"))return null; var user=await store.FindAccountLink(Hash(token)); if(user==null||user.AccountLinkExpiresAt<=DateTime.UtcNow||user.AccountLinkExpiresAt==null)return null; - if(user.AccountLinkPurpose=="Invite"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length==0)return user; - if(user.AccountLinkPurpose=="Restore"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length>0)return user; + if(user.AccountLinkPurpose=="Invite"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length==0)return user; + if(user.AccountLinkPurpose=="Restore"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length>0)return user; return user.AccountLinkPurpose=="Reset"&&user.Active&&user.PasswordHash.Length>0?user:null; } public async Task Accept(AccountAcceptInput input) { if(!PasswordValid(input.Password)||input.Password!=input.ConfirmPassword)throw new AccountInvalid("Use matching passwords of 14 to 128 characters."); var user=await Inspect(input.Token);if(user==null)return false; - var hash=user.AccountLinkHash;var version=user.Version; + var hash=user.AccountLinkHash;var version=user.Version;var purpose=user.AccountLinkPurpose; user.PasswordHash=hasher.HashPassword(user,input.Password);user.Active=true;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++; + if(purpose=="Invite")user.EmailVerifiedAt=DateTime.UtcNow; user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null; - return await store.ConsumeAccountLink(user,version,hash); + var changed=await store.ConsumeAccountLink(user,version,hash);if(changed&&mail!=null)await mail.Notify(user,"PasswordChanged","Your GuestOps password was changed. Contact the hotel owner immediately if this was not you.");return changed; } public async Task Disable(StaffUser user,long version) { - if(user.Role!="Staff"||user.Version!=version)return false; + if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false; user.Active=false;user.SecurityStamp=Guid.NewGuid().ToString("N");user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++; - return await store.Replace(user.HotelId,user.Id,version,user); + var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"AccountDisabled","Your GuestOps account was disabled.");return changed; } public async Task Revoke(StaffUser user,long version) { - if(user.Role!="Staff"||user.Version!=version)return false; + if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false; user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++; return await store.Replace(user.HotelId,user.Id,version,user); } -} + public async Task ChangeRole(StaffUser user,long version,string role) + { + role=Access.NormalizeRole(role);if(role==Access.Owner||!Access.Roles.Contains(role)||user.Version!=version||Access.NormalizeRole(user.Role)==Access.Owner)return false; + user.Role=role;user.RoleVersion=1;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++; + var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"RoleChanged","Your GuestOps role changed to "+role+".");return changed; + } + public async Task ResetMfa(StaffUser user,long version) + { + if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false; + MfaService.Reset(user);user.Version++;var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"MfaReset","MFA was reset for your GuestOps account. You must enroll again before the next MFA-enforced sign-in.");return changed; + } +} diff --git a/src/GuestOps.Api/TeamEndpoints.cs b/src/GuestOps.Api/TeamEndpoints.cs index b27e0e8..eb1f481 100644 --- a/src/GuestOps.Api/TeamEndpoints.cs +++ b/src/GuestOps.Api/TeamEndpoints.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; namespace GuestOps.Web; public static class TeamEndpoints { + static object Delivery(AccountLinkResult result,bool preview)=>preview?new{result.UserId,result.DeliveryState,result.ExpiresAt,result.Link}:(object)new{result.UserId,result.DeliveryState,result.ExpiresAt}; public static void Map(WebApplication app,RouteGroupBuilder api,bool preview) { app.MapPost("/api/account-links/inspect",async(AccountTokenInput input,TeamAccounts accounts,IStore store)=> @@ -17,25 +19,36 @@ public static class TeamEndpoints await store.Insert(new Activity{HotelId=user.HotelId,UserName=user.Name,Action=user.AccountLinkPurpose=="Invite"?"Accepted staff invitation":"Changed account password"}); await c.SignOutAsync();return Results.Ok(); }).RequireRateLimiting("accounts"); - var team=api.MapGroup("/team").RequireAuthorization("Owner"); + var team=api.MapGroup("/team").RequireAuthorization(Access.Team); team.MapGet("/",async(HttpContext c,IStore store)=>Results.Ok((await store.List(Session.Hotel(c))).Select(TeamAccounts.View))); team.MapPost("/invite",async(InviteInput input,HttpContext c,IStore store,TeamAccounts accounts)=> { - var result=await accounts.Invite(Session.Hotel(c),input);await Session.Audit(store,c,"Issued a staff invitation link");return Results.Ok(result); + var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role); + if(!Access.CanManage(actor,role))return Results.Forbid(); + var result=await accounts.Invite(Session.Hotel(c),input with{Role=role});await Session.Audit(store,c,"Issued a team invitation");return Results.Ok(Delivery(result,preview)); }).RequireRateLimiting("accounts"); team.MapPost("/{id}/{action}",async(string id,string action,VersionInput input,HttpContext c,IStore store,TeamAccounts accounts)=> { var user=await store.Get(Session.Hotel(c),id);if(user==null)return Results.NotFound(); - if(user.Role!="Staff")return Results.BadRequest(new {error="Owner accounts are managed by the server administrator."}); + var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";if(!Access.CanManage(actor,user.Role))return Results.Forbid(); if(action is "reset" or "restore") { var result=action=="reset"?await accounts.ResetStaff(user,input.Version):await accounts.Restore(user,input.Version); - await Session.Audit(store,c,action=="reset"?"Issued a staff password recovery link":"Issued a staff restoration link");return Results.Ok(result); + await Session.Audit(store,c,action=="reset"?"Issued a team password recovery":"Issued a team restoration");return Results.Ok(Delivery(result,preview)); + } + if(action=="mfa-reset") + { + if(Access.NormalizeRole(actor)!=Access.Owner)return Results.Forbid();if(!await accounts.ResetMfa(user,input.Version))return Input.Conflict();await Session.Audit(store,c,"Reset team MFA after identity verification");return Results.Ok(); } if(action is not ("disable" or "revoke"))return Results.NotFound(); if(!(action=="disable"?await accounts.Disable(user,input.Version):await accounts.Revoke(user,input.Version)))return Input.Conflict(); - await Session.Audit(store,c,action=="disable"?"Disabled a staff account":"Revoked a staff account link");return Results.Ok(); + await Session.Audit(store,c,action=="disable"?"Disabled a team account":"Revoked a team account link");return Results.Ok(); }).RequireRateLimiting("accounts"); + team.MapPut("/{id}/role",async(string id,RoleInput input,HttpContext c,IStore store,TeamAccounts accounts)=> + { + var user=await store.Get(Session.Hotel(c),id);if(user==null)return Results.NotFound();var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role); + if(!Access.CanManage(actor,user.Role)||!Access.CanManage(actor,role))return Results.Forbid();if(!await accounts.ChangeRole(user,input.Version,role))return Input.Conflict();await Session.Audit(store,c,"Changed a team role to "+role);return Results.Ok(TeamAccounts.View(user)); + }); api.MapGet("/onboarding",async(HttpContext c,IStore store)=> { var id=Session.Hotel(c);var hotel=await store.Get(id,id); @@ -44,10 +57,11 @@ public static class TeamEndpoints new {title="Check your hotel details",detail="Review the hotel name, timezone and email signature.",path="/settings",complete=hotel!=null&&hotel.Name.Length>=2&&hotel.Signature.Length>0,optional=false}, new {title="Approve your guest answers",detail="Add current check-in, parking and breakfast information.",path="/knowledge",complete=knowledge.Any(x=>x.Approved),optional=false}, new {title="Connect the hotel mailbox",detail="Connect Google and check that guest messages appear in the inbox.",path="/settings",complete=mailboxes.Any(x=>x.Status=="Connected"&&x.LastSyncAt!=null),optional=false}, - new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>x.Role=="Staff"&&x.Active),optional=true}, + new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>Access.NormalizeRole(x.Role)!=Access.Owner&&x.Active),optional=true}, new {title="Test FAQ automation",detail="Review test results before enabling live replies. This checklist does not enable sending.",path="/automation",complete=hotel?.AutoReplyMode=="Test"||hotel?.AutoReplyMode=="Live",optional=true} }}); - }).RequireAuthorization("Owner"); + }).RequireAuthorization(Access.HotelSettings); } } +public sealed record RoleInput(string Role,long Version); diff --git a/src/GuestOps.Worker/Program.cs b/src/GuestOps.Worker/Program.cs index a8b52af..4f7ce8d 100644 --- a/src/GuestOps.Worker/Program.cs +++ b/src/GuestOps.Worker/Program.cs @@ -14,12 +14,36 @@ builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web").PersistK builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); await builder.Build().RunAsync(); +sealed class AccountMailWorker(IStore store,IServiceScopeFactory factory,ILogger log):BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while(!stoppingToken.IsCancellationRequested) + { + try + { + foreach(var item in await store.AccountMails()) + { + using var scope=factory.CreateScope();using var deadline=CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);deadline.CancelAfter(TimeSpan.FromMinutes(1)); + await scope.ServiceProvider.GetRequiredService().Process(item,deadline.Token); + } + } + catch(Exception ex) when(!stoppingToken.IsCancellationRequested){log.LogWarning("Account mail cycle paused ({Type})",ex.GetType().Name);} + await Task.Delay(TimeSpan.FromSeconds(10),stoppingToken); + } + } +} + sealed class HeartbeatWorker(IStore store,ILogger log):BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/tests/GuestOps.Tests/IdentitySecurityTests.cs b/tests/GuestOps.Tests/IdentitySecurityTests.cs new file mode 100644 index 0000000..0a4cb8b --- /dev/null +++ b/tests/GuestOps.Tests/IdentitySecurityTests.cs @@ -0,0 +1,49 @@ +using GuestOps.Web; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Mail; + +public static class IdentitySecurityTests +{ + sealed class FakeTransport(Func outcome):IAccountMailTransport + { + public int Calls; + public Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken){Calls++;var error=outcome(Calls);return error==null?Task.CompletedTask:Task.FromException(error);} + } + public static async Task Run(Action check,IStore store) + { + var secret=System.Text.Encoding.ASCII.GetBytes("12345678901234567890"); + check("TOTP matches RFC 6238 SHA1 vector truncated to six digits",Totp.Code(secret,1)=="287082"&&Totp.Code(secret,37037036)=="081804"); + var now=DateTimeOffset.FromUnixTimeSeconds(1_234_567_890).UtcDateTime;var step=Totp.Step(now);var current=Totp.Code(secret,step); + check("TOTP accepts a current code",Totp.Verify(secret,current,now,null)==step); + check("TOTP accepts the approved one-step clock window",Totp.Verify(secret,Totp.Code(secret,step-1),now,null)==step-1&&Totp.Verify(secret,Totp.Code(secret,step+1),now,null)==step+1); + check("TOTP rejects malformed and out-of-window codes",Totp.Verify(secret,"12345x",now,null)==null&&Totp.Verify(secret,Totp.Code(secret,step+2),now,null)==null); + check("TOTP rejects replayed time steps",Totp.Verify(secret,current,now,step)==null); + check("Base32 secret round-trips",Totp.Decode(Totp.Encode(secret)).SequenceEqual(secret)); + check("Legacy Staff role has Agent permissions",Access.NormalizeRole("Staff")==Access.Agent&&Access.Permissions("Staff").Contains(Access.Inbox)&&!Access.Permissions("Staff").Contains(Access.Team)); + check("Auditor cannot access guest workflows",Access.Permissions(Access.Auditor).Contains(Access.Audit)&&!Access.Permissions(Access.Auditor).Contains(Access.Inbox)); + check("Managers cannot manage Managers",Access.CanManage(Access.Manager,Access.Agent)&&Access.CanManage(Access.Manager,Access.Auditor)&&!Access.CanManage(Access.Manager,Access.Manager)); + + var protection=new EphemeralDataProtectionProvider();var hotel=Guid.NewGuid().ToString("N");var user=new StaffUser{HotelId=hotel,Email=hotel+"@example.invalid",Name="MFA user",SecurityStamp="initial",Role=Access.Agent};await store.Insert(user); + var mfa=new MfaService(store,protection);var enrollment=await mfa.Begin(user);var enrollmentSecret=Totp.Decode(enrollment.Secret);user=(await store.Get(hotel,user.Id))!;var code=Totp.Code(enrollmentSecret,Totp.Step(DateTime.UtcNow)); + var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(async _=>{var copy=(await store.Get(hotel,user.Id))!;return await mfa.Enable(copy,code);})); + check("Concurrent MFA enrollment verification succeeds once",attempts.Count(x=>x!=null)==1); + var codes=attempts.Single(x=>x!=null)!;user=(await store.Get(hotel,user.Id))!; + check("MFA stores protected secret and hashed recovery codes",user.MfaSecretProtected.Length>0&&!user.MfaSecretProtected.Contains(enrollment.Secret)&&user.RecoveryCodeHashes.Length==10&&codes.All(x=>!user.RecoveryCodeHashes.Contains(x))); + var used=await mfa.UseRecovery(user,codes[0]);check("Recovery code is consumed once",used!=null&&await mfa.UseRecovery((await store.Get(hotel,user.Id))!,codes[0])==null); + + var mailConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"Preview","false"}}).Build();var mail=new AccountMailService(store,protection,mailConfig); + var queued=await mail.Queue(user,"Test","Security notice","Safe body","https://example.invalid/private-token",DateTime.UtcNow.AddMinutes(5)); + check("Account mail protects address body and token at rest",!queued.ProtectedPayload.Contains(user.Email)&&!queued.ProtectedPayload.Contains("private-token")&&queued.MessageId.StartsWith('<')); + var success=new FakeTransport(_=>null);await new AccountMailProcessor(store,mail,success,NullLogger.Instance).Process(queued,CancellationToken.None); + check("Account mail completes one claimed delivery",(await store.Get(hotel,queued.Id))?.State=="Sent"&&success.Calls==1); + var ambiguous=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var uncertainTransport=new FakeTransport(_=>new SmtpException("ambiguous")); + await new AccountMailProcessor(store,mail,uncertainTransport,NullLogger.Instance).Process(ambiguous,CancellationToken.None); + check("Ambiguous SMTP outcome is never automatically retried",(await store.Get(hotel,ambiguous.Id))?.State=="NeedsReview"); + var transient=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var preSubmit=new FakeTransport(_=>new MailPreSubmissionException("not submitted")); + await new AccountMailProcessor(store,mail,preSubmit,NullLogger.Instance).Process(transient,CancellationToken.None);var pending=await store.Get(hotel,transient.Id); + check("Clearly pre-submission mail failure receives bounded retry",pending?.State=="Pending"&&pending.AttemptCount==1&&pending.NextAttemptAt>DateTime.UtcNow); + check("Preference filters are fixed presets",IdentityEndpoints.ValidFilter("DraftReady")&&!IdentityEndpoints.ValidFilter("OwnerOnly")); + } +} diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index f73237b..f6558e9 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -23,7 +23,8 @@ try await store.Ping(); if(uri!=null){await store.RecordWorkerHeartbeat();Check("Worker heartbeat persists in MongoDB",await store.WorkerLastSeen()>DateTime.UtcNow.AddMinutes(-1));} await MailboxTests.Run(Check, store); - await TeamTests.Run(Check, store); + await TeamTests.Run(Check, store); + await IdentitySecurityTests.Run(Check, store); await ReplyTests.Run(Check, store); await PmsTests.Run(Check, store); await PaymentTests.Run(Check, store); diff --git a/tests/GuestOps.Tests/TeamTests.cs b/tests/GuestOps.Tests/TeamTests.cs index 443f06b..824a712 100644 --- a/tests/GuestOps.Tests/TeamTests.cs +++ b/tests/GuestOps.Tests/TeamTests.cs @@ -14,7 +14,7 @@ public static class TeamTests var hasher=new PasswordHasher();var service=new TeamAccounts(store,hasher,config); var link=await service.Invite(hotel,new("Test Colleague",email));var token=Token(link); var user=(await store.Get(hotel,link.UserId))!; - check("Invitation stores hash and creates inactive Staff only",user.Role=="Staff"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64); + check("Invitation stores hash and creates inactive Agent only",user.Role=="Agent"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64); check("Account link uses configured HTTPS origin and fragment",link.Link.StartsWith("https://hotel.example.invalid/account#token=")&&!link.Link.Contains('?')); check("Token inspection rejects malformed token",await service.Inspect("bad")==null); bool denied=false;try{await service.Invite("foreign",new("Other Colleague",email));}catch(AccountInvalid){denied=true;}check("Invitation cannot claim another hotel's account",denied); diff --git a/tests/test_account_security_acceptance.py b/tests/test_account_security_acceptance.py new file mode 100644 index 0000000..abc2bdd --- /dev/null +++ b/tests/test_account_security_acceptance.py @@ -0,0 +1,30 @@ +import copy +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location("account_security_acceptance",ROOT/"deploy"/"account_security_acceptance.py") +validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator) + +def valid_record(): + record=json.loads((ROOT/"deploy"/"account-security-acceptance.example.json").read_text(encoding="utf-8")) + record.update(operator="Operator Name",reviewedBy="Independent Reviewer",securityReviewedBy="Security Reviewer",securityReviewEvidence=["restricted/security-review-001"]) + record["productionControls"]["enabledAfterSecurityReview"]=True + for scenario in record["scenarios"]:scenario.update(status="pass",evidence=["restricted/"+scenario["id"]]) + return record + +class AccountSecurityAcceptanceTests(unittest.TestCase): + def test_complete_record_passes(self): validator.validate(valid_record()) + def test_missing_scenario_fails(self): + record=valid_record();record["scenarios"].pop() + with self.assertRaisesRegex(ValueError,"exact milestone 19 scenario"):validator.validate(record) + def test_review_must_be_independent(self): + record=valid_record();record["securityReviewedBy"]=record["operator"] + with self.assertRaisesRegex(ValueError,"different people"):validator.validate(record) + def test_evidence_rejects_contact_data(self): + record=valid_record();record["scenarios"][0]["evidence"]=["person@example.invalid"] + with self.assertRaisesRegex(ValueError,"opaque evidence"):validator.validate(record) + +if __name__=="__main__":unittest.main() diff --git a/web/src/AutomationPage.tsx b/web/src/AutomationPage.tsx index 73cf013..dbf01e6 100644 --- a/web/src/AutomationPage.tsx +++ b/web/src/AutomationPage.tsx @@ -6,8 +6,8 @@ type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimi type Decision={matches:boolean;reason:string;body:string}; type Evaluation={total:number;passed:number;falsePositives:number;falseNegatives:number;results:{id:string;passed:boolean;reason:string}[]}; type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null}; -type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise)=>Promise;onHotel:(h:Hotel)=>void}; -export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){ +type Props={hotel:Hotel;owner:boolean;canLive:boolean;busy:boolean;run:(f:()=>Promise)=>Promise;onHotel:(h:Hotel)=>void}; +export function AutomationPage({hotel,owner,canLive,busy,run,onHotel}:Props){ const [status,setStatus]=useState(null),[rules,setRules]=useState([]),[knowledge,setKnowledge]=useState([]),[history,setHistory]=useState([]); const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState(null); const [evaluationText,setEvaluationText]=useState('[\n {"id":"parking-exact","subject":"Parking","body":"Is parking available?","expectedMatch":true},\n {"id":"parking-extra-request","subject":"Parking","body":"Is parking available? Also cancel my booking.","expectedMatch":false}\n]'),[evaluation,setEvaluation]=useState(null); @@ -18,7 +18,7 @@ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){ async function mode(value:string){await run(async()=>{if(value==='Live'&&!window.confirm('Enable automatic FAQ sending for new incoming messages? Confirm that you reviewed test-mode results and accepted Gmail delivery with a sandbox mailbox. Up to 20 replies per hotel per UTC day may be sent.'))return;onHotel(await api('/auto-replies/mode','PUT',{mode:value,version:hotel.version,acceptanceConfirmed:value==='Live'}));});} async function save(e:React.FormEvent){e.preventDefault();await run(async()=>{const item=await api(`/auto-replies/rules/${question}`,'PUT',{question:status!.questions[question],knowledgeId:answer,enabled,version:current?.version||0});setRules(old=>[item,...old.filter(r=>r.id!==item.id)]);setResult(null);});} return
Simple questions, thoughtful answers

FAQ automation

Start in test mode. Let approved answers handle a small set of straightforward questions.

-

Automation mode: {hotel.autoReplyMode||'Off'}

Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.

Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.

Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.

{status?.preview&&

Sample workspace: the question tester works here. Live sending is disabled.

}
+

Automation mode: {hotel.autoReplyMode||'Off'}

Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.

Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.

Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.

{status?.preview&&

Sample workspace: the question tester works here. Live sending is disabled.

}

Review a FAQ rule

{answer&&

{knowledge.find(k=>k.id===answer)?.answer}

}

The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.

Try a question

{e.preventDefault();run(async()=>setResult(await api('/auto-replies/test','POST',{subject,body})));}}>