219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# 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.
|