Skip to content

Commit def553a

Browse files
Add identity strategy system with tenant isolation architecture
Introduce dual identity strategies (shared/isolated) backed by entity-agnostic contracts. In shared mode, users are global and teams serve as collaboration boundaries. In isolated mode, users are scoped to tenants, which are resolved before authentication. New models: Tenant, TenantAuthSettings New services: ContextManager, MembershipService, IdentityProviderService New contracts: ContextContainerInterface, ResolvableContextInterface, IdentityProviderOwnerInterface, HasMembersInterface New middleware: BindContextMiddleware, EnsureContextSSO, ResolveTeamMiddleware New traits: BelongsToTeam, TeamScope Rewrite documentation as organized hub with architecture guides, fix stale references, and add cross-links throughout all docs. 996 tests pass, PHPStan clean, Pint clean.
1 parent a4c030e commit def553a

67 files changed

Lines changed: 4968 additions & 422 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.0] - 2025-02-20
11+
12+
### Added
13+
- **Identity strategy system** — choose between `shared` (users global, teams as collaboration) and `isolated` (users scoped to tenant, tenant resolved before auth) via `config('neev.identity_strategy')`
14+
- **Tenant model** (`Ssntpl\Neev\Models\Tenant`) — dedicated identity boundary for isolated mode with slug-based resolution, managed-by hierarchy, and SSO ownership
15+
- **TenantAuthSettings model** — per-tenant SSO configuration (mirrors TeamAuthSettings for isolated mode)
16+
- **ContextManager service** — request-scoped singleton holding resolved tenant, team, and user; immutable after binding
17+
- **Entity-agnostic contracts**`ContextContainerInterface`, `ResolvableContextInterface`, `IdentityProviderOwnerInterface`, `HasMembersInterface` — same service code works for both Team and Tenant
18+
- **MembershipService** — entity-agnostic membership checks via `HasMembersInterface`
19+
- **IdentityProviderService** — entity-agnostic SSO/auth queries via `IdentityProviderOwnerInterface`
20+
- **BindContextMiddleware** — locks ContextManager after middleware pipeline, clears after response
21+
- **EnsureContextSSO middleware** (`neev:ensure-sso`) — enforces SSO-only access for teams/tenants with SSO configured
22+
- **ResolveTeamMiddleware** (`neev:resolve-team`) — resolves team from route parameter (numeric ID or slug)
23+
- **TeamScope** global scope and `BelongsToTeam` trait — team-level model scoping (complements existing `BelongsToTenant`)
24+
- **`tenants` migration**`id`, `name`, `slug` (unique), `managed_by_tenant_id` (self-reference for reseller model)
25+
- **`tenant_auth_settings` migration** — SSO config for tenants in isolated mode
26+
- **TenantFactory and TenantAuthSettingsFactory** for testing
27+
- **Install wizard stubs** — published migration stubs for hard user isolation (`tenant_id` on users table)
28+
- **Architecture documentation**`docs/architecture.md` (identity strategy, tenant vs team concepts, context lifecycle) and `docs/architecture-internals.md` (interfaces, patterns, coding standards)
29+
- Comprehensive test suite — 996 tests, 1886 assertions
30+
31+
### Changed
32+
- **TenantResolver** now resolves `Tenant` in isolated mode and `Team` in shared mode via the `ContextContainerInterface` abstraction. Backward-compatible `current()` method still returns Team
33+
- **TenantResolver** populates `ContextManager` automatically on resolution
34+
- **Middleware pipeline** reordered: TenantMiddleware → ResolveTeamMiddleware → Auth → EnsureTenantMembership → BindContextMiddleware
35+
- **TenantMiddleware** now uses `TenantResolver::resolve()` which handles X-Tenant header, subdomain, and custom domain resolution in a unified flow
36+
- **EnsureTenantMembership** updated to work with `ContextManager` and `MembershipService`
37+
- **TenantSSOManager** refactored to accept `IdentityProviderOwnerInterface` — works with both Team and Tenant
38+
- **TenantSSOController** updated to use `ContextManager` for tenant resolution and `IdentityProviderOwnerInterface` for SSO
39+
- **Team model** now implements `ContextContainerInterface`, `ResolvableContextInterface`, `IdentityProviderOwnerInterface`, `HasMembersInterface`
40+
- **Domain model** gains `tenant_id` foreign key and `tenant()` relationship for isolated mode
41+
- **AccessToken model** gains tracking fields (`last_used_ip`, `user_agent`, etc.)
42+
- **BelongsToTenant trait** updated to be identity-strategy-aware — resolves to `teams` or `tenants` table depending on config
43+
- **NeevServiceProvider** registers `ContextManager` as scoped singleton, registers new middleware aliases
44+
- **TenantScope** updated to read from `ContextManager` instead of `TenantResolver` directly
45+
- Minimum PHP version bumped to 8.3
46+
- Dropped Laravel 10 support (Laravel 11.x and 12.x only)
47+
- CI workflows updated for PHP 8.3
48+
49+
### Fixed
50+
- Fixed copy-paste error in API reference docs — "Send Verification Email" response now correctly says "Verification email has been sent"
51+
- Fixed duplicated "Customizing Routes" section in web routes documentation
52+
53+
### Documentation
54+
- **docs/README.md** rewritten as organized documentation hub (Guides / Reference / Architecture)
55+
- **docs/multi-tenancy.md** — added Identity Strategy section, ContextManager section, fixed TenantResolver method signatures, added BelongsToTeam docs, updated database schema
56+
- **docs/configuration.md** — added `identity_strategy` and `tenant_model` options
57+
- **docs/installation.md** — added multi-tenancy and architecture links
58+
- **README.md** — added `identity_strategy` to feature toggles, middleware aliases table, updated database schema, added architecture docs link, added Contributing/Security links
59+
- **CLAUDE.md** — updated with all new directories, models, middleware, services, traits, and patterns
60+
- **TODO.md** — updated stale items (tests, PHPStan, Pint, CI now marked complete)
61+
- Cross-links added between all documentation files
62+
63+
## [0.1.2] - 2025-02-11
64+
65+
### Added
66+
- Comprehensive test suite with 60%+ line coverage
67+
- Codecov coverage integration
68+
- CI badges (code style, static analysis, tests, coverage)
69+
70+
### Changed
71+
- Minimum PHP version bumped to 8.3
72+
- Dropped Laravel 10 support
73+
- CI workflows updated for PHP 8.3
74+
75+
## [0.1.1] - 2025-12-16
76+
77+
### Added
78+
- Code quality tooling (PHPStan level 5, Pint PSR-12)
79+
- GitHub Actions CI workflows (tests, static analysis, code style)
80+
- Community files (CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, CHANGELOG.md)
81+
- Database factories
82+
83+
### Changed
84+
- Simplified and refined codebase for clarity and consistency
85+
- Hash OTPs at rest, fix model casts, clean up token handling
86+
87+
### Fixed
88+
- Security vulnerabilities and bugs for public release
89+
- MFA setup bug fix
90+
- Recovery codes stored as hashed values
91+
92+
## [0.1.0] - 2025-11-26
93+
1094
### Added
1195
- Initial public release
1296
- Password-based authentication with strong validation
@@ -28,3 +112,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28112
- API token authentication with permissions
29113
- Comprehensive Blade views and email templates
30114
- Artisan commands for installation, GeoIP download, and cleanup
115+
116+
[Unreleased]: https://github.com/ssntpl/neev/compare/v0.2.0...HEAD
117+
[0.2.0]: https://github.com/ssntpl/neev/compare/v0.1.2...v0.2.0
118+
[0.1.2]: https://github.com/ssntpl/neev/compare/v0.1.1...v0.1.2
119+
[0.1.1]: https://github.com/ssntpl/neev/compare/v0.1.0...v0.1.1
120+
[0.1.0]: https://github.com/ssntpl/neev/releases/tag/v0.1.0

