11 KiB
Server identity, encryption and disclosure
Auth, JWE, sessions, field encryption, tenancy, and the two-stage clinical disclosure rule.
Last verified: 2026-08-02 against commit
51e86a1.
1. Phone-OTP is the public login
There is no username/password path for a normal user. Controllers/V1/AuthController
(request_otp / verify_otp / refresh / logout) plus MeController (/me, select_role) drive the
Features/Identity/ slices.
OTP delivery goes through the ISmsSender seam. The mock (LoggingSmsSender) logs the code; the real
rails are config-selected — see structure.md §3.
The OTP-capture bridge
AddDevelopmentOtpCapture() decorates the registered ISmsSender to capture each OTP in memory for
GET /api/v1/dev/last_otp/{phone}. It is:
- never wired outside Development, and
- only wired for a capture-safe provider —
mock/unset, or the Development-onlytelegramrelay.
A real gateway (kavenegar) disables it, so a production OTP only ever leaves the process over the SMS
wire. TelegramSmsSender is the one non-mock provider that keeps the bridge enabled, because it is a
broadcast, not a gateway: it pushes every code to a fixed list of chat ids so a human tester can read them
without grepping logs. Its API key is not committed.
DevController returns 404 outside Development.
2. Tokens and sessions
- JWE — a signed and AES-128-encrypted JWT — issued by
IJwtService(Baya.Infrastructure.Identity/Jwt/JwtService.cs).GenerateAccessTokenAsyncmints an access token only (the REST flow); the legacyGenerateAsyncadditionally writes aUserRefreshTokensrow and still feeds the gRPC path. - Every login creates a revocable
usr.UserSessionsrow storing only the refresh token'sIFieldEncryptor.Hash— never the token itself. - Refresh rotates: the old session is revoked and a new pair issued.
- A replayed or revoked token revokes ALL of the user's sessions and returns 401. This is reuse detection, and it is the reason the client's silent refresh is single-flight.
- Logout revokes the session AND rotates the security stamp, so outstanding access tokens fail the JWE
OnTokenValidatedstamp check. Revoking the session alone would leave a valid access token live for up to its full lifetime.
Settings bind from appsettings.json → IdentitySettings. RequireHttpsMetadata is on outside
Dev/Testing (passed into RegisterIdentityServices), the access-token lifetime is ExpirationMinutes: 60,
and Issuer/Audience are real (Balinyaar / BalinyaarClient).
SecretKey and Encryptkey belong in the environment-specific file, never in the base
appsettings.json, which stays at its StartupSecretsGuard-rejected placeholder. Never hardcode a secret
in C# — keys, connection strings and tokens come from configuration bound to typed settings, never a literal
in a handler or service.
3. Encrypted PII
users.PhoneNumber / Email / NationalId are encrypted at rest through an EF value converter over
IFieldEncryptor, wired in ApplicationDbContext.OnModelCreating.
Two consequences that are easy to get wrong:
- The encryptor must stay a process-wide singleton, because EF caches the model. A scoped encryptor gives you a model whose converters point at a disposed instance.
- Equality lookups go through the deterministic
PhoneHashcolumn (UNIQUE, synced onSaveChanges— which also resetsShahkarVerifiedAtwhen the phone actually changes). Never queryPhoneNumber == x: the ciphertext is not deterministic, so the comparison silently matches nothing.
What else is encrypted
| Column | Notes |
|---|---|
customer_profiles emergency contact |
|
patients.initial_medical_notes |
|
customer_addresses — address line, postal code, recipient name/phone |
Decrypted only in the owner's own read |
nurse_bank_accounts.iban |
Plus UNIQUE(iban_hash) as a deterministic-hash duplicate guard |
nurse_payouts.iban_snapshot |
[AuditRedacted], frozen from the verified primary account |
partner_centers.settlement_iban |
[AuditRedacted], masked to last 4 in every read |
payment_gateways.config_json |
|
booking_care_instructions — every field |
See §6 |
patient_care_records.body_encrypted |
Ciphertext with no EF value converter — the handler encrypts on write and decrypts only after the access check passes |
messaging.TicketMessages.Body |
Ticket bodies are the refund/dispute paper trail — phone numbers, addresses, clinical detail. Column widened to nvarchar(max); the 4000-char cap stays a boundary-validation rule |
Annotate any encrypted or PII property with [AuditRedacted] so the audit diff records a marker rather
than plaintext.
Seams:FieldEncryption:Keyand:HashKeyare load-bearing and must never change. They decrypt all existing PII and derive the phone-lookup hash. Rotating them without a re-encryption migration makes every PII read throw and every phone lookup miss.
4. Roles and permissions
The full vocabulary is in Domain/Entities/User/RoleNames.
SeedDataBasealways seeds the roles, and seeds a bootstrap admin only whenSeed:AdminUsername/Seed:AdminPasswordare configured — break-glass only. There is no committedadmin/qw123321any more. Day-to-day admins come from the phone-OTP demo seeds or are provisioned out-of-band.customerandnurseare self-selectable viaPOST me/select_role— audited (granted_by,granted_at), idempotent, and both can be held by one user (a dual session moves freely between the family and nurse apps).- Admin sub-roles are internal-only and
select_rolereturns 403 for them. Never build a flow that implies a user can grant themselves an admin role. user_roles.revoked_athas a global query filter, so a revoked grant disappears from every role read automatically.- The dynamic permission system (
DynamicPermissionHandler) reads the[controller]+[action]route values and checks role claims. Always use the tokens so the permission keys stay consistent — a hardcoded route string produces a key nothing grants.
Auth knobs — auth_otp_resend_seconds, auth_otp_max_attempts, auth_session_ttl_days — are
platform_configs rows read via IPlatformConfig, not constants.
nurse_profiles.is_verified has no public setter. It is flipped only by the verification pipeline's
guarded cross-aggregate transition — see persistence.md §5.
5. Rate limiting
Auth and OTP endpoints must be rate-limited, using ASP.NET Core's built-in limiter (no extra package).
| Endpoint | Policy |
|---|---|
request_otp, verify_otp |
otp, plus a per-phone resend window via ICacheService |
refresh |
auth |
| The PSP and BNPL webhooks | the single deliberate webhook policy — bursty-tolerant, partitioned per provider |
| Admin money/trust actions | sensitive |
| Everything else | the per-resolved-IP global policy |
Behind a reverse proxy the limiter partitions on the forwarded client IP, which is why
UseForwardedHeaders() runs first and UseRateLimiter() runs before UseAuthentication(). See
structure.md §4.
6. Two-stage clinical disclosure
This is the platform's central privacy invariant. A nurse learns progressively more about a patient as the engagement becomes real, and each stage is enforced at the query layer.
| Stage | When | What the nurse can see |
|---|---|---|
| 1 — a booking request | Before payment | Only the unencrypted, limited customer_notes — never routed through IFieldEncryptor. The full address is masked to a coarse city/district: no line, no postal code, no recipient |
| 2 — a confirmed booking | After capture | booking_care_instructions (every field encrypted), readable only post-confirmation and only by the assigned nurse + admin. GetCareInstructionsQuery enforces it |
Stage-2 fields are never projected into a list and never logged.
patient_care_records are patient-scoped, not booking-scoped, encrypted, and behind a strict access check:
the owning customer, a nurse with a confirmed booking for that patient, or an admin. Anyone else gets 403.
The handler decrypts only after the check passes.
7. Tenancy
Child rows must belong to the caller. A patient and an address must be in the caller's customer_id; a
variant must belong to the requested nurse_id.
Two rules:
- Resolve the owner from
ICurrentUser, never from the request body. A body-suppliedcustomer_idis an authorization bypass waiting to happen. - A mismatch is a clean 404, never a 403 and never a leak. A 403 confirms the row exists.
The same applies to a cross-tenant booking on a review submit, and to the partner portal: a centre resolves from the caller, never from a raw id in the URL.
INotificationService and the notification endpoints are always tenant-scoped to ICurrentUser.
support_alerts are admin-only and must never appear on a user-facing route.
8. is_internal is a hard visibility boundary
Ticket messages can be internal staff notes. The boundary is enforced at the QUERY layer, never in the UI.
GetTicketThreadQuery takes an AsAdmin flag:
false(the user view) — the repository projection strips everyis_internalmessage (GetMessagesAsync(includeInternal: false)).true(staff only) — returns them.
A non-staff caller can never set is_internal on PostMessage, and can never read one. The client mirrors
this by not modelling is_internal in its user-app types at all — see
client/services.md §5 — but that is a second layer, not the boundary.
Related messaging invariants:
- There is no direct nurse↔customer channel. All post-booking communication is ticket-mediated and
admin-readable. Participation (
TicketParticipant,UNIQUE(ticket_id, user_id), soft-remove viaremoved_at) plus staff status is the authorization boundary. reference_codeis minted once, collision-checked, UNIQUE, and stable.booking_idandrefund_idlinks are both nullable — handle a ticket with neither.- A coordination ticket is auto-created (idempotent, one per booking) on confirmation, dispatched from the card
confirm and the BNPL settle handlers. A refund ticket is auto-opened by
CreateRefundCommandwhen the caller supplies none, sorefunds.ticket_idis always non-null. LogEmergencyTicketrecords the aftermath of an out-of-platform emergency call and exposes no phone number. There is no telephony seam by design; the call is atel:link.
9. Logging
- Structured logging with message templates, never string interpolation of values:
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId). - Never log passwords, tokens, secrets, or full PII. Email is borderline — use
userIdin logs instead. - The mock SMS sender never logs the OTP code; clinical text and IBANs are encrypted or masked before they could reach a log.
- Levels:
Debugfor trace detail,Informationfor meaningful events,Warningfor recoverable issues,Errorfor unexpected failures. Deployed environments write Information+ toBaya_Logs, with framework categories held at Warning.