063-entra-signin (#76)

Key changes

Adds Entra OIDC redirect + callback endpoints under /auth/entra/* (token exchange only there).
Upserts tenant users keyed by (entra_tenant_id = tid, entra_object_id = oid); regenerates session; never stores tokens.
Blocks disabled / soft-deleted users with a generic error and safe logging.
Membership-based post-login routing:
0 memberships → /admin/no-access
1 membership → tenant dashboard (via Filament URL helpers)
>1 memberships → /admin/choose-tenant
Adds Filament pages:
/admin/choose-tenant (tenant selection + redirect)
/admin/no-access (tenantless-safe)
Both use simple layout to avoid tenant-required UI.
Guards / tests

Adds DbOnlyPagesDoNotMakeHttpRequestsTest to enforce DB-only render/hydration for:
/admin/login, /admin/no-access, /admin/choose-tenant
with Http::preventStrayRequests()
Adds session separation smoke coverage to ensure tenant session doesn’t access system and vice versa.
Runs: vendor/bin/sail artisan test --compact tests/Feature/Auth

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #76
This commit is contained in:
ahmido 2026-01-27 16:38:53 +00:00
parent 81c010fa00
commit c5fbcaa692
49 changed files with 2764 additions and 546 deletions

View File

@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Entra ID (OIDC) - Tenant Admin (/admin) sign-in
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_REDIRECT_URI="${APP_URL}/auth/entra/callback"
ENTRA_AUTHORITY_TENANT=organizations

View File

@ -175,7 +175,6 @@ ## 15) Agent output contract
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions - https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules === === .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
@ -258,7 +257,6 @@ ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
# Laravel Boost Guidelines # Laravel Boost Guidelines
@ -272,6 +270,7 @@ ## Foundational Context
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@ -281,7 +280,7 @@ ## Foundational Context
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
@ -289,7 +288,7 @@ ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
@ -301,17 +300,16 @@ ## Replies
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@ -322,22 +320,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order 3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules === === php rules ===
@ -348,7 +345,7 @@ ## PHP
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
@ -362,7 +359,7 @@ ### Type Declarations
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
@ -370,7 +367,6 @@ ## PHPDoc Blocks
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules === === sail rules ===
## Laravel Sail ## Laravel Sail
@ -378,21 +374,19 @@ ## Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute node commands: `vendor/bin/sail npm run dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
@ -404,7 +398,7 @@ ## Do Things the Laravel Way
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading. - Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
@ -439,36 +433,36 @@ ### Testing
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 ## Laravel 12
- Use the `search-docs` tool to get version specific documentation. - Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`. - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers. - `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules === === livewire/core rules ===
## Livewire Core ## Livewire
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components - Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it. - State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices ## Livewire Best Practices
- Livewire components require a single root element. - Livewire components require a single root element.
@ -485,15 +479,14 @@ ## Livewire Best Practices
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php"> <code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; } public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); } public function updatedSearch() { $this->resetPage(); }
</code-snippet> </code-snippet>
## Testing Livewire ## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php"> <code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class) Livewire::test(Counter::class)
->assertSet('count', 0) ->assertSet('count', 0)
->call('increment') ->call('increment')
@ -502,12 +495,10 @@ ## Testing Livewire
->assertStatus(200); ->assertStatus(200);
</code-snippet> </code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
<code-snippet name="Testing a Livewire component exists within a page" lang="php"> $this->get('/posts/create')
$this->get('/posts/create') ->assertSeeLivewire(CreatePost::class);
->assertSeeLivewire(CreatePost::class); </code-snippet>
</code-snippet>
=== pint/core rules === === pint/core rules ===
@ -516,7 +507,6 @@ ## Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
@ -537,9 +527,9 @@ ### Pest Tests
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test`. - To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions
@ -558,7 +548,7 @@ ### Mocking
- You can also create partial mocks using the same import or self method. - You can also create partial mocks using the same import or self method.
### Datasets ### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php"> <code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) { it('has emails', function (string $email) {
@ -569,18 +559,17 @@ ### Datasets
]); ]);
</code-snippet> </code-snippet>
=== pest/v4 rules === === pest/v4 rules ===
## Pest 4 ## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. - Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project. - Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`. - Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features. - Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing ### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. - You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. - Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari). - If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). - If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
@ -614,39 +603,37 @@ ### Example Tests
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); $pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet> </code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind Core ## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically - Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing ### Spacing
- When listing items, use gap utilities for spacing, don't use margins. - When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules === === tailwindcss/v4 rules ===
## Tailwind 4 ## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities. - Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4. - `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css"> <code-snippet name="Extending Theme in CSS" lang="css">
@theme { @theme {
--color-brand: oklch(0.72 0.11 178); --color-brand: oklch(0.72 0.11 178);
@ -662,9 +649,8 @@ ## Tailwind 4
+ @import "tailwindcss"; + @import "tailwindcss";
</code-snippet> </code-snippet>
### Replaced Utilities ### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric. - Opacity values are still numeric.
| Deprecated | Replacement | | Deprecated | Replacement |

View File

@ -1,19 +1,19 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 1.2.1 → 1.3.0 - Version change: 1.3.0 → 1.4.0
- Modified principles: - Modified principles:
- None - Operations / Run Observability Standard (added OPS-EX-AUTH-001)
- Added principles: - Added sections:
- Badge Semantics Are Centralized (BADGE-001) - OPS-EX-AUTH-001 — Auth Handshake Exception
- Added sections: None
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- ✅ .specify/templates/plan-template.md - ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/spec-template.md - ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/tasks-template.md - ✅ .specify/templates/tasks-template.md
- N/A: .specify/templates/commands/ (directory not present)
- Follow-up TODOs: - Follow-up TODOs:
- TODO(DELETED_STATUS): Keep “deleted” reserved for Feature 900 / Policy Lifecycle. - TODO(DELETED_STATUS): Keep “deleted” reserved for Feature 900 / Policy Lifecycle.
--> -->
# TenantPilot Constitution # TenantPilot Constitution
@ -53,6 +53,10 @@ ### Operations / Run Observability Standard
3. It is queued or scheduled. 3. It is queued or scheduled.
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”). 4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`. - Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
- OPS-EX-AUTH-001 — Auth Handshake Exception:
- OIDC/SAML login handshakes MAY perform synchronous outbound HTTP (e.g., token exchange) without an `OperationRun`.
- Rationale: interactive, session-critical, and not a tenant-operational “background job”.
- Guardrail: outbound HTTP for auth handshakes is allowed only on `/auth/*` endpoints and MUST NOT occur on Monitoring/Operations pages.
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry - If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
including actor, tenant, action, target, before/after, and timestamp. including actor, tenant, action, target, before/after, and timestamp.
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures), - The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
@ -104,4 +108,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.3.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-22 **Version**: 1.4.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-27

View File

@ -36,7 +36,7 @@ ## Constitution Check
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php` - Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests) - Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked - Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log - Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter - Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens - Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests

View File

@ -82,6 +82,9 @@ ## Requirements *(mandatory)*
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. (preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), **Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.

View File

@ -12,6 +12,8 @@ # Tasks: [FEATURE NAME]
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a **Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub. canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant). If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`.
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001), **Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values. avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.

140
Agents.md
View File

@ -562,7 +562,6 @@ ## 15) Agent output contract
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions - https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules === === .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
@ -645,7 +644,6 @@ ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
# Laravel Boost Guidelines # Laravel Boost Guidelines
@ -659,6 +657,7 @@ ## Foundational Context
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@ -668,7 +667,7 @@ ## Foundational Context
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
@ -676,7 +675,7 @@ ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
@ -688,17 +687,16 @@ ## Replies
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@ -709,22 +707,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order 3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules === === php rules ===
@ -735,7 +732,7 @@ ## PHP
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
@ -749,7 +746,7 @@ ### Type Declarations
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
@ -757,7 +754,6 @@ ## PHPDoc Blocks
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules === === sail rules ===
## Laravel Sail ## Laravel Sail
@ -765,21 +761,19 @@ ## Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute node commands: `vendor/bin/sail npm run dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
@ -791,7 +785,7 @@ ## Do Things the Laravel Way
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading. - Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
@ -826,36 +820,36 @@ ### Testing
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 ## Laravel 12
- Use the `search-docs` tool to get version specific documentation. - Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`. - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers. - `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules === === livewire/core rules ===
## Livewire Core ## Livewire
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components - Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it. - State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices ## Livewire Best Practices
- Livewire components require a single root element. - Livewire components require a single root element.
@ -872,15 +866,14 @@ ## Livewire Best Practices
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php"> <code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; } public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); } public function updatedSearch() { $this->resetPage(); }
</code-snippet> </code-snippet>
## Testing Livewire ## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php"> <code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class) Livewire::test(Counter::class)
->assertSet('count', 0) ->assertSet('count', 0)
->call('increment') ->call('increment')
@ -889,12 +882,10 @@ ## Testing Livewire
->assertStatus(200); ->assertStatus(200);
</code-snippet> </code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
<code-snippet name="Testing a Livewire component exists within a page" lang="php"> $this->get('/posts/create')
$this->get('/posts/create') ->assertSeeLivewire(CreatePost::class);
->assertSeeLivewire(CreatePost::class); </code-snippet>
</code-snippet>
=== pint/core rules === === pint/core rules ===
@ -903,7 +894,6 @@ ## Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
@ -924,9 +914,9 @@ ### Pest Tests
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test`. - To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions
@ -945,7 +935,7 @@ ### Mocking
- You can also create partial mocks using the same import or self method. - You can also create partial mocks using the same import or self method.
### Datasets ### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php"> <code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) { it('has emails', function (string $email) {
@ -956,18 +946,17 @@ ### Datasets
]); ]);
</code-snippet> </code-snippet>
=== pest/v4 rules === === pest/v4 rules ===
## Pest 4 ## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. - Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project. - Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`. - Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features. - Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing ### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. - You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. - Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari). - If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). - If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
@ -1001,39 +990,37 @@ ### Example Tests
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); $pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet> </code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind Core ## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically - Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing ### Spacing
- When listing items, use gap utilities for spacing, don't use margins. - When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules === === tailwindcss/v4 rules ===
## Tailwind 4 ## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities. - Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4. - `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css"> <code-snippet name="Extending Theme in CSS" lang="css">
@theme { @theme {
--color-brand: oklch(0.72 0.11 178); --color-brand: oklch(0.72 0.11 178);
@ -1049,9 +1036,8 @@ ## Tailwind 4
+ @import "tailwindcss"; + @import "tailwindcss";
</code-snippet> </code-snippet>
### Replaced Utilities ### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric. - Opacity values are still numeric.
| Deprecated | Replacement | | Deprecated | Replacement |

143
GEMINI.md
View File

@ -402,7 +402,6 @@ ## 15) Agent output contract
- https://filamentphp.com/docs/5.x/advanced/assets - https://filamentphp.com/docs/5.x/advanced/assets
- https://filamentphp.com/docs/5.x/testing/testing-actions - https://filamentphp.com/docs/5.x/testing/testing-actions
=== .ai/filament-v5-checklist rules === === .ai/filament-v5-checklist rules ===
# SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES) # SECTION C — AI REVIEW CHECKLIST (STRICT CHECKBOXES)
@ -485,7 +484,6 @@ ## Deployment / Ops
- [ ] `php artisan filament:assets` is included in the deployment process when using registered assets. - [ ] `php artisan filament:assets` is included in the deployment process when using registered assets.
- Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade” - Source: https://filamentphp.com/docs/5.x/advanced/assets — “The FilamentAsset facade”
=== foundation rules === === foundation rules ===
# Laravel Boost Guidelines # Laravel Boost Guidelines
@ -499,6 +497,7 @@ ## Foundational Context
- filament/filament (FILAMENT) - v5 - filament/filament (FILAMENT) - v5
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@ -508,7 +507,7 @@ ## Foundational Context
- tailwindcss (TAILWINDCSS) - v4 - tailwindcss (TAILWINDCSS) - v4
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
@ -516,7 +515,7 @@ ## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
@ -528,17 +527,16 @@ ## Replies
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@ -549,22 +547,21 @@ ## Reading Browser Logs With the `browser-logs` Tool
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order 3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules === === php rules ===
@ -575,7 +572,7 @@ ## PHP
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
@ -589,7 +586,7 @@ ### Type Declarations
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
@ -597,7 +594,6 @@ ## PHPDoc Blocks
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== sail rules === === sail rules ===
## Laravel Sail ## Laravel Sail
@ -605,21 +601,19 @@ ## Laravel Sail
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. - This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. - Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`. - Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples: - Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate` - Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install` - Install Composer packages: `vendor/bin/sail composer install`
- Execute node commands: `vendor/bin/sail npm run dev` - Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
@ -631,7 +625,7 @@ ## Do Things the Laravel Way
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading. - Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
@ -666,36 +660,36 @@ ### Testing
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 ## Laravel 12
- Use the `search-docs` tool to get version specific documentation. - Use the `search-docs` tool to get version-specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure ### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`. - In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. - `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers. - `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. - The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. - Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules === === livewire/core rules ===
## Livewire Core ## Livewire
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` artisan command to create new components - Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `vendor/bin/sail artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it. - State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices ## Livewire Best Practices
- Livewire components require a single root element. - Livewire components require a single root element.
@ -712,15 +706,14 @@ ## Livewire Best Practices
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php"> <code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; } public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); } public function updatedSearch() { $this->resetPage(); }
</code-snippet> </code-snippet>
## Testing Livewire ## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php"> <code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class) Livewire::test(Counter::class)
->assertSet('count', 0) ->assertSet('count', 0)
->call('increment') ->call('increment')
@ -729,12 +722,10 @@ ## Testing Livewire
->assertStatus(200); ->assertStatus(200);
</code-snippet> </code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
<code-snippet name="Testing a Livewire component exists within a page" lang="php"> $this->get('/posts/create')
$this->get('/posts/create') ->assertSeeLivewire(CreatePost::class);
->assertSeeLivewire(CreatePost::class); </code-snippet>
</code-snippet>
=== pint/core rules === === pint/core rules ===
@ -743,7 +734,6 @@ ## Laravel Pint Code Formatter
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. - Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
=== pest/core rules === === pest/core rules ===
## Pest ## Pest
@ -764,9 +754,9 @@ ### Pest Tests
### Running Tests ### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits. - Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `vendor/bin/sail artisan test`. - To run all tests: `vendor/bin/sail artisan test --compact`.
- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `vendor/bin/sail artisan test --compact --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. - When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions ### Pest Assertions
@ -785,7 +775,7 @@ ### Mocking
- You can also create partial mocks using the same import or self method. - You can also create partial mocks using the same import or self method.
### Datasets ### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. - Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php"> <code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) { it('has emails', function (string $email) {
@ -796,18 +786,17 @@ ### Datasets
]); ]);
</code-snippet> </code-snippet>
=== pest/v4 rules === === pest/v4 rules ===
## Pest 4 ## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage. - Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project. - Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`. - Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features. - Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing ### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test. - You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test. - Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari). - If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints). - If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
@ -841,39 +830,37 @@ ### Example Tests
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); $pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet> </code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind Core ## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically - Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing ### Spacing
- When listing items, use gap utilities for spacing, don't use margins. - When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules === === tailwindcss/v4 rules ===
## Tailwind 4 ## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities. - Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4. - `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css"> <code-snippet name="Extending Theme in CSS" lang="css">
@theme { @theme {
--color-brand: oklch(0.72 0.11 178); --color-brand: oklch(0.72 0.11 178);
@ -889,9 +876,8 @@ ## Tailwind 4
+ @import "tailwindcss"; + @import "tailwindcss";
</code-snippet> </code-snippet>
### Replaced Utilities ### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric. - Opacity values are still numeric.
| Deprecated | Replacement | | Deprecated | Replacement |
@ -910,8 +896,9 @@ ### Replaced Utilities
</laravel-boost-guidelines> </laravel-boost-guidelines>
## Recent Changes ## Recent Changes
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
## Active Technologies ## Active Technologies
- PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0` (063-entra-signin)

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\Login as BaseLogin;
class Login extends BaseLogin
{
protected string $view = 'filament.pages.auth.login';
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserTenantPreference;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Schema;
class ChooseTenant extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'choose-tenant';
protected static ?string $title = 'Choose tenant';
protected string $view = 'filament.pages.choose-tenant';
/**
* @return Collection<int, Tenant>
*/
public function getTenants(): Collection
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0')->get();
}
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
if ($tenants instanceof Collection) {
return $tenants;
}
return collect($tenants);
}
public function selectTenant(int $tenantId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = Tenant::query()
->where('status', 'active')
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {
$user->forceFill(['last_tenant_id' => $tenant->getKey()])->save();
return;
}
if (! Schema::hasTable('user_tenant_preferences')) {
return;
}
UserTenantPreference::query()->updateOrCreate(
['user_id' => $user->getKey(), 'tenant_id' => $tenant->getKey()],
['last_used_at' => now()]
);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use Filament\Pages\Page;
class NoAccess extends Page
{
protected static string $layout = 'filament-panels::components.layout.simple';
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'no-access';
protected static ?string $title = 'No access';
protected string $view = 'filament.pages.no-access';
}

View File

@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Models\User;
use App\Services\Auth\PostLoginRedirectResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EntraController
{
public function redirect(Request $request): RedirectResponse
{
$clientId = (string) config('services.microsoft.client_id');
$clientSecret = (string) config('services.microsoft.client_secret');
$redirectUri = (string) config('services.microsoft.redirect');
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$state = (string) Str::uuid();
$request->session()->put('entra_state', $state);
$scopes = implode(' ', ['openid', 'profile', 'email']);
$url = sprintf(
'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s',
$authorityTenant,
http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => $scopes,
'state' => $state,
])
);
return redirect()->away($url);
}
public function callback(Request $request): RedirectResponse
{
$expectedState = $request->session()->pull('entra_state');
if (! is_string($expectedState) || $expectedState === '') {
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
}
if ($expectedState !== $request->string('state')->toString()) {
return $this->failRedirect($request, reasonCode: 'oidc_invalid_state');
}
if ($request->string('error')->toString() !== '') {
return $this->failRedirect($request, reasonCode: 'oidc_user_denied');
}
$code = $request->string('code')->toString();
if ($code === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$clientId = (string) config('services.microsoft.client_id');
$clientSecret = (string) config('services.microsoft.client_secret');
$redirectUri = (string) config('services.microsoft.redirect');
$authorityTenant = (string) config('services.microsoft.tenant', 'organizations');
if ($clientId === '' || $clientSecret === '' || $redirectUri === '') {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$response = Http::asForm()->post(
sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $authorityTenant),
[
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUri,
]
);
if ($response->failed()) {
return $this->failRedirect($request, reasonCode: 'oidc_provider_unavailable');
}
$payload = $response->json() ?: [];
$idToken = $payload['id_token'] ?? null;
if (! is_string($idToken) || $idToken === '') {
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
}
$claims = $this->decodeJwtClaims($idToken);
$entraTenantId = is_string($claims['tid'] ?? null) ? (string) $claims['tid'] : '';
$entraObjectId = is_string($claims['oid'] ?? null) ? (string) $claims['oid'] : '';
if ($entraTenantId === '' || $entraObjectId === '') {
return $this->failRedirect($request, reasonCode: 'oidc_missing_claims');
}
$email = $this->resolveEmailFromClaims($claims, $entraTenantId, $entraObjectId);
$name = $this->resolveNameFromClaims($claims, $email);
try {
$existingUser = User::withTrashed()
->where('entra_tenant_id', $entraTenantId)
->where('entra_object_id', $entraObjectId)
->first();
if ($existingUser?->trashed()) {
return $this->failRedirect(
$request,
reasonCode: 'user_disabled',
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
userId: (int) $existingUser->getKey(),
);
}
$isNewUser = $existingUser === null;
$user = $existingUser ?? new User;
$user->fill([
'entra_tenant_id' => $entraTenantId,
'entra_object_id' => $entraObjectId,
'email' => $email,
'name' => $name,
]);
if ($isNewUser) {
$user->password = Str::password(64);
}
$user->save();
} catch (\Throwable $exception) {
return $this->failRedirect(
$request,
reasonCode: 'oidc_user_upsert_failed',
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
);
}
Auth::login($user);
$request->session()->regenerate();
Log::info('auth.entra.login', $this->logContext($request, success: true, entraTenantId: $entraTenantId, entraObjectId: $entraObjectId, userId: (int) $user->getKey()));
$redirectTo = app(PostLoginRedirectResolver::class)->resolve($user);
return redirect()->to($redirectTo);
}
/**
* @return array<string, mixed>
*/
private function decodeJwtClaims(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) < 2) {
return [];
}
$payload = $this->base64UrlDecode($parts[1]);
if ($payload === null) {
return [];
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
private function base64UrlDecode(string $value): ?string
{
$value = str_replace(['-', '_'], ['+', '/'], $value);
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($value, true);
return $decoded === false ? null : $decoded;
}
/**
* @param array<string, mixed> $claims
*/
private function resolveEmailFromClaims(array $claims, string $entraTenantId, string $entraObjectId): string
{
$candidate = null;
foreach (['preferred_username', 'email', 'upn'] as $key) {
$value = $claims[$key] ?? null;
if (is_string($value) && $value !== '') {
$candidate = $value;
break;
}
}
if (! is_string($candidate) || $candidate === '') {
$candidate = sprintf('%s@%s.entra.invalid', $entraObjectId, $entraTenantId);
}
$candidate = strtolower(trim($candidate));
return Str::limit($candidate, 255, '');
}
/**
* @param array<string, mixed> $claims
*/
private function resolveNameFromClaims(array $claims, string $email): string
{
$candidate = $claims['name'] ?? null;
if (is_string($candidate) && $candidate !== '') {
return Str::limit(trim($candidate), 255, '');
}
$given = $claims['given_name'] ?? null;
$family = $claims['family_name'] ?? null;
if (is_string($given) && is_string($family)) {
$full = trim($given.' '.$family);
if ($full !== '') {
return Str::limit($full, 255, '');
}
}
return Str::limit($email, 255, '');
}
private function failRedirect(
Request $request,
string $reasonCode,
?string $entraTenantId = null,
?string $entraObjectId = null,
?int $userId = null,
): RedirectResponse {
Log::warning('auth.entra.login', $this->logContext(
$request,
success: false,
reasonCode: $reasonCode,
entraTenantId: $entraTenantId,
entraObjectId: $entraObjectId,
userId: $userId,
));
return redirect()
->to('/admin/login')
->with('error', 'Authentication failed. Please try again.');
}
/**
* @return array{success:bool,reason_code?:string,user_id?:int,entra_tenant_id?:string,entra_object_id_hash?:string,correlation_id:string,timestamp:string}
*/
private function logContext(
Request $request,
bool $success,
?string $reasonCode = null,
?string $entraTenantId = null,
?string $entraObjectId = null,
?int $userId = null,
): array {
$correlationId = $request->header('X-Request-Id')
?: ($request->hasSession() ? $request->session()->getId() : null)
?: (string) Str::uuid();
$context = [
'success' => $success,
'correlation_id' => (string) $correlationId,
'timestamp' => now()->toISOString(),
];
if ($reasonCode !== null) {
$context['reason_code'] = $reasonCode;
}
if ($userId !== null) {
$context['user_id'] = $userId;
}
if ($entraTenantId !== null) {
$context['entra_tenant_id'] = $entraTenantId;
}
if ($entraObjectId !== null) {
$context['entra_object_id_hash'] = hash('sha256', $entraObjectId);
}
return $context;
}
}

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -21,6 +22,8 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
use SoftDeletes;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *

