# SEC2 — access audit, clinical edit log, admin ≠ medical, patient data scope, media/export permissions, de-identified export

Read `_COMMON.md` first. Spec: SEC2 row of `../master-plan.md`, `../design-gyn-integration.md` §5 (whole security table — SEC1/SEC2/SEC3 rows + «خاص بالنسا» row) and §2 rows `media` / `access_audit` / `صلاحيات gyn.*`. Migration range `2026_11_03_180000`–`180099`. Depends on: C1 + G1 landed (so their endpoints exist to be mapped) — but the route map below is a plain list, so any later WP adds its route names to it. Runs in parallel with P5 (P5 names its routes as listed in §3 so you can map them).

## Why
Before the GYN client goes live the owner must answer «مين فتح ملف المريضة ومين حمّل/طبع/صدّر». Today: no read audit at all; edits ARE logged (see «reality» below) but invisible; the `admin` role silently gets every medical `*.view`; a doctor with data_scope `own` sees every patient in the patient list; attachment upload/download ride on `clinic.patients.update/view`; there is no export without identity.

## Code reality (verified 2026-10-22 — the design docs are wrong on two points)
- **Edits are already logged.** `App\Models\BaseModel` uses `App\Traits\Auditable` = Spatie `LogsActivity` `logAll()->logOnlyDirty()`. Every Clinic/Dental/Hospital model that extends BaseModel writes `activity_log` today. The gaps are: (a) patient-data models that do NOT extend BaseModel (list in Do §5), (b) `Modules/Core/app/Http/Controllers/ActivityLogController.php` `SUBJECT_TYPES` whitelist has **zero** clinic/nutrition/dental/hospital types, so nobody can see those rows, (c) logAll copies private field text into `activity_log.properties`.
- Patients live in **`lab_patients`** (LIS) through two models: `Modules\LIS\Models\LabPatient` (writes, own `logOnly` list) and the read-only shim `Modules\Clinic\Models\Patient`. `lab_patients` has **no `branch_id`**; `appointments.branch_id` exists.
- `Modules\Core\Support\DataScope::apply()` 'own' = `created_by` → useless for patients (reception creates them). The doctor pattern that works is in `QueueController::baseQuery` (~line 115): `LabDoctor::where('user_id', $user->id)->value('id')` + `DataScope::effectiveScope($user) === 'own'` → scope by `doctor_id`. Mirror it.
- `RolePermissionSeeder::createRoles()` (~line 2386): admin = every `core.*` + every `*.view` (+ a few explicit). `applyPerms()` = `syncPermissions` on fresh install, `givePermissionTo` in `runAdditive()` (updates) → changing the filter only affects fresh installs; existing installs need an explicit, owner-run revoke.
- `super-admin` passes every Gate (`AppServiceProvider` `Gate::before`) — out of scope, state it in the report.

