Skip to content

fix(auth): gate activity notes and last-scan data server-side - #2807

Merged
DonKoko merged 6 commits into
mainfrom
fix/activity-and-scan-authz
Aug 5, 2026
Merged

fix(auth): gate activity notes and last-scan data server-side#2807
DonKoko merged 6 commits into
mainfrom
fix/activity-and-scan-authz

Conversation

@carlosvirreira

@carlosvirreira carlosvirreira commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Server-side authorization gap: three loaders fetched permission-gated data and returned it in the page payload, with the permission check running only in the component. The UI hid the data; the response body still contained it.

Split out of #2800. That PR bundles this with the ONE_WAY consumable work, which is under review. There is no behavioural overlap between the two halves, so this one should not wait. #2800 keeps the consumable change.


The defect

routes/_layout+/assets.$assetId.activity.tsx gated its loader on asset:read, fetched every note, and returned them. The note:read check ran client-side, swapping the notes for an empty state. Roles that hold asset:read but not note:read therefore received the notes in the response and only React hid them.

routes/_layout+/audits.$auditId.activity.tsx had the identical shape with auditNote:read. Not a live exposure — every role currently holds that permission, so nothing leaks today. It is the same defect one permission edit away from mattering, and it is fixed the same way.

The intended rule was already established elsewhere in the codebase: all four activity CSV exports require the note permission, and the bookings and locations activity pages gate their loaders on bookingNote:read / locationNote:read. Assets and audits were the outliers.

What changed

  • Assets activity loader now gates on note:read. The parent route already enforces asset:read for the page, so the child requires only the permission covering the data it returns. Notes are never fetched without it.
  • Audits activity loader now gates on auditNote:read. The orthogonal requireAuditAssigneeForBaseSelfService restriction is untouched, with a test pinning that it still runs.
  • Nav — the Activity tab is hidden for roles lacking the permission on both detail pages, rather than routing them into a 403. Mirrors what the bookings detail page already does.
  • Client-side checks stay as defence in depth, matching bookings and locations.

The sweep

Grepping every loader that returns data the component hides behind userHasPermission turned up two further instances.

Fixed here — asset overview lastScan. The loader returned scan data that includes personal information, with the only scan:read check running client-side. Because that is personal data rather than workspace content, it is fixed here rather than filed.

The gate is extracted into getLastScanForViewer (modules/scan/service.server.ts) so the data cannot be fetched without the check — a caller can no longer forget. It returns null for an unauthorized viewer, the same shape as "never scanned", so callers need no special case. Extracting it also makes it directly unit-testable, which an inline check in that very large loader would not be.

One further instance was identified during the sweep. Its correct server-side gate depends on an org-level visibility setting rather than a flat permission, so fixing it changes visible behaviour and is being handled separately as a product decision.

Tests

  • assets.$assetId.activity.test.server.ts — drives the real Role2PermissionMap through the loader. Asserts the gate asks for note:read rather than asset:read, that a denied request throws a 403 and getPaginatedAndFilterableAssetNotes is never called, and that a permitted role still receives its notes. Asserting against the real matrix rather than restating it means the test speaks up if the matrix changes.
  • audits.$auditId.activity.test.server.ts — same, plus a test pinning that the assignee restriction survives the entity switch, plus one documenting every role currently holding auditNote:read so a change to that set is caught.
  • last-scan-for-viewer.test.ts — runs the real hasPermission and parseScanData against the real matrix with only the DB read mocked. A viewer without scan:read gets null and the scan row is never queried; a permitted viewer still gets the parsed scan.

Verification

  • pnpm turbo typecheck — 4/4 successful
  • pnpm webapp:lint — 0 errors (5 warnings, all pre-existing, none in touched files)
  • The three suites above — 14/14 passing

Full pnpm webapp:validate was not run to completion locally: the machine was carrying a load average above 150 on 8 cores, under which vitest suites die during collect with hook timeouts and the failing set differs between runs on identical code. Each leg is green run separately. Worth a clean CI run before merge.

