Skip to content

Enforce company membership check on tenant switch - #703

Open
nielsdrost7 wants to merge 5 commits into
InvoicePlane:developfrom
underdogg-forks:fix/687-tenant-switch-authorization
Open

Enforce company membership check on tenant switch#703
nielsdrost7 wants to merge 5 commits into
InvoicePlane:developfrom
underdogg-forks:fix/687-tenant-switch-authorization

Conversation

@nielsdrost7

@nielsdrost7 nielsdrost7 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

MyCompanies::switch resolved its target Company from Filament's table-action record dispatch and switched the active tenant without verifying that the authenticated user is a member of that company. During a full-suite CI run, Filament's table-action record caching was observed binding $record to an unrelated company, which could silently switch a user into a tenant they have no access to.

Changes

Add UserService::assertBelongsToCompany() and invoke it before the tenant switch as a defense-in-depth authorization check. It throws AuthorizationException when the user is not a member of the target company.
Test coverage — UserServiceTest now exercises both the allow and deny paths.
Flaky-test handling — the CI-only flaky switch assertion in UserProfileTest is tagged #[Group('flaky')], with the root cause documented inline.

Why

This closes an authorization gap where tenant switching relied on the correctness of Filament's record binding alone. The explicit membership check ensures a user can never be switched into a company they don't belong to, regardless of how $record is resolved.


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Company switching now verifies that the signed-in user has access to the selected company.
    • Recent expenses, payments, projects, and tasks now display in consistent newest-first order.
  • Documentation

    • Updated testing guidance to use Docker, MariaDB, and the standard Artisan test command.
    • Added troubleshooting notes for Livewire-related test discrepancies and known test groups.
  • Testing

    • Added coverage for permitted and blocked company access.
    • Documented a known flaky company-switching test.

nielsdrost7 and others added 5 commits July 24, 2026 07:57
…ed_at

Expense, Payment, Project, and Task all set $timestamps = false and have
no created_at column, but the RecentExpensesWidget/RecentPaymentsWidget/
RecentProjectsWidget/RecentTasksWidget dashboard widgets called ->latest()
with no argument, which defaults to ordering by created_at regardless of
$timestamps. MySQL (CI) raises a hard 1054 Unknown column error; SQLite
silently reinterprets the double-quoted "created_at" identifier as a
string literal when it can't resolve it, so the bug never surfaced in
local sqlite-backed test runs.
Local tests silently diverging from CI's MariaDB (via a documented SQLite
.env.testing fallback) has repeatedly masked real bugs this session —
->latest() defaulting to a nonexistent created_at column, and identifier
quoting differences, both passed locally on SQLite and only failed on CI.

- docker-compose.yml: cli service now injects DB_CONNECTION=mysql/DB_HOST=db
  etc. itself and depends_on db, so `docker compose run --rm cli php artisan
  test` works against real MariaDB with zero per-developer .env.testing edits
- Add docker-resources/mariadb/init/01-create-test-db.sql to provision a
  dedicated invoiceplane_test database alongside the dev one on first boot
- Fix db service: the named `database` volume was declared but never
  mounted, so all local dev/test data was lost on every container recreate
- docker-resources/php-cli/Dockerfile: rebuild on Debian (php:8.4-cli) with
  the minimal proven extension set, matching the ip2-test-php:8.4 image this
  session used successfully throughout — see InvoicePlane#689 for a still-open false-
  failure issue found with a fresh cli image build, flagged in the docs
- Update AGENTS.md/CLAUDE.md/README.md/.github/DOCKER.md/Makefile to point
  at the compose db/cli path instead of the SQLite instructions
- Note throughout: use `php artisan test`, not raw vendor/bin/phpunit — the
  two were observed to behave differently for this app's Livewire form tests