## Decisions already made (do not re-litigate)
- One append-only table `access_audit`. Written by ONE terminable middleware driven by a **route-name allow-list** (no edits to routes files), plus explicit `AccessAudit::record()` calls for downloads/prints/exports where the middleware cannot see the record. Writes happen in `terminate()` with `DB::table()->insert()` (no Eloquent, no events) so reads never slow down; only 2xx responses are recorded.
- List/poll endpoints are NOT audited as views (queue, queue/display, patients index, dashboard/summary, encounters index, lab-results index) — the queue polls every few seconds. `view` rows are de-duplicated: one row per (user, patient, route_name) per 5 minutes (cache key). download / print / export / upload / share are never de-duplicated.
- Patient data scope is enforced in the same middleware **before** the handler for patient-file routes (out-of-scope → **404**, never 403 — don't confirm existence) and in `PatientController@index` as a query scope. Scope only bites users whose effective DataScope is `own`/`branch`; `all` (reception/owner) is unchanged → zero behaviour change for existing installs.
- Admin ≠ medical: fresh installs via the seeder filter (orchestrator edits); existing installs via an owner-run command, never automatically.
- De-identification: drop name*/phone/email/national_id/address*/mrn/portal tokens; patient key = HMAC pseudonym; DOB → age in years at the encounter; every date → year; free-text columns excluded by default; private fields (`history_questions.is_private`, `clinical_notes.is_private`, private attachments) never exported unless `include_private=1` AND `clinic.fields.private`.

## Do (BE)
1. **Migration `2026_11_03_180000_create_access_audit_table.php`** (guarded `hasTable`): `access_audit` = `id` bigint, `company_id` (idx), `user_id` nullable, `patient_id` unsignedBigInteger nullable, `action` string(12) (`view|download|upload|export|share|print`), `subject_type` string(40) nullable (short alias: `patient|encounter|attachment|prescription|rad_report|document|lab_request|export|admission|nutrition_record|dental_chart`), `subject_id` nullable, `encounter_id` nullable, `route_name` string(120), `ip` string(45) nullable, `user_agent` string(255) nullable, `meta` json nullable (e.g. `{rows: 120, deidentified: true, filters:{…}}`), `occurred_at` timestamp (useCurrent). **No timestamps, no softDeletes.** Indexes `(company_id, patient_id, occurred_at)`, `(company_id, user_id, occurred_at)`, `(company_id, action, occurred_at)`.
2. **Model** `Modules/Clinic/app/Models/AccessAuditEntry.php` — extends plain `Illuminate\Database\Eloquent\Model` + `App\Traits\TenantAware` ONLY (NOT BaseModel — it would log every audit row into activity_log and soft-delete). `$timestamps = false`; `updating`/`deleting` hooks throw (append-only).
3. **Writer** `Modules/Clinic/app/Support/AccessAudit/AccessAudit.php`: `record(string $action, ?int $patientId, ?string $subjectType = null, ?int $subjectId = null, array $meta = [], ?int $encounterId = null)` — reads user/company/ip/UA/route name from the current request; dedupe rule above; never throws (wrap + `Log::warning`). `RouteMap.php` = const array **route name → [action, resolver]**, resolver ∈ `patient` (route param id) | `encounter` | `prescription` | `attachment` | `study` | `rad_report` | `intent` | `admission` | `document` (load the bound model or id → `patient_id`, `encounter_id`). Initial map (route names as registered — verify each with `php artisan route:list --name=…` and fix the name, don't guess):
   - view: `api.clinic.patients.show`, `.patients.header`, `.patients.profile-fields.show`, `.patients.visit-history`, `.patients.vitals-history`, `.patients.vital-entries.index`, `.patients.family-history.index`, `.patients.past-medical.index`, `.patients.past-surgical.index`, `.patients.intents.index`, `.patients.lab-history`, `.patients.attachments.index`, `.encounters.show`, `.encounters.history.show`, `.encounters.vitals.show`, `.encounters.diagnoses.index`, `.encounters.prescriptions.index`, `.encounters.lab-orders.index`, `.encounters.rad-orders.index`, `.referrals.view`, `.rad-studies.show`; Nutrition `api.nutrition.patients.summary`, `.patients.record`, `.patients.visit-context`; Dental `api.dental.…chart.show`, `…visit-context`; Hospital admission show + `api.hospital.nursing.chart`; LIS `api.lis.patients.show`, `…patients.history-report`; plus whatever C1/G1 added (patient record `GET clinic/patients/{p}/record`, timeline, episodes, procedures — read their routes) and D3/P5 if present.
   - download/print (also in the map, never deduped): `api.clinic.patients.attachments.download`, `.intents.attachments.download`, `api.nutrition.patients.photos.show`, `.prescriptions.print`, `.prescriptions.pdf`, P3 `rad-reports/{r}/pdf`, P5 `api.clinic.documents.pdf` + `api.clinic.print.show`, LIS `requests/{request}/smart-report`.
4. **Middleware** `Modules/Clinic/app/Http/Middleware/RecordPatientAccess.php`: `handle()` — if the route name is in the map AND the user's patient scope (§6) excludes the resolved patient → `abort(404)`; `terminate()` — if 2xx → `AccessAudit::record(...)`. Registration: append it to the route group middleware array in each module's OWN `app/Providers/RouteServiceProvider.php` — Clinic, Nutrition (the staff group only, not the portal group), Dental, Hospital, LIS (the staff `api/lis` group only — not the patient-portal / external-lab portal groups) (edit only that one array line in each; the middleware must sit after `SubstituteBindings` (it does when appended to the module group) so bound models are available — assert it in a test on `encounters/{encounter}`; if another agent has an uncommitted diff in one of those providers, STOP and report). Explicit `AccessAudit::record()` calls (one line each) in: `ClinicPatientAttachmentController::store` (upload) and `::download`, `ClinicalIntentController::downloadAttachment`, `ClinicReportController::export`, the de-identified export (§8).
5. **Edit log coverage.** Add `use App\Traits\Auditable;` (+ redaction, below) to the patient-data models that do not extend BaseModel: Nutrition `PatientProfile`, `CaseNote`, `Note`, `PlanNote`, `BodyComposition`, `PatientFoodPref`, `VisitAdherence`, `ContactLog`, `PatientDietPlan`; Hospital `NursingNote`, `MedAdmin`, `MedOrder`, `VitalRound`, `DoctorEntry`; Clinic `ClinicDrugAlert` (read each first — skip any that has no patient link and say so). New trait `App\Traits\AuditableClinical` (new file) = Auditable + `tapActivity()` that sets `log_name='clinical'`, `properties.patient_id` (from the model's `patient_id` or its encounter) and **redacts** values of private content (row `is_private=true`, or a `PatientHistoryAnswer` whose question `is_private`): keep the attribute keys, replace old/new values with `"[private]"`. Apply `AuditableClinical` (instead of plain Auditable) to: `PatientHistoryAnswer`, `Encounter`, `EncounterDiagnosis`, `Prescription`, `PrescriptionItem`, `ClinicalDocument`, `ClinicPatientAttachment`, `ClinicReferral`, `PastMedicalHistory`, `PastSurgicalHistory`, `FamilyHistory`, D2/C1 tables (`patient_alerts`, `clinical_notes`, `clinical_episodes`, `clinical_procedures`, `follow_up_tasks` models — if present) and the list above. Since BaseModel already uses Auditable, define `AuditableClinical` so it can be used on a BaseModel subclass without a trait collision (override `getActivitylogOptions()`/`tapActivity()` via the model, or `insteadof` — test it).
6. **Patient scope** `Modules/Clinic/app/Support/PatientDataScope.php`: `apply(Builder $patients, User $u)` and `allows(User $u, int $patientId): bool`. Effective scope from `DataScope::effectiveScope()`. `own` + user linked to a `LabDoctor` → patient has an appointment or encounter with that `doctor_id`, OR (if the C1 columns exist) is in `clinic_referrals.member_user_id = user` / `clinical_episodes.team` contains the user, OR `lab_patients.created_by = user`. `own` + not a doctor → `created_by = user` OR has an appointment created by the user. `branch` → EXISTS appointment with `branch_id IN DataScope::userBranchIds()` (no branches assigned → unrestricted, same opt-in rule as DataScope) OR created_by = user. `all` → no clause. Apply in `PatientController@index` (all three search scopes, keep the typeahead/simplePaginate behaviour) and through the middleware for the patient-file routes.
7. **Permissions** (report — do NOT edit the seeder): `clinic.media.upload`, `clinic.media.download`, `clinic.media.share` (permission only, no endpoint yet), `clinic.export.identified`, `clinic.export.deidentified`, `clinic.audit.view`. Ship a new `Modules/Clinic/database/seeders/GrantClinicSecurityPermissionsSeeder.php` (creates them; additive bridge: every role holding `clinic.patients.view` gets `clinic.media.download`; holding `clinic.patients.update` gets `clinic.media.upload` — **skipping the `admin` and `super-admin` roles** (otherwise the bridge re-grants what §8 strips); `owner` gets all six) + bridge migration `2026_11_03_180001_clinic_security_permission_bridge.php` mirroring `2026_10_09_130003_clinic_device_permission_bridge.php` (skip under unit tests, non-fatal). Report the route-middleware swaps the orchestrator must make in `Modules/Clinic/routes/api.php`: attachments store → `permission:clinic.media.upload`, attachments download + intents attachment download → `permission:clinic.media.download`; Nutrition `photos/{attachment}` → add `clinic.media.download` to its `$photoView` OR-list. Also report the Core `PermissionCatalog` labels (ar/en) for the six.
8. **Admin ≠ medical.** Report to the orchestrator the exact exclusion for the admin filter in `RolePermissionSeeder::createRoles()`: remove from admin `clinic.{encounters,history,vitals,prescriptions,diagnosis,lab-orders,lab_results,rad-orders,rad-worklist,intents,referrals}.view`, `clinic.media.download`, `clinic.export.identified`, `clinic.fields.private`, `nutrition.visit.consult`, `nutrition.visit.measure`, `nutrition.patients.manage` (+ any `gyn.*` / `dental.*` view that exists by then). Admin keeps `clinic.patients.view` (demographics), appointments, queue, payments, reports, settings, `clinic.audit.view`. New command `clinic:security:strip-admin-medical {--company=} {--dry-run}` (in `Modules/Clinic/app/Console/`, register it in the Clinic service provider's command list — report if you can't) revokes exactly that list from the `admin` role; prints what it would revoke; never auto-run.
9. **De-identified + identified export.** `Modules/Clinic/app/Support/Export/Deidentifier.php` (pure, unit-tested; G7 reuses it): `pseudonym(int $patientId, int $companyId): string` = first 16 hex of `hash_hmac('sha256', "pt:$companyId:$patientId", <key>)` where key = `config('app.key')`-derived (`hash_hmac('sha256','moon-deid', APP_KEY)`); `row(array $raw): array` per the decision rule. New controller `ClinicExportController@patients` → `GET clinic/exports/patients?mode=deidentified|identified&from=&to=&clinical_type=&sections[]=encounters,diagnoses,observations,answers,prescriptions&include_private=0` → streamed CSV (one file per section in a zip; `ZipArchive` + temp file, delete after send). mode=identified needs `clinic.export.identified`; deidentified needs `clinic.export.deidentified`; `include_private` additionally needs `clinic.fields.private`. Company-scoped, patient scope (§6) applied. Records `AccessAudit::record('export', null, 'export', null, ['mode'=>…, 'rows'=>N, 'sections'=>…, 'patient_count'=>M])`. Report the route line + permission middleware.
10. **Audit read API** (new `AccessAuditController`): `GET clinic/access-audit?patient_id=&user_id=&action=&from=&to=` (paginate 50, perm `clinic.audit.view`, rows include user name, patient name+mrn, subject label, route label) ; `GET clinic/patients/{patient}/access-audit` (same, one patient) ; `GET clinic/patients/{patient}/change-log` = `activity_log` where `log_name='clinical'` and `properties->patient_id = {patient}` (perm `clinic.audit.view`, reuse `Modules\Core\Support\ActivityLogEnricher` for labels/diff); `GET clinic/access-audit/export` (CSV, audited as export). Report the 4 route lines.
11. **Activity log screen.** In `ActivityLogController::SUBJECT_TYPES` add keys for the clinical models above (e.g. `encounter`, `encounter_diagnosis`, `prescription`, `prescription_item`, `patient_history_answer`, `clinical_document`, `clinic_patient_attachment`, `clinic_referral`, `nutrition_case_note`, `hospital_nursing_note`, …) — you own this edit (SEC1-3 does not touch this file; check LEDGER for any 🔄 Core row first). Check whether `ActivityLogEnricher` needs labels for them.
12. **Retention:** setting `security.access_audit_retention_days` (int, 0 = keep forever) — report the definition for `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (orchestrator); command `clinic:access-audit:prune` (chunked delete older than N days; no-op at 0); report the schedule line.
13. Lang keys (ar/en) in `Modules/Clinic/lang/{ar,en}/security.php` (new) for action labels + errors.

## Do (FE)
- New `src/app/features/clinic/audit/`: `access-audit.component` (route `clinic/audit`, guard perm `clinic.audit.view`): filters patient (reuse `<app-clinic-patient-search>`), user, action chips, date range; table: when · user · action chip (عرض/تحميل/رفع/تصدير/مشاركة/طباعة, distinct colours) · what (subject label, link to the patient file / encounter) · IP · device; CSV export button (calls the BE export). `patient-audit-tab.component` with two segments «من فتح الملف» (access-audit) and «التعديلات» (change-log, old→new with private values shown as «خاص»). Host the tab in `features/clinic/patients/clinic-patient-file.component.*` (small targeted edit: one tab entry) and add ONE route entry in `features/clinic/clinic.routes.ts` + ONE nav item (find the clinic nav config) — targeted Edit only, never rewrite those files.
- Service `features/clinic/services/clinic-access-audit.service.ts`. Export buttons for patient lists/tables: hide unless `clinic.export.identified`.
- i18n under `CLINIC_AUDIT.*` in ar.json + en.json with small targeted edits (other agents edit the same files) — do NOT bump I18N_VERSION. `ng build --base-href /app/` green.
- **Fable consult (mandatory, ONE focused consult):** before building the viewer, send a Fable agent (`model: "fable"`) a screenshot of the existing activity-log screen (`features/core/activity-log`) + the patient file, and ask one question: the layout of the audit viewer table + the patient-file tab (density, action chip colours, how to show «خاص» values, mobile). Apply its answer; don't iterate.

## Tests
`Modules/Clinic/tests/Feature/Sec2AccessAuditTest.php` (prefix helpers `sec2_`): GET patient show writes one view row, a second within 5 min writes none; non-mapped route writes nothing; 4xx writes nothing; attachment download writes `download` every time; upload writes `upload`; own-scoped doctor: patient index lists only patients with his appointments/encounters, show of another patient → 404; `all` scope unchanged; change-log returns clinical activity for the patient and private answer values come back `[private]`; access_audit row cannot be updated/deleted; export deidentified has no name/phone/national_id/mrn columns, dates are years, pseudonym stable across calls, private excluded without `include_private`; identified export 403 without permission; strip-admin-medical `--dry-run` lists and revokes nothing. `Modules/Clinic/tests/Unit/Sec2DeidentifierTest.php`. Run also `ClinicPatientAttachmentTest.php`, `Af3AttachmentPrivacyTest.php`, any `*PatientController*Test.php` / `*PatientSearch*Test.php` / `*Queue*Test.php` in Modules/Clinic/tests and `Modules/Core/tests/Feature/*ActivityLog*Test.php` — keep green. Then call one mapped route on the live dev API (read-only GET, your own test user) and confirm the row lands in MySQL — SQLite hides missing columns.

## Files you must NOT edit (report instead)
`Modules/*/routes/api.php`, `Modules/Core/database/seeders/RolePermissionSeeder.php`, `SettingDefinitionSeeder.php`, `ClinicSettingDefinitionSeeder.php`, `Modules/Core/app/Support/PermissionCatalog.php`, `bootstrap/app.php`, HistoryService, the consultation component, `visit-shell/**`, CHANGELOG (give 2 lines EN + AR).

## Out of scope
Encryption/backup (SEC3), MFA (SEC1-3), GYN sexual-health module permissions (G7), patient-portal share links (`clinic.media.share` is permission-only), super-admin bypass.

## Report
Files, test output, the route lines + middleware swaps, permission list + bridge grants, setting definition, admin exclusion list, schedule line, anything not done.