View File

@ -34,8 +34,11 @@
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
use Filament\Events\TenantSet; use Filament\Events\TenantSet;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -81,6 +84,10 @@ public function register(): void
*/ */
public function boot(): void public function boot(): void
{ {
RateLimiter::for('entra-callback', function (Request $request) {
return Limit::perMinute(20)->by((string) $request->ip());
});
RestoreRun::observe(RestoreRunObserver::class); RestoreRun::observe(RestoreRunObserver::class);
Event::listen(TenantSet::class, function (TenantSet $event): void { Event::listen(TenantSet::class, function (TenantSet $event): void {

View File

@ -2,10 +2,14 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\Middleware\DenyNonMemberTenantAccess;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -31,9 +35,14 @@ public function panel(Panel $panel): Panel
->default() ->default()
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login(Login::class)
->authenticatedRoutes(function (Panel $panel): void {
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
})
->tenant(Tenant::class, slugAttribute: 'external_id') ->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t') ->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu() ->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class) ->tenantRegistration(RegisterTenant::class)
->colors([ ->colors([

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Collection;
class PostLoginRedirectResolver
{
public function resolve(User $user): string
{
$tenants = $this->getActiveTenants($user);
if ($tenants->isEmpty()) {
return '/admin/no-access';
}
if ($tenants->count() === 1) {
/** @var Tenant $tenant */
$tenant = $tenants->first();
return TenantDashboard::getUrl(tenant: $tenant);
}
return '/admin/choose-tenant';
}
/**
* @return Collection<int, Tenant>
*/
private function getActiveTenants(User $user): Collection
{
if ($user->isPlatformSuperadmin()) {
return Tenant::query()
->where('status', 'active')
->orderBy('name')
->get();
}
return $user->tenants()
->where('status', 'active')
->orderBy('name')
->get();
}
}

View File

@ -10,7 +10,9 @@
"filament/filament": "^5.0", "filament/filament": "^5.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"torchlight/engine": "^0.1.0" "torchlight/engine": "^0.1.0",
"laravel/socialite": "^5.0",
"socialiteproviders/microsoft-azure": "^4.0"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.16", "barryvdh/laravel-debugbar": "^3.16",

1079
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -35,4 +35,11 @@
], ],
], ],
'microsoft' => [
'client_id' => env('ENTRA_CLIENT_ID'),
'client_secret' => env('ENTRA_CLIENT_SECRET'),
'redirect' => env('ENTRA_REDIRECT_URI'),
'tenant' => env('ENTRA_AUTHORITY_TENANT', 'organizations'),
],
]; ];

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasColumn('users', 'deleted_at')) {
return;
}
Schema::table('users', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasColumn('users', 'deleted_at')) {
return;
}
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default}; function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!s&&e>this.getStepIndex(this.step)||(this.step=t,this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,36 @@
<x-filament-panels::page.simple>
<div class="flex flex-col gap-6">
@if (session('error'))
<div class="rounded-md bg-red-50 p-4 text-sm text-red-800 dark:bg-red-950/30 dark:text-red-200">
{{ session('error') }}
</div>
@endif
@php
$isConfigured = filled(config('services.microsoft.client_id'))
&& filled(config('services.microsoft.client_secret'))
&& filled(config('services.microsoft.redirect'));
@endphp
@if (! $isConfigured)
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
Microsoft sign-in is not configured.
</div>
@endif
<div class="flex flex-col gap-3">
<x-filament::button
tag="a"
href="{{ route('auth.entra.redirect') }}"
:disabled="! $isConfigured"
color="primary"
>
Sign in with Microsoft
</x-filament::button>
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
Tenant Admin access requires a tenant membership.
</div>
</div>
</div>
</x-filament-panels::page.simple>

View File

@ -0,0 +1,37 @@
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Select a tenant to continue.
</div>
@php
$tenants = $this->getTenants();
@endphp
@if ($tenants->isEmpty())
<div class="rounded-md border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
No tenants are available for your account.
</div>
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($tenants as $tenant)
<div wire:key="tenant-{{ $tenant->id }}" class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
<div class="flex flex-col gap-3">
<div class="font-medium text-gray-900 dark:text-gray-100">
{{ $tenant->name }}
</div>
<x-filament::button
type="button"
color="primary"
wire:click="selectTenant({{ (int) $tenant->id }})"
>
Continue
</x-filament::button>
</div>
</div>
@endforeach
</div>
@endif
</div>
</x-filament::section>

View File

@ -0,0 +1,11 @@
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
You dont have access to any tenants yet.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Ask an administrator to add you to a tenant, then sign in again.
</div>
</div>
</x-filament::section>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\TenantOnboardingController; use App\Http\Controllers\TenantOnboardingController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -20,3 +21,10 @@
Route::get('/admin/rbac/callback', [RbacDelegatedAuthController::class, 'callback']) Route::get('/admin/rbac/callback', [RbacDelegatedAuthController::class, 'callback'])
->name('admin.rbac.callback'); ->name('admin.rbac.callback');
Route::get('/auth/entra/redirect', [EntraController::class, 'redirect'])
->name('auth.entra.redirect');
Route::get('/auth/entra/callback', [EntraController::class, 'callback'])
->middleware('throttle:entra-callback')
->name('auth.entra.callback');

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Entra Sign-in (Tenant Panel) v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-26
**Feature**: [specs/063-entra-signin/spec.md](specs/063-entra-signin/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All validation checks passed. The specification is clear, complete, and ready for the planning phase.
- Three clarifications were incorporated: multi-tenant login flow, disabled user login behavior, and data model column sizing.

View File

@ -0,0 +1,39 @@
# Contract: Entra Auth Flow
This document describes the OIDC authentication flow for Entra sign-in.
## Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant TenantPilot
participant MicrosoftEntra
User->>Browser: Access /admin/login
Browser->>TenantPilot: GET /admin/login
TenantPilot-->>Browser: Show "Sign in with Microsoft" button
User->>Browser: Click "Sign in with Microsoft"
Browser->>TenantPilot: GET /auth/entra/redirect
TenantPilot->>Browser: HTTP 302 Redirect to Microsoft Entra
Browser->>MicrosoftEntra: GET /authorize... (with OIDC params)
MicrosoftEntra-->>Browser: Show Microsoft login page
User->>Browser: Enter credentials
Browser->>MicrosoftEntra: POST credentials
MicrosoftEntra-->>Browser: HTTP 302 Redirect to /auth/entra/callback (with auth code)
Browser->>TenantPilot: GET /auth/entra/callback?code=...&state=...
TenantPilot->>MicrosoftEntra: POST /token (exchange code for ID token)
MicrosoftEntra-->>TenantPilot: Return ID Token (JWT)
TenantPilot->>TenantPilot: Validate token, decode claims (tid, oid, etc.)
TenantPilot->>TenantPilot: Upsert user in DB using (tid, oid)
TenantPilot->>TenantPilot: Regenerate session, log user in
TenantPilot->>TenantPilot: Check user's tenant memberships
alt 0 memberships
TenantPilot->>Browser: HTTP 302 Redirect to /admin/no-access
else 1 membership
TenantPilot->>Browser: HTTP 302 Redirect to tenant dashboard
else >1 memberships
TenantPilot->>Browser: HTTP 302 Redirect to /admin/choose-tenant
end
```

View File

@ -0,0 +1,21 @@
# Data Model: 063 — Entra Sign-in
This feature reuses the existing `users` table and does not introduce new tables.
## `users` table
The following columns are used for Entra ID integration. The spec confirms these columns and their types are authoritative for v1 and should not be changed.
- `entra_tenant_id`
- **Type**: `varchar(255)`
- **Nullable**: Yes
- **Description**: Stores the Entra ID tenant identifier (`tid` claim).
- `entra_object_id`
- **Type**: `varchar(255)`
- **Nullable**: Yes
- **Description**: Stores the Entra ID user object identifier (`oid` claim).
### Indexes
A unique composite index on `(entra_tenant_id, entra_object_id)` already exists and will be used to enforce uniqueness for user upserts.

View File

@ -0,0 +1,96 @@
# Implementation Plan: 063 — Entra Sign-in (Tenant Panel) v1
**Branch**: `063-entra-signin` | **Date**: 2026-01-27 | **Spec**: [specs/063-entra-signin/spec.md](spec.md)
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/063-entra-signin/spec.md`
## Summary
This feature will implement Microsoft Entra ID OIDC-based sign-in for the Tenant Admin panel. The implementation will override the default Filament login page to present an Entra-only sign-in option. It will utilize Laravel Socialite to manage the OAuth2 flow, handling redirection to Microsoft Entra ID and processing the callback. Core logic involves upserting users based on `(entra_tenant_id, entra_object_id)`, blocking disabled users, regenerating sessions, and dynamically routing users post-login to their tenant dashboard, a dedicated tenant chooser page (for multiple memberships), or a "no access" page (for zero memberships). The implementation adheres to security best practices by not storing sensitive tokens and ensures all affected pages remain DB-only at render time.
Routing stability rule: redirects into a tenant MUST use Filament page URL helpers (e.g., `App\\Filament\\Pages\\TenantDashboard::getUrl(tenant: $tenant)`) rather than hardcoding `/admin/t/...`, so future route prefix / tenant slug changes dont break auth flows.
## Technical Context
**Language/Version**: PHP 8.4
**Primary Dependencies**: `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
**Storage**: PostgreSQL
**Testing**: Pest
**Target Platform**: Web
**Project Type**: Web application
**Performance Goals**: Callback returns within ~2s under normal conditions.
**Constraints**: Do not persist secrets/tokens. Sanitize all error output and logs. Outbound HTTP is permitted only inside /auth/entra/* endpoints.
**Scale/Scope**: Tenant Admin panel (`/admin`) sign-in only.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **Inventory-first**: Not directly applicable to auth.
- **Read/write separation**: N/A for login, but user upsert is a write. The spec requires it to be idempotent, which aligns.
- **Graph contract path**: The spec explicitly forbids Graph calls during render/poll/hydration, and limits OIDC calls to the `/auth/entra/*` routes. This is compliant.
- **Deterministic capabilities**: N/A.
- **Tenant isolation**: Compliant. The entire flow is built around tenant context (`tid`).
- **Run observability**: Compliant. The spec references `OPS-EX-AUTH-001`, the Auth Handshake Exception, which exempts this synchronous login flow from requiring an `OperationRun`. Logging requirements are specified.
- **Automation**: N/A.
- **Data minimization**: Compliant. Spec says "MUST NOT store Entra access/refresh tokens" and requires safe logging.
- **Badge semantics (BADGE-001)**: N/A.
**Result**: The plan is compliant with the constitution.
## Project Structure
### Documentation (this feature)
```text
specs/063-entra-signin/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── entra-auth-flow.md
└── tasks.md
```
### Source Code (repository root)
```text
# Web application
app/
├── Http/
│ ├── Controllers/
│ │ └── Auth/
│ │ └── EntraController.php # New: Handles OIDC redirect and callback logic
│ └── Middleware/
├── Filament/
│ ├── Pages/
│ │ ├── Auth/
│ │ │ └── Login.php # New: Custom Filament login page with Entra-only CTA
│ │ ├── NoAccess.php # New: Page for users with no tenant memberships
│ │ └── ChooseTenant.php # New: Page for users with multiple tenant memberships
│ └── Tenant/
│ └── Resources/
├── Models/
│ └── User.php # Modified: User model for Entra IDs and tenant relationships
├── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php # Modified: Register custom login page
config/
├── services.php # Modified: Add Microsoft Socialite provider config
routes/
│ └── web.php # Modified: Register OIDC redirect and callback routes
tests/
└── Feature/
└── Auth/
├── AdminLoginIsEntraOnlyTest.php
├── EntraCallbackUpsertByTidOidTest.php
├── PostLoginRoutingByMembershipTest.php
├── OidcFailureRedirectsSafelyTest.php
├── SessionSeparationSmokeTest.php
└── DisabledUserLoginIsBlockedTest.php
```
**Structure Decision**: The project is a standard Laravel web application. The changes will be implemented within the existing structure, primarily affecting `app/`, `routes/`, `config/` and `tests/`.
## Complexity Tracking
No violations.

View File

@ -0,0 +1,46 @@
# Quickstart: 063 — Entra Sign-in
## 1. Environment Setup
Add the following to your `.env` file. These are required for the Microsoft Socialite provider.
```dotenv
MICROSOFT_CLIENT_ID=your-entra-app-client-id
MICROSOFT_CLIENT_SECRET=your-entra-app-client-secret
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/entra/callback"
```
## 2. Install Dependencies
Ensure Laravel Socialite is installed:
```bash
sail composer require laravel/socialite
```
## 3. Configuration
Add the Microsoft provider configuration to `config/services.php`:
```php
'microsoft' => [
'client_id' => env('MICROSOFT_CLIENT_ID'),
'client_secret' => env('MICROSOFT_CLIENT_SECRET'),
'redirect' => env('MICROSOFT_REDIRECT_URI'),
'tenant' => 'common', // Or your specific tenant ID
],
```
## 4. Run Migrations
The required columns (`entra_tenant_id`, `entra_object_id`) and the unique index should already exist from previous migrations. If not, a migration will be created.
```bash
sail artisan migrate
```
## 5. Usage
1. Navigate to `/admin/login`.
2. Click "Sign in with Microsoft".
3. Complete the sign-in flow on the Microsoft page.
4. You will be redirected back to the application and routed according to your tenant memberships.

View File

@ -0,0 +1,51 @@
# Research: 063 — Entra Sign-in
## 1. Required OAuth Scopes for Entra ID Sign-in
**Decision**: Use the standard OpenID Connect scopes: `openid`, `email`, `profile`.
**Rationale**:
- `openid`: Required for OIDC compliance, returns the `sub` (subject) claim which can be used for the user identifier.
- `email`: Requests the `email` claim.
- `profile`: Requests claims like `name`, `family_name`, `given_name`.
The `tid` and `oid` claims are standard in Microsoft's implementation and do not require special scopes.
**Alternatives considered**:
- Requesting more specific scopes (e.g., `User.Read` from Microsoft Graph). This is not necessary for basic sign-in and would require the Graph API, which is out of scope for the login flow itself.
## 2. User Provisioning Strategy
**Decision**: Just-In-Time (JIT) provisioning using `updateOrCreate`.
**Rationale**:
- The `spec.md` requires upserting the user based on `(entra_tenant_id, entra_object_id)`.
- Laravel's `updateOrCreate` is the idiomatic way to handle this. It will find a user with the matching `entra_tenant_id` and `entra_object_id` or create a new one if none exists.
- The `User` model will be updated with information from the Entra ID claims (name, email).
**Alternatives considered**:
- Pre-provisioning users. This would require an admin to manually create users before they can sign in, which is not a good user experience.
## 3. Interaction with Tenant RBAC
**Decision**: The sign-in flow will only *read* tenant memberships after the user is authenticated.
**Rationale**:
- The `spec.md` is clear: "063 MUST NOT refactor tenant RBAC data model or enforcement. It may only **read** memberships to decide where to redirect after login."
- After the user is upserted and logged in, a service class will be used to check their tenant memberships (e.g., `Auth::user()->tenants()->count()`).
- The result of this check (0, 1, or N) will determine the redirect path as per the spec.
**Alternatives considered**:
- Modifying RBAC during login. This is explicitly out of scope.
## 4. User Experience for Unmapped/New Users
**Decision**: The user experience is defined by the post-login routing based on tenant memberships.
**Rationale**:
- If a new user signs in via Entra, they will be created in the `users` table.
- At this point, they will have 0 tenant memberships.
- According to the spec (`FR-003`), they will be redirected to `/admin/no-access`.
- This page will instruct them to "Ask an admin to add you." This is the desired flow.
**Alternatives considered**:
- Displaying an error on the login page. This is less user-friendly than guiding them to a page that explains the next steps.

View File

@ -0,0 +1,245 @@
# Feature Specification: 063 — Entra Sign-in (Tenant Panel) v1
**Feature Branch**: `063-entra-signin`
**Created**: 2026-01-26
**Status**: Draft (v1)
**Scope**: Tenant Admin panel (`/admin`) sign-in only
---
## Context / Goal
TenantAtlas needs a clean, enterprise-grade sign-in flow for the **Tenant Admin panel** (`/admin`) using **Microsoft Entra ID (OIDC)**.
This feature MUST:
- provide a Microsoft Entra sign-in entrypoint on `/admin/login` (Entra-only enforcement is finalized in Feature 064)
- upsert tenant users safely keyed by `(entra_tenant_id, entra_object_id)`
- route users after login based on **tenant memberships**
- keep pages **DB-only at render time**
- log safely (no tokens/claims dumps)
This feature intentionally does **not** change platform/operator access (`/system`) or break-glass behavior; those belong to Feature **064-auth-structure**.
---
## Scope boundary (063 vs 064)
063 introduces Entra OIDC sign-in capability for the tenant panel:
- Entra redirect + callback
- Safe user upsert keyed by (tid, oid)
- Membership-based post-login routing (0 / 1 / N memberships)
Making "/admin" fully **Entra-only** (removing local form login and removing any break-glass/operator UX from /admin) is finalized in **Feature 064 (Auth Structure)**.
---
## Clarifications
### Session 2026-01-26
- Q: For a user belonging to multiple tenants, what should happen immediately after they sign in? → A: Redirect to a dedicated, full-screen "tenant chooser" page that lists all their memberships. The user must click one to proceed to the main dashboard.
- Q: If a user record exists in a disabled state (e.g., `deleted_at` is not null, or an `is_active` flag is false), and that user completes a valid Entra ID sign-in, what should happen? → A: Block the login. Redirect the user to the login page with a generic error message (e.g., "Your account is disabled. Please contact an administrator.").
- Q: What is the v1 DB strategy for users.entra_tenant_id / users.entra_object_id? → A: Keep existing columns/types if present (currently varchar(255) nullable) and keep the existing unique composite index. Do not enforce NOT NULL or type-change in v1.
---
### Session 2026-01-27
- Q: How should the Entra Tenant ID (`tid`) be treated in logs? → A: Log the `entra_tenant_id` in plaintext. Rationale: `tid` is a tenant identifier, not a user secret. Plaintext is valuable for operations and incident response. The primary PII (`oid`) is hashed.
---
## Existing System Compatibility (important)
The repository already contains:
- A **System Panel** at `/system` using guard `platform` and `platform_users` (platform operator access).
- Break-glass recovery mechanics for platform operators (banner/middleware/routes).
- Tenant membership storage using a pivot table (`tenant_user` or equivalent) with role values via `TenantRole` enum.
**Compatibility constraints for 063**
- 063 MUST NOT modify `/system` panel, platform guards, platform users, or break-glass routes/UX.
- 063 MUST NOT refactor tenant RBAC data model or enforcement. It may only **read** memberships to decide where to redirect after login.
- 063 MUST NOT introduce Graph calls (or any outbound HTTP) during render/poll/hydration of `/admin` pages.
---
## Non-Goals (explicit)
- No Platform Operator `/system` login implementation (already exists or handled elsewhere).
- No break-glass UX on `/admin/login`.
- No tenant RBAC redesign or role enforcement changes (assume memberships already exist).
- No Graph calls or remote work in login render/poll/hydration.
- No storing of Entra access/refresh tokens.
- No queue/worker dependence for login (login is synchronous request/response).
---
## User Scenarios & Testing (mandatory)
### User Story 1 — Entra sign-in entrypoint on `/admin/login` (Priority: P1)
A tenant user can start Microsoft Entra sign-in from `/admin/login`.
**Acceptance Scenarios**
1. Given I open `/admin/login`, then I see only “Sign in with Microsoft” and no email/password fields.
2. Given Entra config is missing/invalid, `/admin/login` still renders and shows a generic message (no secrets).
**Independent Test**
- Render `/admin/login` and assert no password inputs exist.
---
### User Story 2 — OIDC callback upserts tenant identity safely (Priority: P1)
The callback upserts a tenant user using Entra claims.
**Acceptance Scenarios**
1. Given Entra callback includes `tid` and `oid`, when sign-in completes, then `users` is upserted keyed by:
- `entra_tenant_id = tid`
- `entra_object_id = oid`
2. Given sign-in succeeds, then the session is regenerated.
3. Given callback is missing `tid` or `oid`, then redirect to `/admin/login` with a generic error.
4. Given a user exists but is disabled (soft-deleted), when they complete a valid sign-in, then they are redirected to /admin/login with a generic error.
**Independent Test**
- Fake a callback payload and assert `(tid, oid)` uniqueness is enforced.
---
- Q: What is the specific title and primary message for the `/admin/no-access` page? → A: Title: "No Access", Message: "Please contact an administrator for access."
### User Story 3 — Post-login routing is membership-based (Priority: P1)
After login, routing depends on Suite tenant memberships.
**Acceptance Scenarios**
1. Given I have 0 memberships, then redirect to `/admin/no-access`.
2. Given I have exactly 1 membership, then redirect into that tenants dashboard.
3. Given I have >1 memberships, then redirect to a dedicated, full-screen tenant chooser page, displaying tenant name and role for each option.
**Independent Test**
- Seed memberships and assert each redirect path.
---
### User Story 4 — Filament-native “No access” page (Priority: P2)
Users without memberships get a safe, Filament-native page.
**Acceptance Scenarios**
1. Given I have 0 memberships, `/admin/no-access` renders using Filament UI (no raw HTML pages), with the title "No Access" and the message "Please contact an administrator for access.".
2. The page does not leak internal details; it provides next steps (“Ask an admin to add you”).
---
## Requirements (mandatory)
### Functional Requirements
- **FR-001**: `/admin/login` MUST offer a "Sign in with Microsoft" entrypoint that starts the Entra OIDC flow. (Entra-only removal of local/break-glass UX is finalized in Feature 064.)
- **FR-002**: Tenant user upsert MUST be keyed by `(entra_tenant_id, entra_object_id)` and MUST NOT store Entra access/refresh tokens.
- **FR-003**: Post-login routing MUST be based on memberships:
- 0 → `/admin/no-access`
- 1 → tenant dashboard
- N → dedicated tenant chooser page, displaying tenant name and role for each option.
- **FR-004**: OIDC failures MUST be handled safely:
- redirect to `/admin/login` with generic error
- log stable `reason_code` + `correlation_id`
- never log token/claims payloads
- **FR-005**: Logging MUST be privacy-safe:
- success: minimal (user_id, tid, oid hash, timestamp, correlation_id)
- failure: `reason_code`, correlation_id, minimal diagnostics
- **FR-006**: `/admin/login`, `/admin/no-access`, and `/admin/choose-tenant` MUST be DB-only at render/hydration time (no outbound HTTP).
- **FR-007**: 063 MUST NOT surface break-glass links or platform login UX on `/admin/login`.
- **FR-008**: Session separation MUST prevent implicit crossover:
- a tenant session MUST NOT grant access to `/system`
- a platform session MUST NOT grant access to `/admin` tenant membership routes
(Implementation is via separate guards/panels; 063 only asserts behavior via tests.)
- **FR-009**: Login flow MUST NOT require queue workers; it must complete synchronously.
- **FR-010**: If a user record exists but is in a disabled or soft-deleted state, a successful Entra ID authentication MUST be blocked, and the user redirected to the login page with a generic error.
### Non-Functional Requirements (NFR)
- **NFR-01 (Security)**: Do not persist secrets/tokens. Sanitize all error output and logs.
- **NFR-02 (Stability)**: Callback is idempotent; safe to retry without creating duplicates.
- **NFR-03 (Performance)**: Callback returns within ~2s under normal conditions.
- **NFR-04 (Maintainability)**: Minimal diff; do not refactor membership/RBAC models.
---
## Auth handshake exception to Operations/Run standard
The Operations/Run Observability standard applies to queued/long-running tenant operations (sync, restore, drift, provider ops). The Entra OIDC sign-in handshake is an interactive authentication flow that necessarily performs outbound HTTPS to Entra endpoints during the callback.
This auth handshake is explicitly exempt from the OperationRun requirement:
- It is user-initiated and must complete synchronously to establish a session.
- It does not represent a tenant-scoped background operation.
- It must not enqueue jobs or perform remote work beyond the OIDC exchange.
Guardrail: Only the `/auth/entra/*` endpoints may perform outbound HTTP for OIDC. `/admin/login`, `/admin/no-access`, and `/admin/choose-tenant` remain DB-only at render/hydration time.
---
## Data Model
063 reuses the existing `users` table.
**Current repo state (authoritative for v1):**
- `entra_tenant_id` (varchar(255), nullable)
- `entra_object_id` (varchar(255), nullable)
- Unique composite index on (`entra_tenant_id`, `entra_object_id`) already exists.
**v1 decision (enterprise-safe, minimal-risk):**
- Keep both columns **nullable** in v1.
- Do **not** change column types (no uuid migration) in v1.
- Do **not** enforce NOT NULL in v1.
Optional (if already present / used later):
- `last_tenant_id` (nullable FK to `tenants.id`) to optimize redirect for multi-membership users.
No new tables required.
---
## Routes / UI Surfaces
- `/admin/login` — Filament login override (Entra-only CTA)
- `/auth/entra/redirect` — starts OIDC redirect (Socialite)
- `/auth/entra/callback` — handles callback, upsert, routing
- `/admin/no-access` — Filament page for 0-membership users
- `/admin/choose-tenant` — Filament page for N-membership users to select a tenant
---
## OIDC Handling & Failure Semantics
### Claims requirements
- `tid` (tenant id) MUST be present.
- `oid` (object id) MUST be present.
If missing → fail safely.
### Generic user-facing error
- “Authentication failed. Please try again.”
### Server-side logging
Log event `auth.entra.login` with:
- `success`: boolean
- `reason_code`: string (on failure)
- `user_id`: (on success)
- `entra_tenant_id`: tid (plaintext, for Ops correlation)
- `entra_object_id_hash`: hash(oid)
- `correlation_id`: request id or session id
- `timestamp`
### Reason code examples (stable)
- `oidc_missing_claims`
- `oidc_invalid_state`
- `oidc_user_denied`
- `oidc_provider_unavailable`
- `oidc_user_upsert_failed`
- `user_disabled`
---
## Implementation Guardrails (hard)
- Do not implement a password login form for `/admin`.
- Do not call `$this->form->fill()` with default creds.
- Do not show break-glass link/button on `/admin/login`.
- Do not modify `/system` panel, platform guards, or break-glass logic.
- Do not refactor `User::tenants()` or membership schema; use a small resolver/service to decide redirect.
- Do not make outbound HTTP during render/hydration of /admin/login, /admin/no-access, or /admin/choose-tenant. Outbound HTTP is permitted only inside /auth/entra/* endpoints for the OIDC exchange.
- Do not store raw claims or tokens.
---
## Acceptance Tests (required)
### Feature tests (Pest)
1. **AdminLoginIsEntraOnlyTest**
- GET `/admin/login` contains Microsoft CTA
- asserts no `password` input / no local login form
2. **EntraCallbackUpsertByTidOidTest**
- callback upserts user by `(tid, oid)` (unique)
- session is regenerated
3. **PostLoginRoutingByMembershipTest**
- 0 memberships → `/admin/no-access`
- 1 membership → tenant dashboard
- N memberships → chooser page
4. **OidcFailureRedirectsSafelyTest**
- missing tid/oid → redirect `/admin/login`
- logs contain `reason_code` + `correlation_id`
- logs do not contain tokens/claims dumps
5. **SessionSeparationSmokeTest**
- tenant session cannot access `/system`
- platform session cannot access tenant membership routes without membership
6. **DisabledUserLoginIsBlockedTest**
- Seed a disabled/soft-deleted user
- Fake a successful OIDC callback for that user
- Assert redirect to /admin/login
- Assert log contains `reason_code: user_disabled`
### Quality gate
- `./vendor/bin/sail bin pint --dirty`
- `./vendor/bin/sail artisan test tests/Feature/Auth --stop-on-failure`
---
## Manual Verification Checklist
1. Open `/admin/login` - only Microsoft sign-in CTA visible
2. Complete Entra sign-in - user record exists with tid/oid
3. 0 memberships → `/admin/no-access`
4. 1 membership → tenant dashboard
5. >1 memberships → chooser page
6. Verify logs:
- failures show reason_code + correlation_id
- no tokens/claims in logs
---
## Out of Scope / Follow-ups
- **064-auth-structure**: `/system` operator login hardening, break-glass UX, panel separation governance.
- **062-tenant-rbac-v1**: role enforcement audit + resource-by-resource authorization hardening.
- Advanced Entra topics (v2+): delegated flows, refresh token storage, certificate auth, conditional access UI.

View File

@ -0,0 +1,96 @@
---
description: "Task list for feature 063 — Entra Sign-in (Tenant Panel) v1"
---
# Tasks: 063 — Entra Sign-in (Tenant Panel) v1
**Input**: Design documents from `/specs/063-entra-signin/`
**Prerequisites**: `plan.md`, `spec.md`
**Tests**: REQUIRED (Pest)
**Notes / Guardrails**
- 063 covers **/admin** Entra sign-in only.
- No `/system` changes and **no break-glass UX** on `/admin/login`.
- Login is synchronous and must not require a queue worker.
- DB-only render/hydration scope: `/admin/login`, `/admin/no-access`, `/admin/choose-tenant`.
- Outbound HTTP is allowed only for the OIDC exchange on `/auth/entra/*` endpoints.
---
## Phase 1: Setup (Shared Infrastructure)
- [x] T001 Configure Microsoft (Entra) OIDC provider in `config/services.php` under key `microsoft` using env vars `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`, `ENTRA_REDIRECT_URI`, and optional `ENTRA_AUTHORITY_TENANT` (default `organizations`).
- [x] T002 Add `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`, `ENTRA_REDIRECT_URI`, and `ENTRA_AUTHORITY_TENANT` to `.env.example` (no break-glass vars in 063).
---
## Phase 2: Foundational (Blocking Prerequisites)
- [x] T003 Verify `users` has `entra_tenant_id` and `entra_object_id` (prefer existing types; if missing, add nullable `string(255)` columns) + a unique index on (entra_tenant_id, entra_object_id). Keep columns nullable in v1; enforce non-null only after explicit backfill/migration.
- [x] T004 Apply migrations via Sail: `./vendor/bin/sail artisan migrate`.
- [x] T005 Create `app/Http/Controllers/Auth/EntraController.php` to handle `/auth/entra/redirect` and `/auth/entra/callback` only (NoAccess and Chooser are Filament pages).
- [x] T006 Add routes for `/auth/entra/redirect` and `/auth/entra/callback` in `routes/web.php` with route names `auth.entra.redirect` and `auth.entra.callback`.
- [x] T007 Define a dedicated rate limiter for `auth.entra.callback` in `app/Providers/AppServiceProvider.php` using `RateLimiter::for('entra-callback', ...)` and apply `->middleware('throttle:entra-callback')` to the callback route; add a small feature test asserting 429 after excessive hits.
- [x] T008 Ensure `app/Models/User.php` allows writing `entra_tenant_id` and `entra_object_id` (fillable/guarded), without refactoring membership relationships.
---
## Phase 3: US1 — Entra-only login UI on `/admin/login` (P1)
**Goal**: A tenant user can only start Microsoft sign-in from `/admin/login`.
- [x] T009 [US1] Override the Filament login page for the `/admin` panel to show only a "Sign in with Microsoft" action linking to `route('auth.entra.redirect')` (no email/password inputs).
- [x] T010 [US1] Create feature test `tests/Feature/Auth/AdminLoginIsEntraOnlyTest.php` verifying the Microsoft button exists and password inputs do not.
---
## Phase 4: US2 — OIDC callback upserts tenant identity safely (P1)
**Goal**: The callback upserts a tenant user using Entra claims.
- [x] T011 [US2] Implement `EntraController@callback` upsert keyed by `(entra_tenant_id=tid, entra_object_id=oid)`; regenerate session on success; never store tokens.
- [x] T012 [US2] Block login for disabled/soft-deleted users (Option B): redirect to `/admin/login` with generic error; log `reason_code=user_disabled`.
- [x] T013 [US2] Handle missing/invalid `tid` or `oid` (or invalid state) by redirecting back to `/admin/login` with a generic error + reason_code log (no claims/tokens dumped).
- [x] T014 [US2] Implement privacy-safe logging for login successes and failures (minimal identity; include `correlation_id`; never dump raw claims/tokens).
- [x] T015 [US2] Create feature test `tests/Feature/Auth/EntraCallbackUpsertByTidOidTest.php` to test upsert and session regeneration.
- [x] T016 [US2] Create feature test `tests/Feature/Auth/DisabledUserLoginIsBlockedTest.php`.
- [x] T017 [US2] Create feature test `tests/Feature/Auth/OidcFailureRedirectsSafelyTest.php`.
---
## Phase 5: US3 — Post-login routing is membership-based (P1)
**Goal**: After login, routing depends on Suite tenant memberships.
- [x] T018 [P] [US3] Create `app/Services/Auth/PostLoginRedirectResolver.php` that resolves redirect targets for 0/1/>1 memberships (0 → `/admin/no-access`, 1 → `TenantDashboard::getUrl(tenant: $tenant)`, >1 → `/admin/choose-tenant`). IMPORTANT: do not hardcode `/admin/t/...`; always use Filament page URL helpers so routing stays stable if prefixes/slugs change.
- [x] T019 [US3] In `EntraController@callback`, call `PostLoginRedirectResolver` after upsert to determine redirect.
- [x] T020 [US3] Create a Filament page `app/Filament/Pages/ChooseTenant.php` mounted under the `/admin` panel at `/admin/choose-tenant`.
- [x] T021 [US3] Implement tenant selection action on the chooser page: selecting a tenant sets Filament tenant context (session) and redirects to `App\\Filament\\Pages\\TenantDashboard::getUrl(tenant: $tenant)`; persist `users.last_tenant_id` if present. IMPORTANT: use Filament URL helpers (no hardcoded paths).
- [x] T022 [US3] Create feature test `tests/Feature/Auth/PostLoginRoutingByMembershipTest.php` to validate 0/1/>1 routing.
- [x] T023 [US3] Create feature test `tests/Feature/Auth/TenantChooserSelectionTest.php` ensuring chooser selection sets tenant context and redirects (and stores last_tenant_id if present).
---
## Phase 6: US4 — Filament-native “No access” page (P2)
- [x] T024 [US4] Create a Filament page `app/Filament/Pages/NoAccess.php` under the `/admin` panel with route `/admin/no-access`.
- [x] T025 [US4] Add feature test `tests/Feature/Auth/NoAccessPageRendersTest.php`.
---
## Phase 7: Cross-cutting (Polish & Guards)
- [x] T026 Create `tests/Feature/Auth/SessionSeparationSmokeTest.php` to ensure tenant session cannot access `/system`; platform session cannot access `/admin` tenant routes.
- [x] T027 Run formatting: `./vendor/bin/sail bin pint --dirty`.
- [x] T028 Run feature tests: `./vendor/bin/sail artisan test tests/Feature/Auth --stop-on-failure`.
- [ ] T029 Manual verification: run through `/admin/login` → OIDC → 0/1/>1 membership outcomes, chooser selection, and confirm no break-glass UI appears on `/admin/login`.
- [x] T030 Add `tests/Feature/Auth/DbOnlyPagesDoNotMakeHttpRequestsTest.php` to enforce DB-only render/hydration for `/admin/login`, `/admin/no-access`, and `/admin/choose-tenant` using `Http::preventStrayRequests()` + render each page + assert no exceptions; optional hardening: `Queue::fake()` + `Bus::fake()` (and/or `Event::fake()`) so render paths cant silently dispatch.
---
## Dependencies & Execution Order
- Phase 12 must complete before US1US4.
- Implement in order: US1 → US2 → US3 → US4.
- Phase 7 is last.

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
it('shows only Entra sign-in on /admin/login', function () {
$response = $this->get('/admin/login');
$response->assertSuccessful();
$response->assertSee('Sign in with Microsoft');
$response->assertSee(route('auth.entra.redirect'), false);
$response->assertDontSee('type="password"', false);
$response->assertDontSee('name="password"', false);
});

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('does not make outbound HTTP requests when rendering db-only auth pages', function () {
Http::preventStrayRequests();
$this->get('/admin/login')->assertOk();
$user = User::factory()->create();
$this->actingAs($user);
$this->get('/admin/no-access')->assertOk();
$this->get('/admin/choose-tenant')->assertOk();
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
if (! function_exists('entra_build_jwt')) {
function entra_build_jwt(array $claims): string
{
$encode = static fn (array $data): string => rtrim(
strtr(base64_encode(json_encode($data, JSON_UNESCAPED_SLASHES) ?: ''), '+/', '-_'),
'='
);
return $encode(['alg' => 'none', 'typ' => 'JWT']).'.'.$encode($claims).'.';
}
}
it('blocks login for soft-deleted users', function () {
config()->set('services.microsoft.client_id', 'test-client');
config()->set('services.microsoft.client_secret', 'test-secret');
config()->set('services.microsoft.redirect', 'http://localhost/auth/entra/callback');
config()->set('services.microsoft.tenant', 'organizations');
$user = User::factory()->create([
'entra_tenant_id' => 'tenant-1',
'entra_object_id' => 'object-1',
'email' => 'user@example.com',
'name' => 'Disabled User',
]);
$user->delete();
$state = 'state-123';
Http::fake([
'https://login.microsoftonline.com/*/oauth2/v2.0/token' => Http::response([
'id_token' => entra_build_jwt([
'tid' => 'tenant-1',
'oid' => 'object-1',
'preferred_username' => 'user@example.com',
'name' => 'Disabled User',
]),
]),
]);
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/login');
$response->assertSessionHas('error');
expect(User::withTrashed()->count())->toBe(1);
expect(User::withTrashed()->first()?->trashed())->toBeTrue();
});

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
it('rate limits the Entra callback route', function () {
$ip = '192.0.2.1';
$lastResponse = null;
for ($i = 0; $i < 21; $i++) {
$lastResponse = $this
->withServerVariables(['REMOTE_ADDR' => $ip])
->get(route('auth.entra.callback'));
}
expect($lastResponse)->not->toBeNull();
$lastResponse->assertTooManyRequests();
});

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
if (! function_exists('entra_build_jwt')) {
function entra_build_jwt(array $claims): string
{
$encode = static fn (array $data): string => rtrim(
strtr(base64_encode(json_encode($data, JSON_UNESCAPED_SLASHES) ?: ''), '+/', '-_'),
'='
);
return $encode(['alg' => 'none', 'typ' => 'JWT']).'.'.$encode($claims).'.';
}
}
it('upserts user by tid+oid and regenerates the session on success', function () {
config()->set('services.microsoft.client_id', 'test-client');
config()->set('services.microsoft.client_secret', 'test-secret');
config()->set('services.microsoft.redirect', 'http://localhost/auth/entra/callback');
config()->set('services.microsoft.tenant', 'organizations');
$state = 'state-123';
$tokenBefore = 'token-before';
Http::fake([
'https://login.microsoftonline.com/*/oauth2/v2.0/token' => Http::response([
'id_token' => entra_build_jwt([
'tid' => 'tenant-1',
'oid' => 'object-1',
'preferred_username' => 'user@example.com',
'name' => 'Test User',
]),
]),
]);
$response = $this
->withSession(['entra_state' => $state, '_token' => $tokenBefore])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/no-access');
$user = User::query()
->where('entra_tenant_id', 'tenant-1')
->where('entra_object_id', 'object-1')
->firstOrFail();
expect($user->email)->toBe('user@example.com');
expect($user->name)->toBe('Test User');
expect(session('entra_state'))->toBeNull();
expect(session()->token())->not->toBe($tokenBefore);
});

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('renders the no access page for authenticated users', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get('/admin/no-access');
$response->assertOk();
$response->assertSee('You dont have access to any tenants yet.');
});

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
if (! function_exists('entra_build_jwt')) {
function entra_build_jwt(array $claims): string
{
$encode = static fn (array $data): string => rtrim(
strtr(base64_encode(json_encode($data, JSON_UNESCAPED_SLASHES) ?: ''), '+/', '-_'),
'='
);
return $encode(['alg' => 'none', 'typ' => 'JWT']).'.'.$encode($claims).'.';
}
}
it('redirects back to /admin/login if state is invalid without making HTTP requests', function () {
$expectedState = 'expected-state';
Http::preventStrayRequests();
$response = $this
->withSession(['entra_state' => $expectedState])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => 'wrong-state']));
$response->assertRedirect('/admin/login');
$response->assertSessionHas('error');
});
it('redirects back to /admin/login if the token exchange fails', function () {
config()->set('services.microsoft.client_id', 'test-client');
config()->set('services.microsoft.client_secret', 'test-secret');
config()->set('services.microsoft.redirect', 'http://localhost/auth/entra/callback');
config()->set('services.microsoft.tenant', 'organizations');
$state = 'state-123';
Http::fake([
'https://login.microsoftonline.com/*/oauth2/v2.0/token' => Http::response([], 500),
]);
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/login');
$response->assertSessionHas('error');
});
it('redirects back to /admin/login if tid/oid claims are missing', function () {
config()->set('services.microsoft.client_id', 'test-client');
config()->set('services.microsoft.client_secret', 'test-secret');
config()->set('services.microsoft.redirect', 'http://localhost/auth/entra/callback');
config()->set('services.microsoft.tenant', 'organizations');
$state = 'state-123';
Http::fake([
'https://login.microsoftonline.com/*/oauth2/v2.0/token' => Http::response([
'id_token' => entra_build_jwt([
'tid' => 'tenant-1',
// oid intentionally missing
'preferred_username' => 'user@example.com',
]),
]),
]);
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/login');
$response->assertSessionHas('error');
});

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
if (! function_exists('entra_build_jwt')) {
function entra_build_jwt(array $claims): string
{
$encode = static fn (array $data): string => rtrim(
strtr(base64_encode(json_encode($data, JSON_UNESCAPED_SLASHES) ?: ''), '+/', '-_'),
'='
);
return $encode(['alg' => 'none', 'typ' => 'JWT']).'.'.$encode($claims).'.';
}
}
function entra_configure_services(): void
{
config()->set('services.microsoft.client_id', 'test-client');
config()->set('services.microsoft.client_secret', 'test-secret');
config()->set('services.microsoft.redirect', 'http://localhost/auth/entra/callback');
config()->set('services.microsoft.tenant', 'organizations');
}
function entra_fake_token_exchange(string $tid, string $oid): void
{
Http::fake([
'https://login.microsoftonline.com/*/oauth2/v2.0/token' => Http::response([
'id_token' => entra_build_jwt([
'tid' => $tid,
'oid' => $oid,
'preferred_username' => 'user@example.com',
'name' => 'Test User',
]),
]),
]);
}
it('routes to no-access when user has no tenant memberships', function () {
entra_configure_services();
entra_fake_token_exchange('tenant-1', 'object-1');
User::factory()->create([
'entra_tenant_id' => 'tenant-1',
'entra_object_id' => 'object-1',
]);
$state = 'state-123';
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/no-access');
});
it('routes to tenant dashboard when user has exactly one tenant membership', function () {
entra_configure_services();
entra_fake_token_exchange('tenant-1', 'object-1');
$user = User::factory()->create([
'entra_tenant_id' => 'tenant-1',
'entra_object_id' => 'object-1',
]);
$tenant = Tenant::factory()->create(['status' => 'active']);
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$state = 'state-123';
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
});
it('routes to choose-tenant when user has multiple tenant memberships', function () {
entra_configure_services();
entra_fake_token_exchange('tenant-1', 'object-1');
$user = User::factory()->create([
'entra_tenant_id' => 'tenant-1',
'entra_object_id' => 'object-1',
]);
$tenants = Tenant::factory()->count(2)->create(['status' => 'active']);
foreach ($tenants as $tenant) {
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
}
$state = 'state-123';
$response = $this
->withSession(['entra_state' => $state])
->get(route('auth.entra.callback', ['code' => 'code-123', 'state' => $state]));
$response->assertRedirect('/admin/choose-tenant');
});

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('does not allow a non-member user to access tenant-scoped admin routes', function () {
$tenant = Tenant::factory()->create(['status' => 'active']);
$member = User::factory()->create();
$nonMember = User::factory()->create();
TenantMembership::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $member->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
$this->actingAs($nonMember);
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertNotFound();
$this->actingAs($member);
$this->get(TenantDashboard::getUrl(tenant: $tenant))->assertSuccessful();
$this->get('/system')->assertNotFound();
});

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('redirects to the chosen tenant dashboard and persists the last-used tenant', function () {
$user = User::factory()->create();
$this->actingAs($user);
$tenant = Tenant::factory()->create(['status' => 'active']);
$otherTenant = Tenant::factory()->create(['status' => 'active']);
foreach ([$tenant, $otherTenant] as $memberTenant) {
TenantMembership::query()->create([
'tenant_id' => $memberTenant->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
}
Livewire::test(ChooseTenant::class)
->call('selectTenant', (int) $tenant->getKey())
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
$user->refresh();
if (Schema::hasColumn('users', 'last_tenant_id')) {
expect($user->last_tenant_id)->toBe($tenant->getKey());
return;
}
if (Schema::hasTable('user_tenant_preferences')) {
$preference = $user->tenantPreferences()
->where('tenant_id', $tenant->getKey())
->first();
expect($preference)->not->toBeNull();
expect($preference?->last_used_at)->not->toBeNull();
}
});