# Post-demo fix — SessionDataController::getProgress target-owner check

**Status**: NOT APPLIED. Held per Batch 36 §4 decision — the current check is
permissive when `programs.studentId IS NULL`, and permissive→restrictive access
changes days before a client demo risk locking legitimate users out mid-walkthrough.

**Scope**: `POST /api/session-data/progress` — target-progress time series
(session dates, percent correct, running average, trend).

## What is exposed today

**Location**: `backend/app/Http/Controllers/SessionDataController.php`,
`getProgress()`, lines ~404–411.

```php
$ownerStudentId = DB::table('program_targets')
    ->join('programs', 'program_targets.programId', '=', 'programs.id')
    ->where('program_targets.id', $request->input('targetId'))
    ->value('programs.studentId');
if ($ownerStudentId && !DataScopeHelper::canAccessStudent($authUser, (int) $ownerStudentId)) {
    return response()->json(['code' => 403, …], 403);
}
```

When the target's program row has `studentId = NULL` (library/template programs,
and — post Batch 36 §1.3 — programs whose only student linkage is via
`program_student_assignments`), the `if ($ownerStudentId && …)` short-circuits.
The check does not fire. The caller receives session records for whichever
students share that program via psa.

**Roles that reach this check** (verified against
`2025_04_15_000007_correct_role_permission_assignments` and
`2026_06_14_000021_seed_default_role_permission_matrix`):

| Role | ID | Has `SessionData_View`? | `canAccessStudent` returns |
| --- | --- | --- | --- |
| Super Admin | 1 | Yes | `true` unconditionally — no exposure |
| Attendance Manager | 2 | No | not reached |
| Parent | 3 | No | not reached |
| Sub-Admin | 4 | Yes | `true` unconditionally — no exposure |
| Front Desk | 5 | **No** | not reached |
| Accountant | 6 | No | not reached |
| Tutor | 7 | Yes | `true` only if `tutorOwnsStudent` |

**Exposure surface — Tutor only.** A tutor whose only assigned student is
student X could open the Program Detail Progress panel for a template program
(studentId NULL) and read the session history of students Y/Z who share that
program via psa. The check does not stop them because there is no
`ownerStudentId` to check against.

## Fix pattern (to apply post-demo)

Replace the branch with a union check that considers BOTH the legacy
`programs.studentId` linkage AND every `program_student_assignments.studentId`
attached to the target's program. Deny only when the caller is a scoped role
(Tutor, Parent) AND at least one linked student exists AND the caller cannot
access ANY of them. Managers/admins keep the existing pass-through.

```php
// Fetch the target's programId.
$programId = DB::table('program_targets')
    ->where('id', $request->input('targetId'))
    ->value('programId');

// Collect every student linked to this program — same three-source union
// as ProgramController::programStudents (Batch 36 §1.3).
$linkedStudentIds = collect();
$owner = DB::table('programs')->where('id', $programId)->value('studentId');
if ($owner) $linkedStudentIds->push((int) $owner);
$cloneOwners = DB::table('programs')
    ->where('copied_from_program_id', $programId)
    ->where('isDelete', false)
    ->whereNotNull('studentId')
    ->pluck('studentId');
foreach ($cloneOwners as $sid) $linkedStudentIds->push((int) $sid);
$psaOwners = DB::table('program_student_assignments')
    ->where('programId', $programId)
    ->where('isDelete', false)
    ->pluck('studentId');
foreach ($psaOwners as $sid) $linkedStudentIds->push((int) $sid);
$linkedStudentIds = $linkedStudentIds->unique()->values();

// Deny only for scoped roles when at least one student is linked AND the
// caller cannot access any of them. Manager/admin roles keep the current
// pass-through — canAccessStudent returns true for them anyway.
if ($linkedStudentIds->isNotEmpty()) {
    $canAccessAny = $linkedStudentIds->contains(function ($sid) use ($authUser) {
        return DataScopeHelper::canAccessStudent($authUser, (int) $sid);
    });
    if (!$canAccessAny) {
        return response()->json([
            'code' => 403, 'status' => false,
            'message' => 'Access denied to this program\'s students.',
        ], 403);
    }
}
// If $linkedStudentIds is empty, the target belongs to a pure template
// program with no assignments — no clinical data to protect. Fall through.
```

## Why this is safe when applied

- **Manager / admin roles** hit `canAccessStudent === true` on the first
  student and pass immediately — no behaviour change for the roles that reach
  this route most often.
- **Tutor** who owns *any* of the linked students passes. Only a tutor who
  owns *none* is denied. This matches the intent of the Batch 32 audit
  ("tutors should not read other students' session data").
- **Pure template programs** with no assignment and no legacy studentId are
  treated as before (no owner → no check). This preserves the
  library-template read-freely UX.

## Test coverage to add alongside the patch

- Assertions: a tutor without ownership hits 403 on a psa-linked target when
  no other tutor's student is on that program.
- Regression: a tutor with ownership of at least one linked student passes.
- Regression: manager/admin passes regardless of ownership.
- Regression: template program with no students returns 200 as before.

## Do not apply before the demo

The demo runs against a program that either (a) has zero psa rows (seeder
populates via `programs.studentId`) or (b) has a template with no linkage. In
both cases the current permissive branch is safe. Applying this patch during
the demo window introduces a code path a tutor account could hit if the demo
role setup differs from the seed defaults — a 403 on the Progress panel
would derail the walkthrough.

Author's note (Batch 36): my Batch 36 §1.3 union widened the surface (more
programs now have real assignments the check ignores), but it did not create
the exposure. The permissive branch has existed since Batch 35 or earlier.
