Files
baya-monorepo/docs/rules/server/identity.md
T
2026-08-02 18:58:46 +03:30

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-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.jsonIdentitySettings. 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 §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-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 §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.