Files
baya-monorepo/archive/docs/rules/server/identity.md
T
2026-08-02 20:01:31 +03:30

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.