# `SessionDataController::show` has no student-scope guard

**Filed:** Batch 57, 2026-08-03.
**Status:** deferred to post-demo. No known live exploit today (no
public URL shape that leaks session ids), but the endpoint currently
returns any session by id to any user carrying the `SessionData_View`
permission — including sessions belonging to students the caller is
scoped away from.

## Problem

`GET /api/session-data/{id}` returns the full session record + all
trial entries + audit trail + summary. The route middleware is only:

```
->middleware('permission:SessionData_View')
```

The controller method itself performs **no** student-scope check:

```php
public function show(Request $request, int $id)
{
    $s = DB::table('session_data_records as s')
        ->leftJoin('program_targets as t', 't.id', '=', 's.targetId')
        ->leftJoin('programs as p',        'p.id', '=', 's.programId')
        ->leftJoin('students as st',       'st.id', '=', 's.studentId')
        ->leftJoin('teams as tm',          'tm.id', '=', 's.staffId')
        ->where('s.id', $id)->where('s.isDelete', false)
        ->select('s.*', 't.targetName', 'p.programName',
            DB::raw("CONCAT(COALESCE(st.first_name,''),' ',COALESCE(st.last_name,'')) as studentName"),
            DB::raw("CONCAT(COALESCE(tm.teamFirstName,''),' ',COALESCE(tm.teamLastName,'')) as staffName"))
        ->first();
    if (!$s) return response()->json(['code' => 404, ...], 404);
    // ...returns entries + audit + summary with no ownership check
}
```

`listPaged`, `index`, `kpis`, `getProgress`, and `getStudentOverview`
all restrict rows to the caller's visible-students set (Batch 57
extended these to union psa alongside classroom). `show` reads a
single session by id and skips the check entirely.

## Why the other endpoints are stricter than this one

Callers on `listPaged` / `index` only ever *iterate* what they can
see, so the scope filter naturally hides forbidden rows. `show` takes
a caller-supplied id — the ONE shape where the scope filter has to be
applied per-request. It isn't.

The write endpoints (`addEntry`, `undoEntry`, `complete`,
`submitReview`, `approve`, `returnCorrection`, `voidSession`,
`restoreSession`) all reach through `getProgress`-style checks or the
autosave path — most have some ownership gate. `show` is a read, and
the read path is where this class of bug is easiest to miss.

## What is exposed today

A user with `SessionData_View` who guesses or scrapes a session id
(e.g. by decrementing `?sessionId=42` in the URL bar, or observing
ids in other API responses) can read:

- Student's first and last name.
- Program name and target name.
- Staff (technician) name.
- Every trial entry, prompt level, correct/incorrect, and per-trial
  timestamps.
- Every audit-trail row (who created, submitted, reviewed, voided).
- Notes and review notes.

For a scoped-role user (tutor, ABA-Staff-only technician, parent),
this bypasses the caseload restriction that the rest of the
controller enforces. For a manager/admin it's a no-op (they already
have full visibility).

There is currently no *public* URL shape that exposes session ids
to unauthenticated users — the endpoint sits behind Sanctum auth.
So the practical exposure is "any authenticated user with the
permission bit can read any session by id." That's a horizontal
privilege boundary breach, not a full data leak.

## Recommended fix

Mirror the guard from `getStudentOverview` at
[SessionDataController.php:1231](../../app/Http/Controllers/SessionDataController.php#L1231),
using the Batch 57 psa-unioning variant:

```php
public function show(Request $request, int $id)
{
    $s = DB::table('session_data_records as s')
        // ...existing joins/select...
        ->where('s.id', $id)->where('s.isDelete', false)
        ->first();
    if (!$s) return response()->json(['code' => 404, ...], 404);

    // Batch 57 §57.2 — scope guard: the caller must have access to
    // this session's student via either classroom or psa. Managers
    // and admins pass through; parents/tutors are restricted.
    $authUser = $request->user();
    if ($s->studentId && !DataScopeHelper::canAccessStudentWithPsaUnion($authUser, (int) $s->studentId)) {
        return response()->json(['code' => 403, 'status' => false, 'message' => 'Access denied to this session.'], 403);
    }

    // ...rest unchanged...
}
```

Also worth verifying: `session_data_records.studentId` should be
NON-NULL in practice (autosave requires it), but if it's ever NULL
the guard above passes through — that's the correct fail-open
behaviour for an orphan record and matches the pattern used
elsewhere.

## Test after the fix

- Admin: still sees any session — no regression.
- Manager: still sees any session — no regression.
- Parent-scoped user: 200 for their own child's sessions, 403 for
  others.
- ABA-only technician (psa-only, no classroom assignments): 200 for
  sessions on their psa students, 403 for others. This is the case
  that motivated Batch 57 §1 and would fail without the psa-union
  variant.
- Classroom tutor with no psa: 200 for their classroom students'
  sessions, 403 for others.

## Related

- Batch 57 §1 fix — `listPaged`, `index`, `kpis`, `getStudentOverview`,
  and `getProgress` write-barrier all extended to union psa alongside
  classroom. `show` was flagged during the same audit but held out for
  a separate ticket because it's a *missing* guard, not a broken one.
- `docs/post-demo/session-progress-access-check.md` — the pre-existing
  ticket about `getProgress`'s owner-null permissive path. Different
  bug, related surface.