Summary by CodeRabbit

  • Bug Fixes
    • Restricted asset and audit activity data to users with the appropriate read permissions.
    • Prevented unauthorized users from accessing activity notes or triggering unnecessary data requests.
    • Hid Activity tabs when users lack permission to view them.
    • Secured last-scan details so only authorized roles can view parsed scan information.
    • Improved handling for assets without QR codes or available scans.
  • Tests
    • Added coverage for permission checks, authorization order, and protected data access.

Asset and audit activity loaders gated on the entity permission rather than the
note permission, then fetched and returned every note in the page payload. The
note check ran client-side only, so roles without it received the data and only
the UI hid it.

The same shape existed on the asset overview loader, which returned last-scan
data containing personal information with the only scan:read check running in
the component.

Splits the authorization half out of #2800 so it is not blocked by the
consumable work under review there. No behavioural overlap between the two.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🩺 React Doctor — webapp

Findings on the files changed by this PR:

  • 0 errors
  • 4 warnings — advisory
⚠️ 4 warnings (click to expand)
  • react-doctor/no-derived-useState (2)
    • apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx:1846
    • apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx:1847
  • react-doctor/async-parallel (1)
    • apps/webapp/app/routes/_layout+/assets.$assetId.activity.tsx:43
  • react-doctor/no-giant-component (1)
    • apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx:737

Run locally with pnpm webapp:doctor for a full scan, or cd apps/webapp && pnpm exec react-doctor . --diff for the same diff-only view.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change adds server-side permission gating for asset scans, asset activity notes, and audit activity notes. It also hides Activity tabs when note-read permission is absent and adds tests for the new authorization paths.

Changes

Authorization enforcement

Layer / File(s) Summary
Viewer-scoped scan access
apps/webapp/app/modules/scan/service.server.ts, apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx, apps/webapp/app/modules/scan/last-scan-for-viewer.test.ts
Added getLastScanForViewer to check scan:read before loading the latest QR scan. The asset overview loader now derives roles once and uses this helper. Tests verify null returns for unauthorized viewers and parsed scan data for authorized viewers.
Asset activity authorization
apps/webapp/app/routes/_layout+/assets.$assetId.activity.tsx, apps/webapp/test/routes-tests/_layout+/assets.$assetId.activity.test.ts
The asset activity loader now checks asset:read, resolves the asset, then checks note:read before loading notes. Tests verify permission type, call order, 403 behavior, and successful note loading for allowed roles.
Audit activity authorization
apps/webapp/app/routes/_layout+/audits.$auditId.activity.tsx, apps/webapp/test/routes-tests/_layout+/audits.$auditId.activity.test.ts
The audit activity loader keeps the initial audit:read step, then checks auditNote:read before loading notes. Tests verify the permission request, denial behavior, preserved assignee checks, and role matrix expectations for BASE and SELF_SERVICE.
Permission-based activity tabs
apps/webapp/app/routes/_layout+/assets.$assetId.tsx, apps/webapp/app/routes/_layout+/audits.$auditId.tsx
The asset and audit detail pages now add the Activity tab only when the current user has note-read permission for that page.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • Shelf-nu/shelf.nu#2757: This PR uses the shared RBAC permission resolver and role matrix that #2757 introduced.

Suggested reviewers: donkoko

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main server-side authorization changes for activity notes and last-scan data.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/activity-and-scan-authz

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 109d028573

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/webapp/app/routes/_layout+/assets.$assetId.activity.tsx
Gating on note:read first would 403 a deep link to an asset or audit living in
another of the user's workspaces, before the resolver could hand off to the
switch-workspace path. Assets and audits have that affordance; bookings, whose
pattern this copied, does not.

Now gates the entity permission, resolves the record, then gates the note
permission before any note is fetched. Both properties hold: the deep link still
hands off, and notes never reach the payload without the right to read them.

Adds a test pinning the call order, verified to fail when the gates are swapped.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/webapp/app/routes/_layout+/assets.$assetId.activity.test.server.ts (2)

102-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep loaderArgs type-safe.

