Skip to content

Time & pay for every workspace: multi-tenant audit fixes and per-workspace switches - #650

Merged
techmore merged 4 commits into
mainfrom
feature/time-system-multitenant-audit
Nov 11, 2026
Merged

techmore merged 4 commits into
mainfrom
feature/time-system-multitenant-audit

Conversation

@techmore

@techmore techmore commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

What this is

An end-to-end audit of the time-keeping system for more than one workspace, the fixes it led to, and per-workspace switches for each part of the time system. Full write-up: docs/audits/2026-11-10-time-multi-tenant-audit.md.

No cross-workspace read was found: every time & pay model is TenantScoped, and ids from another workspace 404 on the web, the staff API, exports and the PIN clock (now proven by tests). The findings are at the edges.

Findings and fixes

Sev Area What was wrong Fix
High Payroll Timesheets imported gross punch hours; the Pay page, overtime checks and accrual use paid hours. The payroll export paid unpaid meal breaks. ClockShift#paid_hours_between; TimesheetWorkflow import and staleness checks use it.
High Time zones Day/week/pay-period boundaries, the midnight split on import and displayed times all used the instance zone (Eastern). tenants.time_zone (blank = unchanged). Web, staff API, kiosk and jobs run on the workspace's clock (Current.with_tenant, use_business_zone).
High Push A phone's token was unique per workspace and could not be forgotten after sign-out, so the previous person's notifications kept arriving. PushDevice.register! moves the token across workspaces; forget_token! needs no session; iPhone sign-out, offboarding and deactivated accounts stop delivery.
Med PIN clock An anonymous browser could tell a real PIN from a wrong one; lockout keyed on the session cookie. Refused before the digits are read; a signed-in phone is compared with its own PIN only; lockout per device or network address.
Med Privacy Team schedule showed everyone's hours to any linked account; the punch report showed pay rates to reviewers without payroll access. Reviewer/own-row scoping; rates need payroll.read.
Med Store scope Store-limited managers could decide punch fixes, timesheets and time off for other stores by id. People::StoreScope.covers? in the service and policies.
Med iPhone app Handoff trusted any control origin; a notification could name another workspace; no way to leave a workspace. Control-plane check, path-only notification links, Switch workspace, explicit session cookie on push registration.
Med Jobs Sync schedulers stopped at the first failing workspace; leave accrual used one "today" for all; caches carried between workspaces. Per-workspace rescue, per-workspace date, Current.with_tenant.
Low Misc /clock/workers 500 when anonymous; client-chosen punch source; Expo builds prefilled one workspace; unclear refusal for an email used in another workspace; settings audit lacked values. Fixed.

Known limits left for later are listed in the audit document (one zone per workspace, Eastern cron times, the .ics feed, cross-store trades as a warning).

What administrators can now turn on and off

Settings → Time rules (/settings/time_rules, needs settings.write), for the whole workspace:

  • Workspace time zone
  • What is turned on: clock from the web, clock from the phone apps, register PIN, clock kiosk, breaks and meals, scheduling, shift trades, time-off requests, tips for staff, pay estimates for staff, punch fixes by staff, self-submitted timesheets, missed clock-out reminders (break reminders already existed per store)
  • Punch logging: device and register name, a detailed punch audit trail (time_clock.* activity lines), the network address, and how long the trail is kept (3, 4 or 7 years)

Every switch defaults to what Rupert did before, so the live workspace is unchanged until someone opens the page. Off is refused server-side (pages redirect with a message, the staff API answers 403 feature_disabled, the PIN clock refuses) and hidden from menus. Every change is an activity-log line with who, when and old → new.

Migration

One additive migration: tenants.time_zone (nullable) and a short-named index on push_devices.token_digest.

Tests

  • time_multi_tenant_isolation_test.rb: two workspaces with the same staff name, employee number and PIN (pages, staff API, PIN clock, exports, jobs, clock zone, settings, push, setup links).
  • time_features_settings_test.rb: defaults, permission, audit, every switch on web + API + PIN clock, logging, retention, and a brand-new worker in a brand-new workspace from setup link to payroll export.
  • iOS package tests (notification links, cookie scoping, leaving a workspace): 19 pass on the Simulator.
  • Local: full Rails suite 4043 runs, 0 failures; RuboCop clean; fresh-database migration check passes.

Behaviour changes to know about

  • A timesheet imported before this change that includes an unpaid meal will ask for a re-import at submit (its hours were gross). Approved sheets are not rewritten.
  • A signed-in phone typing a colleague's PIN is told "That isn't your PIN. On this device only your own PIN works" for any digits that are not its own (it used to confirm the PIN belonged to someone else).
  • Shift leads no longer see pay rates in the punch report; non-reviewers see only their own row in Team schedule.

🤖 Generated with Claude Code

techmore and others added 4 commits November 10, 2026 23:48
A multi-workspace audit of the time system (docs/audits/2026-11-10-time-multi-tenant-audit.md).
No cross-workspace read was found; these are the fixes and the settings that came out of it.

Audit fixes
- Payroll: timesheets import paid hours (unpaid breaks left out), so the timesheet, the Pay
  page and the payroll export agree (ClockShift#paid_hours_between).
- Time zones: tenants.time_zone (blank = instance default). Web, staff API, kiosk and time &
  pay jobs run on the workspace's clock (Current.with_tenant, use_business_zone).
- Push: a token registers to one account across workspaces; forgetting needs only the token
  digest; iPhone sign-out, offboarding and deactivation stop delivery.
- PIN clock: an anonymous browser gets one answer for every PIN; a signed-in phone is compared
  with its own person's PIN only; tries are counted per device or network address.
- Privacy: Team schedule and the punch report no longer show colleagues' hours and pay rates
  to people who do not review them or lack payroll access.
- Store scope is enforced on punch-fix, timesheet and time-off decisions, not only in the inbox.
- Jobs: sync schedulers rescue per workspace; leave accrual uses each workspace's date.
- iPhone app: Switch workspace, handoff accepts only its own control plane, notifications open
  only a path on the current instance, push registration carries the web views' cookie.
- Expo client: no prefilled workspace; sign-out clears the name and workspace.

Per-workspace switches (Settings -> Time rules, settings.write)
- What is turned on: web clock, phone apps, register PIN, kiosk, breaks, scheduling, trades,
  time-off requests, tips and pay for staff, punch fixes by staff, self-submitted timesheets,
  missed clock-out reminders. All on by default; off is refused by the server (pages, staff
  API 403 feature_disabled, PIN clock) and hidden from menus.
- Punch logging: device and register name, an optional detailed audit trail (PunchAudit,
  time_clock.* activity lines) with or without the network address, and its retention.
- Every change is logged old -> new.

One additive migration: tenants.time_zone, an index on push_devices.token_digest.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A transitive dependency of the Expo client; the advisory published 2026-11-10 fails the mobile
audit on every branch. Lockfile only.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…d device

A signed-in phone with no store device gets one answer for every PIN that is not its own
(a colleague's real PIN included), and a locked own PIN says it is locked.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The sync-health tool test created a sync ten minutes ago and expected it to count for today.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@techmore
techmore merged commit 746ccd7 into main Nov 11, 2026
9 checks passed
@techmore
techmore deleted the feature/time-system-multitenant-audit branch November 11, 2026 05:07
techmore added a commit that referenced this pull request Nov 11, 2026
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
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.

1 participant