Prompted by shipping an abort_unless authorization guard (MyCompanies::switch,
see InvoicePlane#687) with zero test proving it actually blocks anything. Complements
security-review (finds missing guards) and is unrelated to test-honesty
(schema/factory/seeder alignment) — this specifically checks that guards
which DO exist in a diff have both an allow-path and a deny-path test.
MyCompanies::switch resolved its target Company from Filament's
table-action record dispatch and switched the active tenant without
verifying the authenticated user is actually a member of that company.
Under a full-suite CI run, Filament's table-action record caching was
observed binding $record to an unrelated company, which would silently
switch a user into a tenant they have no access to.

Add UserService::assertBelongsToCompany() and call it before the switch
as a defense-in-depth authorization check, throwing AuthorizationException
when the user is not a member. Cover both the allow and deny paths in
UserServiceTest, and tag the CI-only flaky switch assertion in
UserProfileTest with #[Group('flaky')], documenting the root cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCdyQ8FJiv9iPCJf1ydZQk
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds company-membership authorization, introduces MariaDB-based Docker test execution, updates testing guidance, adds test-gap documentation, and makes recent-widget ordering explicit by record ID.

Changes

Company membership authorization

Layer / File(s) Summary
Membership guard and service tests
Modules/Core/Services/UserService.php, Modules/Core/Tests/Unit/Services/UserServiceTest.php
UserService now validates company membership and throws AuthorizationException for foreign companies. Tests cover allowed and rejected companies.
Company switch enforcement
Modules/Core/Filament/Company/Pages/MyCompanies.php, Modules/Core/Tests/Feature/UserProfileTest.php
The switch action validates the authenticated user before changing the session. The feature test is marked flaky with CI behavior notes.

MariaDB test environment

Layer / File(s) Summary
Debian PHP CLI image
docker-resources/php-cli/Dockerfile
The CLI image now uses Debian PHP 8.4, apt-based packages, and a reduced extension set.
MariaDB test database wiring
docker-compose.yml, docker-resources/mariadb/init/01-create-test-db.sql
The CLI service runs Artisan tests against MariaDB. The database service provisions invoiceplane_test with persistent storage and initialization scripts.
Testing instructions and coverage guidance
.github/DOCKER.md, AGENTS.md, CLAUDE.md, Makefile, README.md, .claude/skills/test-gaps/SKILL.md
Documentation now specifies MariaDB-backed Artisan tests, Livewire test discrepancies, and allow- and deny-path coverage for guards.

Recent widget ordering

Layer / File(s) Summary
Explicit recent-record ordering
Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php, Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php, Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php, Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php
Recent records now order by descending id instead of the default timestamp column. The 10-record limits remain unchanged.

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

Possibly related PRs

Suggested reviewers: khawarmehfooz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: enforcing company membership before switching tenants.
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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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.

Actionable comments posted: 4

🤖 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.

Inline comments:
In `@CLAUDE.md`:
- Around line 202-204: Update the fenced command block containing the Docker
Compose test command to specify the bash language identifier, changing the
opening fence to bash-labeled syntax to satisfy markdownlint MD040.

In `@docker-compose.yml`:
- Around line 60-61: Update the db service configuration with a healthcheck that
verifies MariaDB is ready to accept connections and has completed
initialization, then replace cli’s short-form depends_on entry with long-form
configuration using db and condition: service_healthy.

In `@Modules/Core/Services/UserService.php`:
- Line 149: Update the condition in the UserService company membership check to
remove the whitespace between the opening parenthesis and the negation operator,
so it conforms to PSR-12. Run vendor/bin/pint and ensure it passes.

In `@Modules/Core/Tests/Unit/Services/UserServiceTest.php`:
- Around line 28-29: Add the PHPUnit #[Group('security')] attribute to both
membership authorization test methods, including it alongside the existing
#[Test] attributes for it_allows_a_user_to_switch_to_a_company_they_belong_to
and the other referenced authorization test, so CI can select them as security
coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 60457c93-247b-49c3-9320-9afc8c1dec0d

📥 Commits

Reviewing files that changed from the base of the PR and between fd370ae and 293b2d0.

📒 Files selected for processing (17)
  • .claude/skills/test-gaps/SKILL.md
  • .github/DOCKER.md
  • AGENTS.md
  • CLAUDE.md
  • Makefile
  • Modules/Core/Filament/Company/Pages/MyCompanies.php
  • Modules/Core/Services/UserService.php
  • Modules/Core/Tests/Feature/UserProfileTest.php
  • Modules/Core/Tests/Unit/Services/UserServiceTest.php
  • Modules/Expenses/Filament/Company/Widgets/RecentExpensesWidget.php
  • Modules/Payments/Filament/Company/Widgets/RecentPaymentsWidget.php
  • Modules/Projects/Filament/Company/Widgets/RecentProjectsWidget.php
  • Modules/Projects/Filament/Company/Widgets/RecentTasksWidget.php
  • README.md
  • docker-compose.yml
  • docker-resources/mariadb/init/01-create-test-db.sql
  • docker-resources/php-cli/Dockerfile

Comment thread CLAUDE.md
Comment on lines 202 to 204
```
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting
```

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the command block.

Line 202 starts an unlabeled fenced code block. Change it to ```bash to resolve markdownlint rule MD040.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 202-202: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@CLAUDE.md` around lines 202 - 204, Update the fenced command block containing
the Docker Compose test command to specify the bash language identifier,
changing the opening fence to bash-labeled syntax to satisfy markdownlint MD040.

Source: Linters/SAST tools

Comment thread docker-compose.yml
Comment on lines +60 to +61
depends_on:
- db

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

docker compose config | rg -n -C2 'depends_on:|condition: service_healthy|healthcheck:'

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== compose files =="
git ls-files | rg '(^|/)docker-compose\.ya?ml$|^\.github/workflows/.*compose|docker'

echo
echo "== docker-compose.yml relevant lines =="
if [ -f docker-compose.yml ]; then
  sed -n '1,130p' docker-compose.yml | cat -n
fi

echo
echo "== search for service_healthy/healthcheck/depends_on/db references =="
rg -n "depends_on|condition: service_healthy|healthcheck|invoiceplane_test|healthcheck|db:" -S .

Repository: InvoicePlane/InvoicePlane-v2

Length of output: 16492


🌐 Web query:

Docker Compose depend-on healthcheck service_healthy short-form does not wait for health documentation

💡 Result:

The short-form depends_on syntax in Docker Compose does not wait for a service's healthcheck to pass [1][2][3]. It only guarantees that the dependency container has started [1][4][3]. To ensure a service waits for a dependency to be fully ready (healthy), you must use the depends_on long-form syntax with the condition: service_healthy attribute [1][5][6]. ### Key Details - Short Syntax (depends_on: [service_name]): This strictly governs startup and shutdown order at the container level [1][3]. It considers a service "started" as soon as its container is created and running, regardless of whether the internal application is ready to accept connections [1][3]. - Long Syntax (condition: service_healthy): This tells Docker Compose to wait until the dependency explicitly reports a "healthy" status via its defined healthcheck [1][5][4]. - Requirement: For service_healthy to function, the dependency service must have a healthcheck block configured [7][3]. If no healthcheck is defined, the condition may not behave as intended, as there is no health status for Compose to monitor [3]. ### Example Configuration yaml services: web: depends_on: db: condition: service_healthy db: image: postgres healthcheck: test: ["CMD-SHELL", "pg_isready"] interval: 10s retries: 5 In this configuration, Compose will wait for the db service's healthcheck to pass before starting the web service [1][5][3].

Citations:


Wait for MariaDB readiness before starting cli.

The short depends_on only orders startup. It does not wait for MariaDB to accept connections or finish first-boot initialization that creates invoiceplane_test. Add a db healthcheck and use long-form depends_on with condition: service_healthy.

🤖 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 `@docker-compose.yml` around lines 60 - 61, Update the db service configuration
with a healthcheck that verifies MariaDB is ready to accept connections and has
completed initialization, then replace cli’s short-form depends_on entry with
long-form configuration using db and condition: service_healthy.

*/
public function assertBelongsToCompany(User $user, Company $company): void
{
if ( ! $user->companies()->whereKey($company->id)->exists()) {

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix PSR-12 whitespace before the negation operator.

Remove the space after the opening parenthesis. This change must pass Laravel Pint before merge.

Proposed fix
-        if ( ! $user->companies()->whereKey($company->id)->exists()) {
+        if (! $user->companies()->whereKey($company->id)->exists()) {

As per coding guidelines, “All code must conform to PSR-12 before merge — vendor/bin/pint must succeed.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ( ! $user->companies()->whereKey($company->id)->exists()) {
if (! $user->companies()->whereKey($company->id)->exists()) {
🤖 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 `@Modules/Core/Services/UserService.php` at line 149, Update the condition in
the UserService company membership check to remove the whitespace between the
opening parenthesis and the negation operator, so it conforms to PSR-12. Run
vendor/bin/pint and ensure it passes.

Source: Coding guidelines

Comment on lines +28 to +29
#[Test]
public function it_allows_a_user_to_switch_to_a_company_they_belong_to(): void

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a security group to both authorization tests.

Add #[Group('security')] to each test method. This makes the membership authorization coverage selectable in CI.

As per coding guidelines, “Use #[Group('smoke|crud|security|authentication|...')] attribute to organize tests by group.”

Also applies to: 42-43

🤖 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 `@Modules/Core/Tests/Unit/Services/UserServiceTest.php` around lines 28 - 29,
Add the PHPUnit #[Group('security')] attribute to both membership authorization
test methods, including it alongside the existing #[Test] attributes for
it_allows_a_user_to_switch_to_a_company_they_belong_to and the other referenced
authorization test, so CI can select them as security coverage.

Source: Coding guidelines

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