Line 108 casts the complete loader argument object through unknown. If the loader adds a required context member or changes its parameter contract, this fixture will still compile and fail only at runtime. Use a typed fixture factory that satisfies Parameters<typeof loader>[0] instead.

As per coding guidelines, define the correct type or narrow genuinely dynamic values instead of using unknown as a type escape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/routes/_layout`+/assets.$assetId.activity.test.server.ts
around lines 102 - 109, Update the loaderArgs fixture factory to construct a
value typed as Parameters<typeof loader>[0] without casting through unknown.
Define or reuse the correct loader-argument type and ensure its context,
request, and params satisfy the loader contract so required contract changes
fail at compile time.

Source: Coding guidelines


188-195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the rejection without an unchecked cast.

Lines 193-195 cast the unknown rejection to a response-wrapper shape. Use a runtime matcher or type guard so the test validates the actual rejection shape.

Proposed test simplification
-      const thrown = await loader(loaderArgs()).then(
-        () => null,
-        (caught: unknown) => caught
-      );
-
-      expect((thrown as { init?: { status?: number } })?.init?.status).toBe(
-        403
-      );
+      await expect(loader(loaderArgs())).rejects.toMatchObject({
+        init: { status: 403 },
+      });

As per coding guidelines, narrow genuinely dynamic values instead of using unchecked type assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/routes/_layout`+/assets.$assetId.activity.test.server.ts
around lines 188 - 195, Update the rejection assertion around loader to avoid
casting thrown to an assumed response shape; use an appropriate runtime matcher
or type guard to narrow the unknown value before checking its init.status equals
403. Preserve the existing behavior of asserting the loader rejects with the
expected forbidden response.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/webapp/app/routes/_layout`+/assets.$assetId.activity.test.server.ts:
- Around line 102-109: Update the loaderArgs fixture factory to construct a
value typed as Parameters<typeof loader>[0] without casting through unknown.
Define or reuse the correct loader-argument type and ensure its context,
request, and params satisfy the loader contract so required contract changes
fail at compile time.
- Around line 188-195: Update the rejection assertion around loader to avoid
casting thrown to an assumed response shape; use an appropriate runtime matcher
or type guard to narrow the unknown value before checking its init.status equals
403. Preserve the existing behavior of asserting the loader rejects with the
expected forbidden response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a9d496c-0bf0-4221-99f2-fbb658666a1a

📥 Commits

Reviewing files that changed from the base of the PR and between 109d028 and 3b8ab9a.

📒 Files selected for processing (3)
  • apps/webapp/app/routes/_layout+/assets.$assetId.activity.test.server.ts
  • apps/webapp/app/routes/_layout+/assets.$assetId.activity.tsx
  • apps/webapp/app/routes/_layout+/audits.$auditId.activity.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/webapp/app/routes/_layout+/assets.$assetId.activity.tsx

DonKoko added a commit that referenced this pull request Aug 5, 2026
The activity-note and last-scan authorization work now lives in #2807, so it is
removed here rather than carrying the same nine files across two open PRs.

This also clears the two ESLint errors blocking CI. The rule they trip,
local-rules/no-test-files-in-routes, landed on main after this branch was cut, so
the two route test suites went red on a rebase rather than on any change here.

assets.$assetId.overview.tsx was the only file carrying both halves. It returns to
its pre-authz state except for the one consumable line, which is kept:
consumptionType passed through to QuantityCustodyList.
DonKoko
DonKoko previously approved these changes Aug 5, 2026
Carlos Virreira added 3 commits August 5, 2026 17:03
Main added an ESLint rule banning test files under app/routes — Vite's dev-server
warmup pulls every file there into the client graph, so a co-located route test
importing a *.server module breaks pnpm webapp:dev.

Moves both activity loader tests to test/routes-tests/ and switches their route
imports to the ~/routes alias.

Also renames them from .test.server.ts to .test.ts. Main removed the
**/*.test.server.[jt]s include pattern from vitest.config.ts, so files with that
spelling are silently never collected. Verified both suites run and pass from the
new location.
@DonKoko
DonKoko merged commit 440b7ba into main Aug 5, 2026
10 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants