# 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](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-only `telegram` relay. **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`). `GenerateAccessTokenAsync` mints an access token only (the REST flow); the legacy `GenerateAsync` additionally writes a `UserRefreshTokens` row and still feeds the gRPC path. - **Every login creates a revocable `usr.UserSessions` row** storing **only the refresh token's `IFieldEncryptor.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 `OnTokenValidated` stamp 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 `PhoneHash` column** (UNIQUE, synced on `SaveChanges` — which also resets `ShahkarVerifiedAt` when the phone actually changes). **Never query `PhoneNumber == 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:Key` and `:HashKey` are **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`. - **`SeedDataBase` always seeds the roles**, and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — break-glass only. There is no committed `admin`/`qw123321` any more. Day-to-day admins come from the phone-OTP demo seeds or are provisioned out-of-band. - **`customer` and `nurse` are self-selectable** via `POST 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_role` returns **403** for them. Never build a flow that implies a user can grant themselves an admin role. - **`user_roles.revoked_at` has 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](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](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-supplied `customer_id` is 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 every `is_internal` message** (`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](../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 via `removed_at`) plus staff status *is* the authorization boundary. - `reference_code` is minted once, collision-checked, UNIQUE, and stable. - `booking_id` and `refund_id` links 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 `CreateRefundCommand` when the caller supplies none, so `refunds.ticket_id` is always non-null. - `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call and **exposes no phone number**. There is no telephony seam by design; the call is a `tel:` 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 `userId` in 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: `Debug` for trace detail, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures. Deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.