README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -528,11 +528,18 @@ Response:
528528

529529
| Middleware | Description |
530530
|------------|-------------|
531-
| `neev:web` | Web authentication with MFA |
532-
| `neev:api` | API token authentication |
533-
| `neev:tenant` | Tenant resolution from domain |
534-
| `neev:tenant-web` | Tenant + web auth |
535-
| `neev:tenant-api` | Tenant + API auth |
531+
| `neev:web` | Web authentication (includes tenant resolution when enabled) |
532+
| `neev:api` | API authentication (includes tenant resolution when enabled) |
533+
| `neev:tenant` | Tenant resolution from domain (no auth) |
534+
535+
### Middleware Aliases
536+
537+
| Alias | Description |
538+
|-------|-------------|
539+
| `neev:active-team` | Blocks access when team is inactive/waitlisted |
540+
| `neev:tenant-member` | Ensures user is a member of the current tenant |
541+
| `neev:resolve-team` | Resolves team from route parameter |
542+
| `neev:ensure-sso` | Enforces SSO-only access for the current context |
536543

537544
---
538545

@@ -594,6 +601,7 @@ php artisan neev:download-geoip
594601
'email_verified' => true, // Require verification
595602
'require_company_email' => false, // Waitlist free emails
596603
'domain_federation' => true, // Domain-based joining
604+
'identity_strategy' => 'shared', // 'shared' or 'isolated'
597605
'tenant_isolation' => false, // Multi-tenancy
598606
'tenant_auth' => false, // Per-tenant auth
599607
'support_username' => false, // Username login
@@ -695,8 +703,10 @@ protected function schedule(Schedule $schedule)
695703

