Skip to content

fix(security): scope the working-hours export and grid to the caller - #1722

Merged
renemadsen merged 1 commit into
stablefrom
fix/single-worker-export-scoping
Sep 18, 2026
Merged

renemadsen merged 1 commit into
stablefrom
fix/single-worker-export-scoping

Conversation

@renemadsen

Copy link
Copy Markdown
Member

Follow-up to #1721, which scoped the all-workers export. This closes the same hole on the two routes that were left.

Problem

The single-worker Excel export (GET working-hours/reports/file) and the working-hours grid (POST working-hours/index) both took SiteId straight from the request and never checked whether the caller was entitled to it. Any signed-in user could read a colleague's hours by guessing or enumerating a site id — the grid answered 200 with their rows, the export handed back their workbook. Both endpoints are [Authorize] only.

The new tests fail against stable for exactly that reason: with no gate, the out-of-scope call simply succeeds.

Fix

Both routes resolve the caller through SiteScopeResolver — the helper #1721 introduced — and refuse anything outside it:

  • admin — every site, unchanged
  • manager — their own site plus the sites in their managed tags
  • worker — their own site only

Refusals are deliberately indistinguishable. An unknown site id is in nobody's allow-set (an admin's included), so it answers exactly as an out-of-scope one: same SiteNotFound message, same 400. The narrowing runs in memory over a list the query does not vary by id, so there is no timing signal either. Before this, an unknown id fell into a catch and produced a different message from an out-of-scope one — enough to enumerate which site ids are real. Two tests assert the two cases answer identically.

Index is now a thin gate over a private IndexUnscoped. The all-workers export calls the unscoped body directly, because it has already narrowed its site ids; gating the shared body would re-resolve the caller's scope once per site in the workbook.

Behaviour changes

Admin-visible, both on requests that produce no data today:

  • a bogus site id now answers "Worker not found." instead of the crash-path message
  • so does a site that has plan registrations but no live assignment (unreachable from the UI)

Every request that produces a workbook or a grid today is unaffected — the gate demands a non-removed AssignedSite for the requested id, which the code already required a few lines later.

Non-admins lose access to sites outside their scope. That is the fix.

Tests

Ten cases in ExportTagFilterAndSiteTagsTests (already in both shard allowlists), mirroring each other across the two routes: admin any site; manager in-tags allowed and out-of-tags refused; plain worker own allowed and another refused; and per route, unknown and out-of-scope answering identically.

Both refusal tests first read or export the target site as an admin and assert success, so a refusal cannot pass because the site was never readable in the fixture.

Three existing fixtures gained a real admin caller because they passed baseDbContext: null!; no assertion in any of them changed. MobileFlexRecomputeAndCascadeTests needed no change and now documents why: its non-admin user resolves through the plain-worker branch and asks for the very site it is linked to.

Verified locally: dotnet build. Tests run in CI.

Follow-ups, not in this PR

  • Index now calls GetCore() and opens an SDK context twice per request, plus a full AssignedSites load, on the hottest endpoint. Threading the open context and resolved scope into IndexUnscoped would halve that.
  • The device/kiosk gRPC surface (Read, ReadSimple, ReadFullByCurrentUser, CalculateHoursSummary, UpdateWorkingHour) is [AllowAnonymous] and takes a caller-supplied sdkSiteId alongside a token. Whether those validate the token against the requested site is unverified — if they don't, it is this same class of hole on an anonymous surface. Worth its own investigation.

🤖 Generated with Claude Code

Both the single-worker Excel export and the working-hours grid took a
site id straight from the request and never checked whether the caller
was entitled to it. Any signed-in user could read a colleague's hours by
guessing a site id: the grid returned 200 with their rows, and the export
handed back their workbook. A test pins both.

Both now resolve the caller through SiteScopeResolver, the helper the
all-workers export already uses, and refuse anything outside that scope:
an admin reaches every site, a manager their own plus the sites in their
managed tags, a worker only their own.

Refusals are deliberately indistinguishable. An unknown site id is in
nobody's allow-set, an admin's included, so it answers exactly as an
out-of-scope one does — same message, same status, and the narrowing
happens in memory so the work does not vary with the id either. Without
that, the two answers differed and could be used to enumerate which site
ids exist.

Index is now a gate over a private IndexUnscoped. The all-workers export
calls the unscoped body directly because it has already narrowed its
site ids, and gating the shared body would re-resolve the caller's scope
once per site in the workbook.

Two admin-visible changes, both on requests that produce no data today:
a bogus site id now answers "Worker not found." instead of the
crash-path message, as does a site that has plan registrations but no
live assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 18, 2026 13:06

Copilot AI 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.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR scopes the working-hours grid and single-worker Excel export to the caller’s permitted sites, matching existing planning-board authorization rules.

Changes:

  • Added caller-scope checks for both endpoints.
  • Added an unscoped internal path for already-authorized export calls.
  • Updated fixtures and added coverage for admins, managers, workers, and indistinguishable refusals.
File summaries
File Description
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelShiftColumnOrderTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelHolidayColumnTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportTagsColumnTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursExcelExportE2ETests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursDisplayParityTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/MobileFlexRecomputeAndCascadeTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ExportTagFilterAndSiteTagsTests.cs Updated as part of this pull request.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DagsoversigtWorksheetExportTests.cs Updated as part of this pull request.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

return scope.ErrorKey;
}

return scope.Narrow([siteId]).Count == 0 ? "SiteNotFound" : null;
@renemadsen
renemadsen merged commit 9ccddbe into stable Sep 18, 2026
79 of 80 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