696704
| Table | Description |
697705
|-------|-------------|
706+
| `tenants` | Tenant organizations (isolated identity mode) |
698707
| `domains` | Custom tenant domains and domain federation |
699-
| `team_auth_settings` | Per-tenant auth config |
708+
| `team_auth_settings` | Per-team auth/SSO config |
709+
| `tenant_auth_settings` | Per-tenant auth/SSO config (isolated mode) |
700710

701711
---
702712

@@ -715,6 +725,8 @@ For comprehensive documentation, see the [docs folder](./docs/):
715725
| [Teams](./docs/teams.md) | Team management guide |
716726
| [Multi-Tenancy](./docs/multi-tenancy.md) | SaaS multi-tenant setup |
717727
| [Security](./docs/security.md) | Security features & best practices |
728+
| [Architecture](./docs/architecture.md) | Identity strategy, tenancy & team design |
729+
| [Architecture Internals](./docs/architecture-internals.md) | Interfaces, patterns & coding standards |
718730

719731
---
720732

@@ -726,6 +738,14 @@ For comprehensive documentation, see the [docs folder](./docs/):
726738

727739
---
728740

741+
## Contributing
742+
743+
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, coding standards, and how to submit pull requests.
744+
745+
Report security vulnerabilities following the process in [SECURITY.md](SECURITY.md).
746+
747+
---
748+
729749
## License
730750

731751
MIT License. See [LICENSE](LICENSE) for details.

config/neev.php

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,24 @@
7474
*/
7575
'domain_federation' => false,
7676

77+
/*
78+
| Identity Strategy
79+
| -----------------
80+
| 'shared' — Users are global. Same user may belong to multiple teams.
81+
| Tenant concept is hidden. (Default, backward compatible)
82+
| 'isolated' — Users are scoped inside a tenant. Same email may exist
83+
| in multiple tenants. Tenant resolved before authentication.
84+
*/
85+
'identity_strategy' => 'shared',
86+
7787
/*
7888
| Tenant Isolation (Multi-Tenancy)
7989
| ---------------------------------
8090
| When enabled: Teams are isolated by domain/subdomain. Each tenant runs on
8191
| their own domain (tenant1.yourapp.com) or custom domain (tenant1.com).
8292
| Requires: 'team' feature to be enabled
8393
| Database: Requires domains table
84-
| Middleware: Adds 'neev:tenant' and 'neev:tenant-api' middleware groups
94+
| Middleware: Activates tenant resolution in 'neev:web' and 'neev:api' middleware groups
8595
| Use case: SaaS applications with isolated tenants
8696
| Default: false (no domain-based tenant isolation)
8797
*/
@@ -161,19 +171,19 @@
161171
| Tenant-Driven Authentication
162172
|--------------------------------------------------------------------------
163173
|
164-
| When enabled, each tenant can configure their own authentication method.
165-
| Tenants choose between password authentication (default) or SSO via
174+
| When enabled, each tenant or team can configure their own authentication method.
175+
| They choose between password authentication (default) or SSO via
166176
| external identity providers (Microsoft Entra ID, Google, Okta, etc.).
167-
| Requires: 'tenant_isolation' feature to be enabled.
177+
| In shared mode, teams own SSO config. In isolated mode, tenants own SSO config.
168178
|
169179
*/
170180

171181
/*
172182
| Enable Tenant-Driven Authentication
173183
| ------------------------------------
174-
| When true: Tenants can configure their authentication method (password or SSO).
184+
| When true: Tenants/teams can configure their authentication method (password or SSO).
175185
| The auth method is checked on login and users are routed accordingly.
176-
| Requires: 'tenant_isolation' = true
186+
| Works in both shared mode (team-level SSO) and isolated mode (tenant-level SSO).
177187
| Default: false
178188
*/
179189
'tenant_auth' => false,
@@ -263,6 +273,17 @@
263273
|
264274
*/
265275

276+
/*
277+
| Tenant Model Class
278+
| ------------------
279+
| The Eloquent model used for tenant management in isolated identity mode.
280+
| Custom model requirements:
281+
| - Must extend Ssntpl\Neev\Models\Tenant or implement same interfaces
282+
| - Must implement: ContextContainerInterface, IdentityProviderOwnerInterface, HasMembersInterface
283+
| Example: App\Models\Tenant::class
284+
*/
285+
'tenant_model' => Ssntpl\Neev\Models\Tenant::class,
286+
266287
/*
267288
| Team Model Class
268289
| ----------------
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Ssntpl\Neev\Database\Factories;
4+
5+
use Illuminate\Database\Eloquent\Factories\Factory;
6+
use Ssntpl\Neev\Models\TenantAuthSettings;
7+
8+
class TenantAuthSettingsFactory extends Factory
9+
{
10+
protected $model = TenantAuthSettings::class;
11+
12+
public function definition(): array
13+
{
14+
return [
15+
'tenant_id' => TenantFactory::new(),
16+
'auth_method' => 'password',
17+
];
18+
}
19+
20+
public function sso(string $provider = 'entra'): static
21+
{
22+
return $this->state([
23+
'auth_method' => 'sso',
24+
'sso_provider' => $provider,
25+
'sso_client_id' => 'test-client-id',
26+
'sso_client_secret' => 'test-client-secret',
27+
'sso_tenant_id' => 'test-tenant-id',
28+
]);
29+
}
30+
31+
public function autoProvision(string $role = 'member'): static
32+
{
33+
return $this->state([
34+
'auto_provision' => true,
35+
'auto_provision_role' => $role,
36+
]);
37+
}
38+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Ssntpl\Neev\Database\Factories;
4+
5+
use Illuminate\Database\Eloquent\Factories\Factory;
6+
use Ssntpl\Neev\Models\Tenant;
7+
8+
class TenantFactory extends Factory
9+
{
10+
protected $model = Tenant::class;
11+
12+
public function definition(): array
13+
{
14+
return [
15+
'name' => fake()->company(),
16+
'slug' => fake()->unique()->slug(2),
17+
];
18+
}
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class () extends Migration {
8+
/**
9+
* Run the migrations.
10+
*/
11+
public function up(): void
12+
{
13+
Schema::create('tenants', function (Blueprint $table) {
14+
$table->id();
15+
$table->string('name');
16+
$table->string('slug')->unique();
17+
$table->foreignId('managed_by_tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
18+
$table->timestamps();
19+
});
20+
}
21+
22+
/**
23+
* Reverse the migrations.
24+
*/
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('tenants');
28+
}
29+
};

database/migrations/2025_01_01_000006_create_access_tokens_table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public function up(): void
1313
Schema::create('access_tokens', function (Blueprint $table) {
1414
$table->id();
1515
$table->foreignId('user_id')->constrained()->onDelete('cascade');
16+
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
1617
$table->unsignedBigInteger('attempt_id')->nullable();
1718
$table->string('name');
1819
$table->string('token')->unique();

database/migrations/2025_01_01_000007_create_teams_table.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public function up(): void
1212
{
1313
Schema::create('teams', function (Blueprint $table) {
1414
$table->id();
15+
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
1516
$table->foreignId('user_id')->constrained()->onDelete('cascade');
1617
$table->string('name');
1718
$table->string('slug')->unique()->nullable();
@@ -20,13 +21,18 @@ public function up(): void
2021
$table->string('inactive_reason')->nullable();
2122
$table->timestamps();
2223
$table->unique(['name', 'user_id']);
24+
$table->unique(['tenant_id', 'slug']);
2325
$table->index('slug');
2426
});
2527

2628
Schema::table('users', function (Blueprint $table) {
2729
$table->foreignId('current_team_id')->nullable()->constrained('teams')->nullOnDelete();
2830
});
2931

32+
Schema::table('tenants', function (Blueprint $table) {
33+
$table->foreignId('platform_team_id')->nullable()->constrained('teams')->nullOnDelete();
34+
});
35+
3036
Schema::create('team_user', function (Blueprint $table) {
3137
$table->id();
3238
$table->foreignId('team_id')->constrained()->onDelete('cascade');
@@ -48,6 +54,11 @@ public function down(): void
4854
Schema::table('users', function (Blueprint $table) {
4955
$table->dropConstrainedForeignId('current_team_id');
5056
});
57+
if (Schema::hasColumn('tenants', 'platform_team_id')) {
58+
Schema::table('tenants', function (Blueprint $table) {
59+
$table->dropConstrainedForeignId('platform_team_id');
60+
});
61+
}
5162
Schema::dropIfExists('teams');
5263
}
5364
};

database/migrations/2025_01_01_000009_create_domains_table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public function up(): void
1313
Schema::create('domains', function (Blueprint $table) {
1414
$table->id();
1515
$table->foreignId('team_id')->constrained()->onDelete('cascade');
16+
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
1617
$table->boolean('enforce')->default(false);
1718
$table->string('domain')->index();
1819
$table->string('verification_token')->nullable();

0 commit comments

Comments
 (0)