security: encrypt 2FA secrets and hash invitation tokens

- Add encrypted cast to UserTwoFactorAuth secret and recovery_codes
- Hash invitation tokens on creation using Hash::make()
- Update token verification to use Hash::check()
- Add migration commands for existing data:
  - security:encrypt-2fa-secrets
  - security:hash-invitation-tokens
- Add tests for encryption and hashing

Fixes SEC-003, SEC-004 from security audit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 12:20:53 +00:00
parent 8be7516d3a
commit a35cbc9306
15 changed files with 2797 additions and 23 deletions

View file

@ -175,5 +175,9 @@ class Boot extends ServiceProvider
$event->command(Console\Commands\ProcessAccountDeletions::class);
$event->command(Console\Commands\CheckUsageAlerts::class);
$event->command(Console\Commands\ResetBillingCycles::class);
// Security migration commands
$event->command(Console\Commands\EncryptTwoFactorSecrets::class);
$event->command(Console\Commands\HashInvitationTokens::class);
}
}

View file

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Migrate existing plaintext 2FA secrets to encrypted format.
*
* This command should be run once after deploying the encryption changes.
* It safely encrypts existing secrets that are not yet encrypted.
*/
class EncryptTwoFactorSecrets extends Command
{
protected $signature = 'security:encrypt-2fa-secrets
{--dry-run : Preview changes without making them}
{--force : Skip confirmation prompt}';
protected $description = 'Encrypt existing plaintext 2FA secrets at rest';
public function handle(): int
{
$dryRun = $this->option('dry-run');
// Get all 2FA records
$records = DB::table('user_two_factor_auth')
->whereNotNull('secret')
->get();
if ($records->isEmpty()) {
$this->info('No 2FA records found. Nothing to migrate.');
return Command::SUCCESS;
}
$toMigrate = [];
$alreadyEncrypted = 0;
foreach ($records as $record) {
// Check if the secret is already encrypted
// Laravel's encrypted values contain JSON with 'iv', 'value', 'mac' keys
if ($this->isLikelyEncrypted($record->secret)) {
$alreadyEncrypted++;
continue;
}
$toMigrate[] = $record;
}
$this->info("Found {$records->count()} 2FA records total.");
$this->info("Already encrypted: {$alreadyEncrypted}");
$this->info("Need migration: ".count($toMigrate));
if (empty($toMigrate)) {
$this->info('All secrets are already encrypted. Nothing to do.');
return Command::SUCCESS;
}
if ($dryRun) {
$this->warn('[DRY RUN] Would encrypt '.count($toMigrate).' secrets.');
$this->table(
['ID', 'User ID', 'Current Value (truncated)'],
collect($toMigrate)->map(fn ($r) => [
$r->id,
$r->user_id,
substr($r->secret, 0, 16).'...',
])->toArray()
);
return Command::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Do you want to encrypt these secrets? This cannot be undone.')) {
$this->warn('Cancelled.');
return Command::FAILURE;
}
$bar = $this->output->createProgressBar(count($toMigrate));
$bar->start();
$migrated = 0;
$errors = 0;
foreach ($toMigrate as $record) {
try {
// Encrypt the secret and recovery codes
$encryptedSecret = Crypt::encryptString($record->secret);
$updateData = ['secret' => $encryptedSecret];
// Also encrypt recovery codes if they exist and aren't encrypted
if ($record->recovery_codes && ! $this->isLikelyEncrypted($record->recovery_codes)) {
$updateData['recovery_codes'] = Crypt::encryptString($record->recovery_codes);
}
DB::table('user_two_factor_auth')
->where('id', $record->id)
->update($updateData);
$migrated++;
} catch (\Throwable $e) {
$this->newLine();
$this->error("Failed to migrate record {$record->id}: {$e->getMessage()}");
$errors++;
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Migration complete: {$migrated} secrets encrypted, {$errors} errors.");
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* Check if a value appears to already be encrypted.
*
* Laravel's encrypted values are base64-encoded JSON containing 'iv', 'value', 'mac'.
*/
protected function isLikelyEncrypted(string $value): bool
{
// Laravel encrypted values are base64 encoded and typically start with 'eyJ'
// (which is base64 for '{"')
if (! str_starts_with($value, 'eyJ')) {
return false;
}
// Try to decode and check for expected structure
$decoded = base64_decode($value, true);
if ($decoded === false) {
return false;
}
$json = json_decode($decoded, true);
return is_array($json) && isset($json['iv']) && isset($json['value']) && isset($json['mac']);
}
}

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* Migrate existing plaintext invitation tokens to hashed format.
*
* This command should be run once after deploying the token hashing changes.
* It safely hashes existing tokens that are not yet hashed.
*
* IMPORTANT: After running this migration, existing invitation links will
* no longer work because the plaintext tokens are lost. Consider:
* - Running this during a maintenance window
* - Notifying users with pending invitations to request new ones
* - Or only hashing expired/accepted invitations initially
*/
class HashInvitationTokens extends Command
{
protected $signature = 'security:hash-invitation-tokens
{--dry-run : Preview changes without making them}
{--force : Skip confirmation prompt}
{--pending-only : Only hash pending (active) invitations}
{--exclude-pending : Only hash expired/accepted invitations (safer)}';
protected $description = 'Hash existing plaintext invitation tokens';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$pendingOnly = $this->option('pending-only');
$excludePending = $this->option('exclude-pending');
$query = DB::table('workspace_invitations')
->whereNotNull('token');
if ($pendingOnly) {
$query->whereNull('accepted_at')
->where('expires_at', '>', now());
} elseif ($excludePending) {
$query->where(function ($q) {
$q->whereNotNull('accepted_at')
->orWhere('expires_at', '<=', now());
});
}
$records = $query->get();
if ($records->isEmpty()) {
$this->info('No invitation tokens found. Nothing to migrate.');
return Command::SUCCESS;
}
$toMigrate = [];
$alreadyHashed = 0;
foreach ($records as $record) {
// Check if the token is already hashed (bcrypt hashes start with $2y$)
if ($this->isLikelyHashed($record->token)) {
$alreadyHashed++;
continue;
}
$toMigrate[] = $record;
}
$this->info("Found {$records->count()} invitation records in scope.");
$this->info("Already hashed: {$alreadyHashed}");
$this->info("Need migration: ".count($toMigrate));
if (empty($toMigrate)) {
$this->info('All tokens are already hashed. Nothing to do.');
return Command::SUCCESS;
}
// Count pending vs non-pending
$pendingCount = collect($toMigrate)->filter(fn ($r) => $r->accepted_at === null && $r->expires_at > now()->toDateTimeString())->count();
$nonPendingCount = count($toMigrate) - $pendingCount;
$this->newLine();
$this->warn("IMPORTANT: Hashing tokens is a one-way operation!");
$this->warn("- Pending invitations ({$pendingCount}): Links will STOP working");
$this->warn("- Expired/Accepted ({$nonPendingCount}): Safe to hash");
if ($dryRun) {
$this->newLine();
$this->warn('[DRY RUN] Would hash '.count($toMigrate).' tokens.');
$this->table(
['ID', 'Email', 'Status', 'Token (truncated)'],
collect($toMigrate)->map(fn ($r) => [
$r->id,
$r->email,
$this->getStatus($r),
substr($r->token, 0, 16).'...',
])->toArray()
);
return Command::SUCCESS;
}
if (! $this->option('force') && $pendingCount > 0) {
$this->newLine();
if (! $this->confirm("This will invalidate {$pendingCount} active invitation links. Continue?")) {
$this->warn('Cancelled. Consider using --exclude-pending to only hash old invitations.');
return Command::FAILURE;
}
}
$bar = $this->output->createProgressBar(count($toMigrate));
$bar->start();
$migrated = 0;
$errors = 0;
foreach ($toMigrate as $record) {
try {
$hashedToken = Hash::make($record->token);
DB::table('workspace_invitations')
->where('id', $record->id)
->update(['token' => $hashedToken]);
$migrated++;
} catch (\Throwable $e) {
$this->newLine();
$this->error("Failed to migrate record {$record->id}: {$e->getMessage()}");
$errors++;
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Migration complete: {$migrated} tokens hashed, {$errors} errors.");
if ($pendingCount > 0 && $errors === 0) {
$this->warn('Remember: Active invitation links will no longer work. Affected users should request new invitations.');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* Check if a value appears to already be hashed with bcrypt.
*/
protected function isLikelyHashed(string $value): bool
{
// Bcrypt hashes start with $2y$ (or $2a$, $2b$) and are 60 characters
return (bool) preg_match('/^\$2[ayb]\$\d{2}\$/', $value);
}
/**
* Get the status of an invitation for display.
*/
protected function getStatus(object $record): string
{
if ($record->accepted_at !== null) {
return 'Accepted';
}
if ($record->expires_at <= now()->toDateTimeString()) {
return 'Expired';
}
return 'Pending';
}
}

View file

@ -6,6 +6,7 @@ namespace Core\Tenant\Database\Factories;
use Core\Tenant\Models\WorkspaceInvitation;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
@ -15,6 +16,13 @@ class WorkspaceInvitationFactory extends Factory
{
protected $model = WorkspaceInvitation::class;
/**
* The plaintext token for the last created invitation.
*
* Since tokens are hashed, tests may need access to the original plaintext.
*/
public static ?string $lastPlaintextToken = null;
/**
* Define the model's default state.
*
@ -22,9 +30,13 @@ class WorkspaceInvitationFactory extends Factory
*/
public function definition(): array
{
// Store the plaintext token so tests can access it if needed
static::$lastPlaintextToken = Str::random(64);
return [
'email' => fake()->unique()->safeEmail(),
'token' => Str::random(64),
// Token will be hashed by the model's creating event
'token' => static::$lastPlaintextToken,
'role' => 'member',
'invited_by' => null,
'expires_at' => now()->addDays(7),
@ -32,6 +44,20 @@ class WorkspaceInvitationFactory extends Factory
];
}
/**
* Create a factory with a specific plaintext token.
*
* Useful for tests that need to know the token before creation.
*/
public function withPlaintextToken(string $token): static
{
static::$lastPlaintextToken = $token;
return $this->state(fn (array $attributes) => [
'token' => $token,
]);
}
/**
* Indicate the invitation has been accepted.
*/

View file

@ -11,23 +11,52 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* User two-factor authentication record.
*
* Stores TOTP secrets and recovery codes for 2FA.
* Sensitive fields are encrypted at rest using Laravel's encryption.
*
* Note: The database column is 'secret' but the codebase uses 'secret_key'.
* Accessor/mutator methods handle the translation transparently.
*/
class UserTwoFactorAuth extends Model
{
protected $table = 'user_two_factor_auth';
/**
* Fillable attributes.
*
* Note: secret_key is an alias for the 'secret' column, handled by mutator.
*/
protected $fillable = [
'user_id',
'secret_key',
'secret',
'secret_key', // Alias handled by setSecretKeyAttribute
'recovery_codes',
'confirmed_at',
];
protected $casts = [
'recovery_codes' => 'collection',
'secret' => 'encrypted',
'recovery_codes' => 'encrypted:collection',
'confirmed_at' => 'datetime',
];
/**
* Accessor for backward compatibility with code using secret_key.
*/
public function getSecretKeyAttribute(): ?string
{
return $this->secret;
}
/**
* Mutator for backward compatibility with code using secret_key.
*
* Translates secret_key writes to the actual 'secret' column.
*/
public function setSecretKeyAttribute(?string $value): void
{
$this->secret = $value;
}
/**
* Get the user this 2FA belongs to.
*/

View file

@ -660,27 +660,38 @@ class Workspace extends Model
->first();
if ($existing) {
// Update existing invitation
// Update existing invitation (keep existing hashed token)
$existing->update([
'role' => $role,
'invited_by' => $invitedBy?->id,
'expires_at' => now()->addDays($expiresInDays),
]);
// For re-sends, we need to generate a new token since we can't retrieve the old plaintext
$plaintextToken = WorkspaceInvitation::generateToken();
$existing->token = $plaintextToken;
$existing->save();
// Send notification with the new plaintext token
$existing->notify(new \Core\Tenant\Notifications\WorkspaceInvitationNotification($existing, $plaintextToken));
return $existing;
}
// Create new invitation
// Generate plaintext token (will be hashed on save via model boot)
$plaintextToken = WorkspaceInvitation::generateToken();
// Create new invitation (token gets hashed in the creating event)
$invitation = $this->invitations()->create([
'email' => $email,
'token' => WorkspaceInvitation::generateToken(),
'token' => $plaintextToken,
'role' => $role,
'invited_by' => $invitedBy?->id,
'expires_at' => now()->addDays($expiresInDays),
]);
// Send notification
$invitation->notify(new \Core\Tenant\Notifications\WorkspaceInvitationNotification($invitation));
// Send notification with the plaintext token (not the hashed one)
$invitation->notify(new \Core\Tenant\Notifications\WorkspaceInvitationNotification($invitation, $plaintextToken));
return $invitation;
}

View file

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class WorkspaceInvitation extends Model
@ -35,6 +36,22 @@ class WorkspaceInvitation extends Model
'accepted_at' => 'datetime',
];
/**
* The "booted" method of the model.
*
* Automatically hashes tokens when creating invitations.
*/
protected static function booted(): void
{
static::creating(function (WorkspaceInvitation $invitation) {
// Only hash if the token looks like a plaintext token (not already hashed)
// Bcrypt hashes start with $2y$ and are 60 chars
if ($invitation->token && ! str_starts_with($invitation->token, '$2y$')) {
$invitation->token = Hash::make($invitation->token);
}
});
}
/**
* Get the workspace this invitation is for.
*/
@ -103,30 +120,65 @@ class WorkspaceInvitation extends Model
/**
* Generate a unique token for this invitation.
*
* Returns the plaintext token. The token will be hashed when stored.
*/
public static function generateToken(): string
{
do {
$token = Str::random(64);
} while (static::where('token', $token)->exists());
return $token;
// Generate a cryptographically secure random token
// No need to check for uniqueness since hashed tokens are unique
return Str::random(64);
}
/**
* Find invitation by token.
*
* Since tokens are hashed, we must check each pending/valid invitation
* against the provided plaintext token using Hash::check().
*/
public static function findByToken(string $token): ?self
{
return static::where('token', $token)->first();
// Get all invitations and check the hash
// We limit to recent invitations to improve performance
$invitations = static::orderByDesc('created_at')
->limit(1000)
->get();
foreach ($invitations as $invitation) {
if (Hash::check($token, $invitation->token)) {
return $invitation;
}
}
return null;
}
/**
* Find pending invitation by token.
*
* Since tokens are hashed, we must check each pending invitation
* against the provided plaintext token using Hash::check().
*/
public static function findPendingByToken(string $token): ?self
{
return static::where('token', $token)->pending()->first();
// Get pending invitations and check the hash
$invitations = static::pending()->get();
foreach ($invitations as $invitation) {
if (Hash::check($token, $invitation->token)) {
return $invitation;
}
}
return null;
}
/**
* Verify if the given plaintext token matches this invitation's hashed token.
*/
public function verifyToken(string $plaintextToken): bool
{
return Hash::check($plaintextToken, $this->token);
}
/**

View file

@ -14,8 +14,15 @@ class WorkspaceInvitationNotification extends Notification implements ShouldQueu
{
use Queueable;
/**
* Create a new notification instance.
*
* @param WorkspaceInvitation $invitation The invitation model
* @param string $plaintextToken The plaintext token for the URL (tokens are hashed in DB)
*/
public function __construct(
protected WorkspaceInvitation $invitation
protected WorkspaceInvitation $invitation,
protected string $plaintextToken
) {}
/**
@ -33,7 +40,8 @@ class WorkspaceInvitationNotification extends Notification implements ShouldQueu
*/
public function toMail(object $notifiable): MailMessage
{
$acceptUrl = route('workspace.invitation.accept', ['token' => $this->invitation->token]);
// Use the plaintext token for the URL (not the hashed one from the DB)
$acceptUrl = route('workspace.invitation.accept', ['token' => $this->plaintextToken]);
$workspaceName = $this->invitation->workspace->name;
$inviterName = $this->invitation->inviter?->name ?? 'A team member';
$roleName = ucfirst($this->invitation->role);

542
TODO.md Normal file
View file

@ -0,0 +1,542 @@
# core-tenant TODO
Comprehensive task list for improving the multi-tenancy package. Items are prioritised by impact and urgency.
## Legend
- **P1** - Critical/Security (must fix immediately)
- **P2** - High (affects production quality)
- **P3** - Medium (should address soon)
- **P4** - Low (quality of life improvements)
- **P5** - Nice-to-have (future enhancements)
- **P6** - Backlog (ideas for later consideration)
---
## P1 - Critical / Security
### SEC-001: Add rate limiting to EntitlementApiController
**Status:** Open
**File:** `Controllers/EntitlementApiController.php`
The Blesta API endpoints (`store`, `suspend`, `unsuspend`, `cancel`, `renew`) lack rate limiting. A compromised API key could be used to mass-provision or cancel packages.
**Acceptance Criteria:**
- Add rate limiting middleware to all Blesta API routes
- Configure sensible limits (e.g., 60 requests/minute per IP)
- Log rate limit violations for security monitoring
---
### SEC-002: Validate API authentication on EntitlementApiController routes
**Status:** Open
**File:** `Routes/api.php`, `Controllers/EntitlementApiController.php`
The Blesta API controller routes are not visible in `api.php` - they may be registered elsewhere or missing authentication. Verify all Blesta API endpoints require proper API key authentication.
**Acceptance Criteria:**
- Confirm all entitlement API routes require authentication
- Add API key validation middleware if missing
- Document required scopes for each endpoint
---
### SEC-003: Encrypt 2FA secrets at rest
**Status:** Open
**File:** `Concerns/TwoFactorAuthenticatable.php`, `Migrations/0001_01_01_000000_create_tenant_tables.php`
The `user_two_factor_auth.secret` column stores TOTP secrets. While marked as `text`, these should be encrypted at rest using Laravel's `encrypted:string` cast.
**Acceptance Criteria:**
- Add `'secret_key' => 'encrypted'` cast to UserTwoFactorAuth model
- Create migration to encrypt existing secrets
- Verify decryption works correctly in TotpService
---
### SEC-004: Audit workspace invitation token security
**Status:** Open
**File:** `Models/WorkspaceInvitation.php`
Invitation tokens are 64-character random strings, which is good. However:
- Tokens should be hashed when stored (store hash, compare with hash_equals)
- Add brute-force protection for invitation acceptance endpoint
- Consider shorter expiry for high-privilege roles (owner/admin)
**Acceptance Criteria:**
- Store hashed tokens instead of plaintext
- Add rate limiting to invitation acceptance
- Add configurable expiry per role type
---
### SEC-005: Add CSRF protection to webhook test endpoint
**Status:** Open
**File:** `Controllers/Api/EntitlementWebhookController.php`
The `test` endpoint triggers an outbound HTTP request. Ensure it cannot be abused as a server-side request forgery (SSRF) vector.
**Acceptance Criteria:**
- Validate webhook URL against allowlist or blocklist
- Prevent requests to internal IP ranges (127.0.0.0/8, 10.0.0.0/8, etc.)
- Add timeout and response size limits
---
### SEC-006: Validate workspace_id in RequireWorkspaceContext middleware
**Status:** Open
**File:** `Middleware/RequireWorkspaceContext.php`
The middleware accepts workspace_id from multiple sources (header, query, input) without validating the authenticated user's access in all code paths.
**Acceptance Criteria:**
- Always validate user has access to the resolved workspace
- Make `validate` parameter the default behaviour
- Log workspace access attempts for security monitoring
---
## P2 - High Priority
### DX-001: Add strict_types declaration to all PHP files
**Status:** Open
**Files:** Multiple files missing declaration
Several files are missing `declare(strict_types=1);`:
- `Models/Workspace.php`
- `Models/User.php`
- `Services/EntitlementService.php`
**Acceptance Criteria:**
- Add strict_types to all PHP files
- Run tests to verify no type coercion issues
---
### DX-002: Document EntitlementService public API
**Status:** Open
**File:** `Services/EntitlementService.php`
The EntitlementService is the core API for entitlement checks but lacks comprehensive PHPDoc. External consumers need clear documentation.
**Acceptance Criteria:**
- Add complete PHPDoc to all public methods
- Document exception conditions
- Add @throws annotations where applicable
- Create usage examples in documentation
---
### TEST-001: Add tests for namespace-level entitlements
**Status:** Open
**File:** `tests/Feature/EntitlementServiceTest.php`
The test file covers workspace-level entitlements but not namespace-level (`canForNamespace`, `recordNamespaceUsage`, etc.).
**Acceptance Criteria:**
- Test `canForNamespace()` with various ownership scenarios
- Test entitlement cascade (namespace -> workspace -> user tier)
- Test `provisionNamespacePackage()` and `provisionNamespaceBoost()`
- Test namespace cache invalidation
---
### TEST-002: Add integration tests for EntitlementApiController
**Status:** Open
**File:** `tests/Feature/EntitlementApiTest.php`
Need HTTP-level integration tests for the API endpoints, including authentication, validation, and error cases.
**Acceptance Criteria:**
- Test all CRUD operations via HTTP
- Test validation error responses
- Test authentication failures
- Test rate limiting (once implemented)
---
### PERF-001: Optimise EntitlementService cache invalidation
**Status:** Open
**File:** `Services/EntitlementService.php`
The `invalidateCache()` method iterates all features and clears each key individually. This is O(n) where n = feature count.
**Acceptance Criteria:**
- Use cache tags when available (Redis)
- Implement version-based cache busting
- Benchmark before/after with 100+ features
---
### PERF-002: Add database indexes for common queries
**Status:** Open
**File:** `Migrations/0001_01_01_000000_create_tenant_tables.php`
Missing indexes identified:
- `users.tier` (for tier-based queries)
- `namespaces.slug` (currently only unique in combination)
- `entitlement_usage_records.user_id`
**Acceptance Criteria:**
- Create migration adding missing indexes
- Verify query plan improvements with EXPLAIN
---
### CODE-001: Extract WorkspaceScope to separate file
**Status:** Open
**File:** `Scopes/WorkspaceScope.php`
The WorkspaceScope class exists but is referenced in BelongsToWorkspace trait without actually being applied as a global scope. Clarify the architecture.
**Acceptance Criteria:**
- Document when WorkspaceScope vs BelongsToWorkspace should be used
- Consider applying WorkspaceScope as a proper global scope
- Update CLAUDE.md with guidance
---
### CODE-002: Consolidate User model relationships
**Status:** Open
**File:** `Models/User.php`
The User model has many undefined relationships (Page, Project, Domain, Pixel, etc.) that reference classes not in this package. These should either be:
1. Moved to the consuming application
2. Made conditional on class existence
**Acceptance Criteria:**
- Audit all relationships for undefined classes
- Add `class_exists()` guards or move to app layer
- Document which relationships are package-native vs app-specific
---
### CODE-003: Remove hardcoded domain in EntitlementApiController
**Status:** Open
**File:** `Controllers/EntitlementApiController.php`, Line 80
The workspace creation uses hardcoded domain `'hub.host.uk.com'`. This should be configurable.
**Acceptance Criteria:**
- Move to config value
- Add sensible default
- Document in CLAUDE.md
---
## P3 - Medium Priority
### DX-003: Add return type hints to all Workspace relationships
**Status:** Open
**File:** `Models/Workspace.php`
Many relationship methods have correct docblocks but inconsistent return types. Standardise for IDE support.
**Acceptance Criteria:**
- Add explicit return types to all relationship methods
- Verify PHPStan/Larastan passes at level 6+
---
### DX-004: Create EntitlementException subtypes
**Status:** Open
**File:** `Exceptions/EntitlementException.php`
Currently there's a single EntitlementException. Consider subtypes:
- `LimitExceededException`
- `PackageNotFoundException`
- `FeatureNotFoundException`
**Acceptance Criteria:**
- Create exception hierarchy
- Update EntitlementService to throw specific exceptions
- Update documentation with exception types
---
### TEST-003: Add tests for WorkspaceTeamService
**Status:** Open
**File:** `Services/WorkspaceTeamService.php`
No dedicated test file for WorkspaceTeamService. The service handles team CRUD, permissions, and member management.
**Acceptance Criteria:**
- Test team creation/update/deletion
- Test permission checks (hasPermission, hasAnyPermission, hasAllPermissions)
- Test member assignment to teams
- Test default team seeding
- Test member migration from roles to teams
---
### TEST-004: Add tests for EntitlementWebhookService
**Status:** Open
**File:** `Services/EntitlementWebhookService.php`
Need tests for webhook dispatch, signature verification, and circuit breaker functionality.
**Acceptance Criteria:**
- Test webhook registration
- Test event dispatch (sync and async)
- Test signature signing and verification
- Test circuit breaker trigger and reset
- Test delivery retry logic
---
### TEST-005: Add edge case tests for TotpService
**Status:** Open
**File:** `Services/TotpService.php`
Current tests may not cover:
- Clock drift (WINDOW parameter)
- Invalid base32 input
- Empty/null code handling
**Acceptance Criteria:**
- Test verification with clock drift
- Test malformed secret handling
- Test edge cases in base32 encode/decode
---
### PERF-003: Lazy-load Workspace relationships
**Status:** Open
**File:** `Models/Workspace.php`
The Workspace model has 30+ relationships. Many are to external packages (Core\Mod\Social, etc.). Consider:
- Marking heavy relationships as lazy
- Using `withCount` instead of loading full relations for counts
**Acceptance Criteria:**
- Audit which relationships are commonly N+1 issues
- Add `$with` property sparingly
- Document recommended eager loading patterns
---
### CODE-004: Standardise error responses across API controllers
**Status:** Open
**Files:** `Controllers/EntitlementApiController.php`, `Controllers/Api/EntitlementWebhookController.php`
Error response formats vary. Standardise to consistent structure:
```json
{
"success": false,
"error": "Error message",
"code": "ERROR_CODE"
}
```
**Acceptance Criteria:**
- Create API response trait or service
- Apply to all API controllers
- Document response format
---
### CODE-005: Add validation for webhook URL in registration
**Status:** Open
**File:** `Services/EntitlementWebhookService.php`
The `register()` method doesn't validate the webhook URL format or accessibility.
**Acceptance Criteria:**
- Validate URL format (must be https in production)
- Optionally verify URL is reachable
- Block internal IP ranges
---
### FEAT-001: Add soft deletes to WorkspaceInvitation
**Status:** Open
**File:** `Models/WorkspaceInvitation.php`
Invitations are currently hard-deleted. Soft deletes would preserve audit trail.
**Acceptance Criteria:**
- Add SoftDeletes trait
- Update delete operations
- Add migration for deleted_at column
---
### FEAT-002: Add invitation resend functionality
**Status:** Open
**File:** `Models/WorkspaceInvitation.php`
Users may miss invitation emails. Add ability to resend with updated expiry.
**Acceptance Criteria:**
- Add `resend()` method to WorkspaceInvitation
- Extend expiry on resend
- Track resend count/timestamps
- Rate limit resends
---
## P4 - Low Priority
### DX-005: Add IDE helper annotations
**Status:** Open
Add `@mixin` and `@method` annotations for better IDE autocomplete with Eloquent.
**Acceptance Criteria:**
- Add annotations to all models
- Document pattern for future models
---
### DX-006: Create artisan command for provisioning packages
**Status:** Open
Manual package provisioning via tinker is error-prone. Add CLI command.
**Acceptance Criteria:**
- `php artisan tenant:provision-package {workspace} {package}`
- Add interactive mode
- Support dry-run option
---
### TEST-006: Add mutation testing
**Status:** Open
Run infection/mutation testing to verify test quality.
**Acceptance Criteria:**
- Add infection to dev dependencies
- Configure for core services
- Achieve >80% mutation score on critical code
---
### CODE-006: Extract constants from WorkspaceMember
**Status:** Open
**File:** `Models/WorkspaceMember.php`
Role constants should be in an enum for type safety.
**Acceptance Criteria:**
- Create WorkspaceMemberRole enum
- Update model to use enum
- Update all role comparisons
---
### CODE-007: Add configurable invitation expiry
**Status:** Open
**File:** `Models/Workspace.php`, Line 654
The `invite()` method has hardcoded 7-day expiry. Make configurable.
**Acceptance Criteria:**
- Add config key `tenant.invitation_expiry_days`
- Document configuration option
---
### FEAT-003: Add workspace transfer ownership
**Status:** Open
Allow workspace owners to transfer ownership to another member.
**Acceptance Criteria:**
- Add `transferOwnership()` method to WorkspaceManager
- Require confirmation from new owner
- Log ownership transfer in audit log
---
### FEAT-004: Add bulk invitation support
**Status:** Open
Allow inviting multiple users at once (CSV upload or multi-email input).
**Acceptance Criteria:**
- Add `inviteMany()` method
- Support CSV email import
- Handle duplicates gracefully
---
## P5 - Nice to Have
### DX-007: Add OpenAPI/Swagger documentation
**Status:** Open
Generate API documentation from route definitions.
**Acceptance Criteria:**
- Add scramble or l5-swagger
- Document all API endpoints
- Include authentication requirements
---
### FEAT-005: Add workspace activity log
**Status:** Open
Track all significant workspace actions for audit purposes.
**Acceptance Criteria:**
- Log member additions/removals
- Log permission changes
- Log package/boost changes
- Provide query interface
---
### FEAT-006: Add usage forecasting
**Status:** Open
Predict when a workspace will hit limits based on usage trends.
**Acceptance Criteria:**
- Track daily usage aggregates
- Implement simple linear projection
- Show "estimated days until limit" in dashboard
---
### FEAT-007: Add webhook event filtering
**Status:** Open
Allow webhooks to filter events by additional criteria (e.g., specific features only).
**Acceptance Criteria:**
- Add filter configuration to webhook
- Support feature code patterns
- Support threshold filtering for limit events
---
## P6 - Backlog / Ideas
### IDEA-001: GraphQL API for entitlements
Consider adding GraphQL endpoint for more flexible entitlement queries.
### IDEA-002: Real-time usage updates
WebSocket support for live usage updates in dashboard.
### IDEA-003: Entitlement simulation mode
Allow testing "what if I upgrade" scenarios without actual changes.
### IDEA-004: Multi-region support
Support for workspace data residency requirements.
### IDEA-005: Workspace templates
Pre-configured workspace setups for different use cases.
---
## Completed
_Move items here when done with completion date._
<!-- Example:
### SEC-001: Add rate limiting to API
**Status:** Done (2026-01-29)
**PR:** #123
-->

422
docs/architecture.md Normal file
View file

@ -0,0 +1,422 @@
---
title: Architecture
description: Technical architecture of the core-tenant multi-tenancy package
updated: 2026-01-29
---
# core-tenant Architecture
This document describes the technical architecture of the core-tenant package, which provides multi-tenancy, user management, and entitlement systems for the Host UK platform.
## Overview
core-tenant is the foundational tenancy layer that enables:
- **Workspaces** - The primary tenant boundary (organisations, teams)
- **Namespaces** - Product-level isolation within or across workspaces
- **Entitlements** - Feature access control, usage limits, and billing integration
- **User Management** - Authentication, 2FA, and workspace membership
## Core Concepts
### Tenant Hierarchy
```
User
├── owns Workspaces (can own multiple)
│ ├── has WorkspacePackages (entitlements)
│ ├── has Boosts (temporary limit increases)
│ ├── has Members (users with roles/permissions)
│ ├── has Teams (permission groups)
│ └── owns Namespaces (product boundaries)
└── owns Namespaces (personal, not workspace-linked)
```
### Workspace
The `Workspace` model is the primary tenant boundary. All tenant-scoped data references a workspace_id.
**Key Properties:**
- `slug` - URL-safe unique identifier
- `domain` - Optional custom domain
- `settings` - JSON configuration blob
- `stripe_customer_id` / `btcpay_customer_id` - Billing integration
**Relationships:**
- `users()` - Members via pivot table
- `workspacePackages()` - Active entitlement packages
- `boosts()` - Temporary limit increases
- `namespaces()` - Owned namespaces (polymorphic)
### Namespace
The `Namespace_` model provides a universal product boundary. Products belong to namespaces rather than directly to users/workspaces.
**Ownership Patterns:**
1. **User-owned**: Individual creator with personal namespace
2. **Workspace-owned**: Agency managing client namespaces
3. **User with workspace billing**: Personal namespace but billed to workspace
**Entitlement Cascade:**
1. Check namespace-level packages first
2. Fall back to workspace pool (if namespace has workspace_id)
3. Fall back to user tier (for user-owned namespaces)
### BelongsToWorkspace Trait
Models that are workspace-scoped should use the `BelongsToWorkspace` trait:
```php
class Account extends Model
{
use BelongsToWorkspace;
}
```
**Security Features:**
- Auto-assigns `workspace_id` on create (or throws exception)
- Provides `ownedByCurrentWorkspace()` scope
- Auto-invalidates workspace cache on model changes
**Strict Mode:**
When `WorkspaceScope::isStrictModeEnabled()` is true:
- Creating models without workspace context throws `MissingWorkspaceContextException`
- Querying without context throws exception
- This prevents accidental cross-tenant data access
## Entitlement System
### Feature Types
Features (`entitlement_features` table) have three types:
| Type | Description | Example |
|------|-------------|---------|
| `boolean` | On/off access | Beta features |
| `limit` | Numeric limit with usage tracking | 100 AI credits/month |
| `unlimited` | No limit | Unlimited social accounts |
### Reset Types
| Type | Description |
|------|-------------|
| `none` | No reset (cumulative) |
| `monthly` | Resets at billing cycle start |
| `rolling` | Rolling window (e.g., last 30 days) |
### Package Model
Packages bundle features with specific limits:
```
Package (creator)
├── Feature: ai.credits (limit: 100)
├── Feature: social.accounts (limit: 5)
└── Feature: tier.apollo (boolean)
```
### Boost Model
Boosts provide temporary limit increases:
| Boost Type | Description |
|------------|-------------|
| `add_limit` | Adds to existing limit |
| `enable` | Enables a boolean feature |
| `unlimited` | Makes feature unlimited |
| Duration Type | Description |
|---------------|-------------|
| `cycle_bound` | Expires at billing cycle end |
| `duration` | Expires after set period |
| `permanent` | Never expires |
### Entitlement Check Flow
```
EntitlementService::can($workspace, 'ai.credits', quantity: 5)
├─> Get Feature by code
│ └─> Get pool feature code (for hierarchical features)
├─> Calculate total limit
│ ├─> Sum limits from active WorkspacePackages
│ └─> Add remaining limits from active Boosts
├─> Get current usage
│ ├─> Check reset type (monthly/rolling/none)
│ └─> Sum UsageRecords in window
└─> Return EntitlementResult
├─> allowed: bool
├─> limit: int|null
├─> used: int
├─> remaining: int|null
└─> reason: string (if denied)
```
### Caching Strategy
Entitlement data is cached with 5-minute TTL:
- `entitlement:{workspace_id}:limit:{feature_code}`
- `entitlement:{workspace_id}:usage:{feature_code}`
Cache invalidation occurs on:
- Package provision/suspend/cancel
- Boost provision/expire
- Usage recording
## Service Layer
### WorkspaceManager
Manages workspace context and basic CRUD:
```php
$manager = app(WorkspaceManager::class);
$manager->setCurrent($workspace); // Set context
$manager->loadBySlug('acme'); // Load by slug
$manager->create($user, $attrs); // Create workspace
$manager->addUser($workspace, $user); // Add member
```
### EntitlementService
Core API for entitlement checks and management:
```php
$service = app(EntitlementService::class);
// Check feature access
$result = $service->can($workspace, 'ai.credits', quantity: 5);
if ($result->isAllowed()) {
// Record usage after action
$service->recordUsage($workspace, 'ai.credits', quantity: 5);
}
// Provision packages
$service->provisionPackage($workspace, 'creator', [
'source' => 'blesta',
'billing_cycle_anchor' => now(),
]);
// Suspend/reactivate
$service->suspendWorkspace($workspace);
$service->reactivateWorkspace($workspace);
```
### WorkspaceTeamService
Manages teams and permissions:
```php
$teamService = app(WorkspaceTeamService::class);
$teamService->forWorkspace($workspace);
// Check permissions
if ($teamService->hasPermission($user, 'social.write')) {
// User can write social content
}
// Team management
$team = $teamService->createTeam([
'name' => 'Content Creators',
'permissions' => ['social.read', 'social.write'],
]);
$teamService->addMemberToTeam($user, $team);
```
### WorkspaceCacheManager
Workspace-scoped caching with tag support:
```php
$cache = app(WorkspaceCacheManager::class);
// Cache workspace data
$data = $cache->remember($workspace, 'expensive-query', 300, function () {
return ExpensiveModel::forWorkspace($workspace)->get();
});
// Flush workspace cache
$cache->flush($workspace);
```
## Middleware
### RequireWorkspaceContext
Ensures workspace context before processing:
```php
Route::middleware('workspace.required')->group(function () {
// Routes here require workspace context
});
// With user access validation
Route::middleware('workspace.required:validate')->group(function () {
// Also validates user has access to workspace
});
```
Workspace resolved from (in order):
1. Request attribute `workspace_model`
2. `Workspace::current()` (session/auth)
3. Request input `workspace_id`
4. Header `X-Workspace-ID`
5. Query param `workspace`
### CheckWorkspacePermission
Checks user has specific permissions:
```php
Route::middleware('workspace.permission:social.write')->group(function () {
// Requires social.write permission
});
// Multiple permissions (OR logic)
Route::middleware('workspace.permission:admin,owner')->group(function () {
// Requires admin OR owner role
});
```
## Event System
### Lifecycle Events
The module uses event-driven lazy loading:
```php
class Boot extends ServiceProvider
{
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
WebRoutesRegistering::class => 'onWebRoutes',
ConsoleBooting::class => 'onConsole',
];
}
```
### Entitlement Webhooks
External systems can subscribe to entitlement events:
| Event | Trigger |
|-------|---------|
| `limit_warning` | Usage at 80% or 90% |
| `limit_reached` | Usage at 100% |
| `package_changed` | Package add/change/remove |
| `boost_activated` | Boost provisioned |
| `boost_expired` | Boost expired |
Webhooks include:
- HMAC-SHA256 signature verification
- Automatic retry with exponential backoff
- Circuit breaker after consecutive failures
## Two-Factor Authentication
### TotpService
RFC 6238 compliant TOTP implementation:
```php
$totp = app(TwoFactorAuthenticationProvider::class);
// Generate secret
$secret = $totp->generateSecretKey(); // 160-bit base32
// Generate QR code URL
$url = $totp->qrCodeUrl('AppName', $user->email, $secret);
// Verify code
if ($totp->verify($secret, $userCode)) {
// Valid
}
```
### TwoFactorAuthenticatable Trait
Add to User model:
```php
class User extends Authenticatable
{
use TwoFactorAuthenticatable;
}
// Enable 2FA
$secret = $user->enableTwoFactorAuth();
// User scans QR, enters code
if ($user->verifyTwoFactorCode($code)) {
$recoveryCodes = $user->confirmTwoFactorAuth();
}
// Disable
$user->disableTwoFactorAuth();
```
## Database Schema
### Core Tables
| Table | Purpose |
|-------|---------|
| `users` | User accounts |
| `workspaces` | Tenant organisations |
| `user_workspace` | User-workspace pivot |
| `namespaces` | Product boundaries |
### Entitlement Tables
| Table | Purpose |
|-------|---------|
| `entitlement_features` | Feature definitions |
| `entitlement_packages` | Package definitions |
| `entitlement_package_features` | Package-feature pivot |
| `entitlement_workspace_packages` | Workspace package assignments |
| `entitlement_namespace_packages` | Namespace package assignments |
| `entitlement_boosts` | Active boosts |
| `entitlement_usage_records` | Usage tracking |
| `entitlement_logs` | Audit log |
### Team Tables
| Table | Purpose |
|-------|---------|
| `workspace_teams` | Team definitions |
| `workspace_invitations` | Pending invitations |
## Configuration
The package uses these config keys:
```php
// config/core.php
return [
'workspace_cache' => [
'enabled' => true,
'ttl' => 300,
'prefix' => 'workspace_cache',
'use_tags' => true,
],
];
```
## Testing
Tests are in `tests/Feature/` using Pest:
```bash
composer test # All tests
vendor/bin/pest tests/Feature/EntitlementServiceTest.php # Single file
vendor/bin/pest --filter="can method" # Filter by name
```
Key test files:
- `EntitlementServiceTest.php` - Core entitlement logic
- `WorkspaceSecurityTest.php` - Tenant isolation
- `WorkspaceCacheTest.php` - Caching behaviour
- `TwoFactorAuthenticatableTest.php` - 2FA flows

465
docs/entitlements.md Normal file
View file

@ -0,0 +1,465 @@
---
title: Entitlements
description: Guide to the entitlement system for feature access and usage limits
updated: 2026-01-29
---
# Entitlement System
The entitlement system controls feature access, usage limits, and billing integration for workspaces and namespaces.
## Quick Start
### Check Feature Access
```php
use Core\Tenant\Services\EntitlementService;
$entitlements = app(EntitlementService::class);
// Check if workspace can use a feature
$result = $entitlements->can($workspace, 'ai.credits', quantity: 5);
if ($result->isAllowed()) {
// Perform action
$entitlements->recordUsage($workspace, 'ai.credits', quantity: 5, user: $user);
} else {
// Handle denial
return response()->json([
'error' => $result->reason,
'limit' => $result->limit,
'used' => $result->used,
], 403);
}
```
### Via Workspace Model
```php
$result = $workspace->can('social.accounts');
if ($result->isAllowed()) {
$workspace->recordUsage('social.accounts');
}
```
## Concepts
### Features
Features are defined in the `entitlement_features` table:
| Field | Description |
|-------|-------------|
| `code` | Unique identifier (e.g., `ai.credits`, `social.accounts`) |
| `type` | `boolean`, `limit`, or `unlimited` |
| `reset_type` | `none`, `monthly`, or `rolling` |
| `rolling_window_days` | Days for rolling window |
| `parent_feature_id` | For hierarchical features (pool sharing) |
**Feature Types:**
| Type | Behaviour |
|------|-----------|
| `boolean` | Binary on/off access |
| `limit` | Numeric limit with usage tracking |
| `unlimited` | Feature enabled with no limits |
**Reset Types:**
| Type | Behaviour |
|------|-----------|
| `none` | Usage accumulates forever |
| `monthly` | Resets at billing cycle start |
| `rolling` | Rolling window (e.g., last 30 days) |
### Packages
Packages bundle features with specific limits:
```php
// Example package definition
$package = Package::create([
'code' => 'creator',
'name' => 'Creator Plan',
'is_base_package' => true,
'monthly_price' => 19.99,
]);
// Attach features
$package->features()->attach($aiCreditsFeature->id, ['limit_value' => 100]);
$package->features()->attach($socialAccountsFeature->id, ['limit_value' => 5]);
```
### Workspace Packages
Packages are provisioned to workspaces:
```php
$workspacePackage = $entitlements->provisionPackage($workspace, 'creator', [
'source' => EntitlementLog::SOURCE_BLESTA,
'billing_cycle_anchor' => now(),
'blesta_service_id' => 'srv_12345',
]);
```
**Statuses:**
- `active` - Package is in use
- `suspended` - Temporarily disabled (e.g., payment failed)
- `cancelled` - Permanently ended
- `expired` - Past expiry date
### Boosts
Boosts provide temporary limit increases:
```php
$boost = $entitlements->provisionBoost($workspace, 'ai.credits', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 50,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
]);
```
**Boost Types:**
| Type | Effect |
|------|--------|
| `add_limit` | Adds to package limit |
| `enable` | Enables boolean feature |
| `unlimited` | Makes feature unlimited |
**Duration Types:**
| Type | Expiry |
|------|--------|
| `cycle_bound` | Expires at billing cycle end |
| `duration` | Expires after set `expires_at` |
| `permanent` | Never expires |
## API Reference
### EntitlementService
#### can()
Check if a workspace can use a feature:
```php
public function can(
Workspace $workspace,
string $featureCode,
int $quantity = 1
): EntitlementResult
```
**Returns `EntitlementResult` with:**
- `isAllowed(): bool`
- `isDenied(): bool`
- `isUnlimited(): bool`
- `limit: ?int`
- `used: int`
- `remaining: ?int`
- `reason: ?string`
- `featureCode: string`
- `getUsagePercentage(): ?float`
- `isNearLimit(): bool` (>80%)
- `isAtLimit(): bool` (100%)
#### canForNamespace()
Check entitlement for a namespace with cascade:
```php
public function canForNamespace(
Namespace_ $namespace,
string $featureCode,
int $quantity = 1
): EntitlementResult
```
Cascade order:
1. Namespace-level packages
2. Workspace pool (if `namespace->workspace_id` set)
3. User tier (if namespace owned by user)
#### recordUsage()
Record feature usage:
```php
public function recordUsage(
Workspace $workspace,
string $featureCode,
int $quantity = 1,
?User $user = null,
?array $metadata = null
): UsageRecord
```
#### provisionPackage()
Assign a package to a workspace:
```php
public function provisionPackage(
Workspace $workspace,
string $packageCode,
array $options = []
): WorkspacePackage
```
**Options:**
- `source` - `system`, `blesta`, `admin`, `user`
- `billing_cycle_anchor` - Start of billing cycle
- `expires_at` - Package expiry date
- `blesta_service_id` - External billing reference
- `metadata` - Additional data
#### provisionBoost()
Add a temporary boost:
```php
public function provisionBoost(
Workspace $workspace,
string $featureCode,
array $options = []
): Boost
```
**Options:**
- `boost_type` - `add_limit`, `enable`, `unlimited`
- `duration_type` - `cycle_bound`, `duration`, `permanent`
- `limit_value` - Amount to add (for `add_limit`)
- `expires_at` - Expiry date (for `duration`)
#### suspendWorkspace() / reactivateWorkspace()
Manage workspace package status:
```php
$entitlements->suspendWorkspace($workspace, 'blesta');
$entitlements->reactivateWorkspace($workspace, 'admin');
```
#### getUsageSummary()
Get all feature usage for a workspace:
```php
$summary = $entitlements->getUsageSummary($workspace);
// Returns Collection grouped by category:
// [
// 'ai' => [
// ['code' => 'ai.credits', 'limit' => 100, 'used' => 50, ...],
// ],
// 'social' => [
// ['code' => 'social.accounts', 'limit' => 5, 'used' => 3, ...],
// ],
// ]
```
## Namespace-Level Entitlements
For products that operate at namespace level:
```php
$result = $entitlements->canForNamespace($namespace, 'bio.pages');
if ($result->isAllowed()) {
$entitlements->recordNamespaceUsage($namespace, 'bio.pages', user: $user);
}
// Provision namespace-specific package
$entitlements->provisionNamespacePackage($namespace, 'bio-pro');
```
## Usage Alerts
The `UsageAlertService` monitors usage and sends notifications:
```php
// Check single workspace
$alerts = app(UsageAlertService::class)->checkWorkspace($workspace);
// Check all workspaces (scheduled command)
php artisan tenant:check-usage-alerts
```
**Alert Thresholds:**
- 80% - Warning
- 90% - Critical
- 100% - Limit reached
**Notification Channels:**
- Email to workspace owner
- Webhook events (`limit_warning`, `limit_reached`)
## Billing Integration
### Blesta API
External endpoints for billing system integration:
```
POST /api/entitlements - Provision package
POST /api/entitlements/{id}/suspend - Suspend
POST /api/entitlements/{id}/unsuspend - Reactivate
POST /api/entitlements/{id}/cancel - Cancel
POST /api/entitlements/{id}/renew - Renew
GET /api/entitlements/{id} - Get details
```
### Cross-App API
For other Host UK services to check entitlements:
```
GET /api/entitlements/check - Check feature access
POST /api/entitlements/usage - Record usage
GET /api/entitlements/summary - Get usage summary
```
## Webhooks
Subscribe to entitlement events:
```php
$webhookService = app(EntitlementWebhookService::class);
$webhook = $webhookService->register($workspace,
name: 'Usage Alerts',
url: 'https://api.example.com/hooks/entitlements',
events: ['limit_warning', 'limit_reached']
);
```
**Available Events:**
- `limit_warning` - 80%/90% threshold
- `limit_reached` - 100% threshold
- `package_changed` - Package add/change/remove
- `boost_activated` - New boost
- `boost_expired` - Boost expired
**Payload Format:**
```json
{
"event": "limit_warning",
"data": {
"workspace_id": 123,
"feature_code": "ai.credits",
"threshold": 80,
"used": 80,
"limit": 100
},
"timestamp": "2026-01-29T12:00:00Z"
}
```
**Verification:**
```php
$isValid = $webhookService->verifySignature(
$payload,
$request->header('X-Signature'),
$webhook->secret
);
```
## Best Practices
### Check Before Action
Always check entitlements before performing the action:
```php
// Bad: Check after action
$account = SocialAccount::create([...]);
if (!$workspace->can('social.accounts')->isAllowed()) {
$account->delete();
throw new \Exception('Limit exceeded');
}
// Good: Check before action
$result = $workspace->can('social.accounts');
if ($result->isDenied()) {
throw new EntitlementException($result->reason);
}
$account = SocialAccount::create([...]);
$workspace->recordUsage('social.accounts');
```
### Use Transactions
For atomic check-and-record:
```php
DB::transaction(function () use ($workspace, $user) {
$result = $workspace->can('ai.credits', 10);
if ($result->isDenied()) {
throw new EntitlementException($result->reason);
}
// Perform AI generation
$output = $aiService->generate($prompt);
// Record usage
$workspace->recordUsage('ai.credits', 10, $user, [
'model' => 'claude-3',
'tokens' => 1500,
]);
return $output;
});
```
### Cache Considerations
Entitlement checks are cached for 5 minutes. For real-time accuracy:
```php
// Force cache refresh
$entitlements->invalidateCache($workspace);
$result = $entitlements->can($workspace, 'feature');
```
### Feature Code Conventions
Use dot notation for feature codes:
```
service.feature
service.feature.subfeature
```
Examples:
- `ai.credits`
- `social.accounts`
- `social.posts.scheduled`
- `bio.pages`
- `analytics.websites`
### Hierarchical Features
For shared pools, use parent features:
```php
// Parent feature (pool)
$aiCredits = Feature::create([
'code' => 'ai.credits',
'type' => Feature::TYPE_LIMIT,
]);
// Child feature (uses parent pool)
$aiGeneration = Feature::create([
'code' => 'ai.generation',
'parent_feature_id' => $aiCredits->id,
]);
// Both check against ai.credits pool
$workspace->can('ai.generation'); // Uses ai.credits limit
```

309
docs/security.md Normal file
View file

@ -0,0 +1,309 @@
---
title: Security
description: Security considerations and audit notes for core-tenant
updated: 2026-01-29
---
# Security Considerations
This document outlines security considerations, implemented protections, and known areas requiring attention in the core-tenant package.
## Multi-Tenant Data Isolation
### Workspace Scope Enforcement
The primary security mechanism is the `BelongsToWorkspace` trait which enforces workspace isolation at the model level.
**How it works:**
1. **Strict Mode** (default in web requests): Queries without workspace context throw `MissingWorkspaceContextException`
2. **Auto-assignment**: Creating models without explicit `workspace_id` uses current context or throws
3. **Cache invalidation**: Model changes automatically invalidate workspace-scoped cache
**Code paths:**
```php
// SAFE: Explicit workspace context
Account::forWorkspace($workspace)->get();
// SAFE: Uses current workspace from request
Account::ownedByCurrentWorkspace()->get();
// THROWS in strict mode: No workspace context
Account::query()->get(); // MissingWorkspaceContextException
// DANGEROUS: Bypasses scope - use with caution
Account::query()->acrossWorkspaces()->get();
WorkspaceScope::withoutStrictMode(fn() => Account::all());
```
### Middleware Protection
| Middleware | Purpose |
|------------|---------|
| `RequireWorkspaceContext` | Ensures workspace is set before route handling |
| `CheckWorkspacePermission` | Validates user has required permissions |
**Recommendation:** Always use `workspace.required:validate` for user-facing routes to ensure the authenticated user actually has access to the resolved workspace.
### Known Gaps
1. **SEC-006**: The `RequireWorkspaceContext` middleware accepts workspace from headers/query params without mandatory user access validation. The `validate` parameter should be the default.
2. **Cross-tenant API**: The `EntitlementApiController` accepts workspace lookups by email, which could allow enumeration of user-workspace associations. Consider adding authentication scopes.
## Authentication Security
### Password Storage
Passwords are hashed using bcrypt via Laravel's `hashed` cast:
```php
protected function casts(): array
{
return [
'password' => 'hashed',
];
}
```
### Two-Factor Authentication
**Implemented:**
- TOTP (RFC 6238) with 30-second time steps
- 6-digit codes with SHA-1 HMAC
- Clock drift tolerance (1 window each direction)
- 8 recovery codes (20 characters each)
**Security Considerations:**
1. **SEC-003**: TOTP secrets are stored in plaintext. Should use Laravel's `encrypted` cast.
- File: `Models/UserTwoFactorAuth.php`
- Risk: Database breach exposes all 2FA secrets
- Mitigation: Use `'secret_key' => 'encrypted'` cast
2. Recovery codes are stored as JSON array. Consider hashing each code individually.
3. No brute-force protection on TOTP verification endpoint (rate limiting should be applied at route level).
### Session Security
Standard Laravel session handling with:
- `sessions` table for database driver
- IP address and user agent tracking
- `remember_token` for persistent sessions
## API Security
### Blesta Integration API
The `EntitlementApiController` provides endpoints for external billing system integration:
| Endpoint | Risk | Mitigation |
|----------|------|------------|
| `POST /store` | Creates users/workspaces | Requires API auth |
| `POST /suspend/{id}` | Suspends access | Requires API auth |
| `POST /cancel/{id}` | Cancels packages | Requires API auth |
**Known Issues:**
1. **SEC-001**: No rate limiting on API endpoints
- Risk: Compromised API key could mass-provision accounts
- Mitigation: Add rate limiting middleware
2. **SEC-002**: API authentication not visible in `Routes/api.php`
- Action: Verify Blesta routes have proper auth middleware
### Webhook Security
**Implemented:**
- HMAC-SHA256 signature on all payloads
- `X-Signature` header for verification
- 32-byte random secrets (256-bit)
**Code:**
```php
// Signing (outbound)
$signature = hash_hmac('sha256', json_encode($payload), $webhook->secret);
// Verification (inbound)
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
```
**Known Issues:**
1. **SEC-005**: Webhook test endpoint could be SSRF vector
- Risk: Attacker could probe internal network via webhook URL
- Mitigation: Validate URLs against blocklist, prevent internal IPs
### Invitation Tokens
**Implemented:**
- 64-character random tokens (`Str::random(64)`)
- Expiration dates with default 7-day TTL
- Single-use (marked accepted_at after use)
**Known Issues:**
1. **SEC-004**: Tokens stored in plaintext
- Risk: Database breach exposes all pending invitations
- Mitigation: Store hash, compare with `hash_equals()`
2. No rate limiting on invitation acceptance endpoint
- Risk: Brute-force token guessing (though 64 chars is large keyspace)
- Mitigation: Add rate limiting, log failed attempts
## Input Validation
### EntitlementApiController
```php
$validated = $request->validate([
'email' => 'required|email',
'name' => 'required|string|max:255',
'product_code' => 'required|string',
'billing_cycle_anchor' => 'nullable|date',
'expires_at' => 'nullable|date',
'blesta_service_id' => 'nullable|string',
]);
```
**Note:** `blesta_service_id` and `product_code` are not sanitised for special characters. Consider adding regex validation if these are displayed in UI.
### Workspace Manager Validation Rules
The `WorkspaceManager` provides scoped uniqueness rules:
```php
// Ensures uniqueness within workspace
$manager->uniqueRule('social_accounts', 'handle', softDelete: true);
```
## Logging and Audit
### Entitlement Logs
All entitlement changes are logged to `entitlement_logs`:
```php
EntitlementLog::logPackageAction(
$workspace,
EntitlementLog::ACTION_PACKAGE_PROVISIONED,
$workspacePackage,
source: EntitlementLog::SOURCE_BLESTA,
newValues: $workspacePackage->toArray()
);
```
**Logged actions:**
- Package provision/suspend/cancel/reactivate/renew
- Boost provision/expire/cancel
- Usage recording
**Not logged (should consider):**
- Workspace creation/deletion
- Member additions/removals
- Permission changes
- Login attempts
### Security Event Logging
Currently limited. Recommend adding:
- Failed authentication attempts
- 2FA setup/disable events
- Invitation accept/reject
- API key usage
## Sensitive Data Handling
### Hidden Attributes
```php
// User model
protected $hidden = [
'password',
'remember_token',
];
// Workspace model
protected $hidden = [
'wp_connector_secret',
];
```
### Guarded Attributes
```php
// Workspace model
protected $guarded = [
'wp_connector_secret',
];
```
**Note:** Using `$fillable` is generally safer than `$guarded` for sensitive models.
## Recommendations
### Immediate (P1)
1. Add rate limiting to all API endpoints
2. Encrypt 2FA secrets at rest
3. Hash invitation tokens before storage
4. Validate webhook URLs against SSRF attacks
5. Make user access validation default in RequireWorkspaceContext
### Short-term (P2)
1. Add comprehensive security event logging
2. Implement brute-force protection for:
- 2FA verification
- Invitation acceptance
- Password reset
3. Add API scopes for entitlement operations
4. Implement session fingerprinting (detect session hijacking)
### Long-term (P3)
1. Consider WebAuthn/FIDO2 as 2FA alternative
2. Implement cryptographic binding between user sessions and workspace access
3. Add anomaly detection for unusual entitlement patterns
4. Consider field-level encryption for sensitive workspace data
## Security Testing
### Existing Tests
- `WorkspaceSecurityTest.php` - Tests tenant isolation
- `TwoFactorAuthenticatableTest.php` - Tests 2FA flows
### Recommended Additional Tests
1. Test workspace scope bypass attempts
2. Test API authentication failure handling
3. Test rate limiting behaviour
4. Test SSRF protection on webhook URLs
5. Test invitation token brute-force protection
## Compliance Notes
### GDPR Considerations
1. **Account Deletion**: `ProcessAccountDeletion` job handles user data removal
2. **Data Export**: Not currently implemented (consider adding)
3. **Consent Tracking**: Not in scope of this package
### PCI DSS
If handling payment data:
- `stripe_customer_id` and `btcpay_customer_id` are stored (tokens, not card data)
- No direct card handling in this package
- Billing details (name, address) stored in workspace model
## Incident Response
If you discover a security vulnerability:
1. Do not disclose publicly
2. Contact: security@host.uk.com (hypothetical)
3. Include: Vulnerability description, reproduction steps, impact assessment

447
docs/teams-permissions.md Normal file
View file

@ -0,0 +1,447 @@
---
title: Teams and Permissions
description: Guide to workspace teams and permission management
updated: 2026-01-29
---
# Teams and Permissions
The team system provides fine-grained access control within workspaces through role-based teams with configurable permissions.
## Overview
```
Workspace
├── Teams (permission groups)
│ ├── Owners (system team)
│ ├── Admins (system team)
│ ├── Members (system team, default)
│ └── Custom teams...
└── Members (users in workspace)
└── assigned to Team (or custom_permissions)
```
## Quick Start
### Check Permissions
```php
use Core\Tenant\Services\WorkspaceTeamService;
$teamService = app(WorkspaceTeamService::class);
$teamService->forWorkspace($workspace);
// Single permission
if ($teamService->hasPermission($user, 'social.write')) {
// User can create/edit social content
}
// Any of multiple permissions
if ($teamService->hasAnyPermission($user, ['admin', 'owner'])) {
// User is admin or owner
}
// All permissions required
if ($teamService->hasAllPermissions($user, ['social.read', 'social.write'])) {
// User has both permissions
}
```
### Via Middleware
```php
// Single permission
Route::middleware('workspace.permission:social.write')
->group(function () {
Route::post('/posts', [PostController::class, 'store']);
});
// Multiple permissions (OR logic)
Route::middleware('workspace.permission:admin,owner')
->group(function () {
Route::get('/settings', [SettingsController::class, 'index']);
});
```
## System Teams
Three system teams are created by default:
### Owners
```php
[
'slug' => 'owner',
'permissions' => ['*'], // All permissions
'is_system' => true,
]
```
Workspace owners have unrestricted access to all features and settings.
### Admins
```php
[
'slug' => 'admin',
'permissions' => [
'workspace.read',
'workspace.manage_settings',
'workspace.manage_members',
'workspace.manage_billing',
// ... all service permissions
],
'is_system' => true,
]
```
Admins can manage workspace settings and members but cannot delete the workspace or transfer ownership.
### Members
```php
[
'slug' => 'member',
'permissions' => [
'workspace.read',
'social.read', 'social.write',
'bio.read', 'bio.write',
// ... basic service access
],
'is_system' => true,
'is_default' => true,
]
```
Default team for new members. Can use services but not manage workspace settings.
## Permission Structure
### Workspace Permissions
| Permission | Description |
|------------|-------------|
| `workspace.read` | View workspace details |
| `workspace.manage_settings` | Edit workspace settings |
| `workspace.manage_members` | Invite/remove members |
| `workspace.manage_billing` | View/manage billing |
### Service Permissions
Each service follows the pattern: `service.read`, `service.write`, `service.delete`
| Service | Permissions |
|---------|-------------|
| Social | `social.read`, `social.write`, `social.delete` |
| Bio | `bio.read`, `bio.write`, `bio.delete` |
| Analytics | `analytics.read`, `analytics.write` |
| Notify | `notify.read`, `notify.write` |
| Trust | `trust.read`, `trust.write` |
| API | `api.read`, `api.write` |
### Wildcard Permission
The `*` permission grants access to everything. Only used by the Owners team.
## WorkspaceTeamService API
### Team Management
```php
$teamService = app(WorkspaceTeamService::class);
$teamService->forWorkspace($workspace);
// List teams
$teams = $teamService->getTeams();
// Get specific team
$team = $teamService->getTeam($teamId);
$team = $teamService->getTeamBySlug('content-creators');
// Get default team for new members
$defaultTeam = $teamService->getDefaultTeam();
// Create custom team
$team = $teamService->createTeam([
'name' => 'Content Creators',
'slug' => 'content-creators',
'description' => 'Team for content creation staff',
'permissions' => ['social.read', 'social.write', 'bio.read', 'bio.write'],
'colour' => 'blue',
]);
// Update team
$teamService->updateTeam($team, [
'permissions' => [...$team->permissions, 'analytics.read'],
]);
// Delete team (non-system only)
$teamService->deleteTeam($team);
```
### Member Management
```php
// Get member record
$member = $teamService->getMember($user);
// List all members
$members = $teamService->getMembers();
// List team members
$teamMembers = $teamService->getTeamMembers($team);
// Assign member to team
$teamService->addMemberToTeam($user, $team);
// Remove from team
$teamService->removeMemberFromTeam($user);
// Set custom permissions (override team)
$teamService->setMemberCustomPermissions($user, [
'social.read',
'social.write',
// No social.delete
]);
```
### Permission Checks
```php
// Get effective permissions
$permissions = $teamService->getMemberPermissions($user);
// Returns: ['workspace.read', 'social.read', 'social.write', ...]
// Check single permission
$teamService->hasPermission($user, 'social.write');
// Check any permission (OR)
$teamService->hasAnyPermission($user, ['admin', 'owner']);
// Check all permissions (AND)
$teamService->hasAllPermissions($user, ['social.read', 'social.write']);
// Role checks
$teamService->isOwner($user);
$teamService->isAdmin($user);
```
## WorkspaceMember Model
The `WorkspaceMember` model represents the user-workspace relationship:
```php
$member = WorkspaceMember::where('workspace_id', $workspace->id)
->where('user_id', $user->id)
->first();
// Properties
$member->role; // 'owner', 'admin', 'member'
$member->team_id; // Associated team
$member->custom_permissions; // Override permissions (JSON)
$member->joined_at;
$member->invited_by;
// Relationships
$member->user;
$member->team;
$member->inviter;
// Permission methods
$member->getEffectivePermissions(); // Team + custom permissions
$member->hasPermission('social.write');
$member->hasAnyPermission(['admin', 'owner']);
$member->hasAllPermissions(['social.read', 'social.write']);
// Role checks
$member->isOwner();
$member->isAdmin();
```
### Permission Resolution
Effective permissions are resolved in order:
1. **Role-based**: Owner role grants `*`, Admin role grants admin permissions
2. **Team permissions**: Permissions from assigned team
3. **Custom permissions**: If set, completely override team permissions
```php
public function getEffectivePermissions(): array
{
// 1. Owner has all permissions
if ($this->isOwner()) {
return ['*'];
}
// 2. Custom permissions override team
if (!empty($this->custom_permissions)) {
return $this->custom_permissions;
}
// 3. Team permissions
return $this->team?->permissions ?? [];
}
```
## Workspace Invitations
### Invite Users
```php
// Via Workspace model
$invitation = $workspace->invite(
email: 'newuser@example.com',
role: 'member',
invitedBy: $currentUser,
expiresInDays: 7
);
// Invitation sent via WorkspaceInvitationNotification
```
### Accept Invitation
```php
// Find and accept
$invitation = WorkspaceInvitation::findPendingByToken($token);
if ($invitation && $invitation->accept($user)) {
// User added to workspace
}
// Or via Workspace static method
Workspace::acceptInvitation($token, $user);
```
### Invitation States
```php
$invitation->isPending(); // Not accepted, not expired
$invitation->isExpired(); // Past expires_at
$invitation->isAccepted(); // Has accepted_at
```
## Custom Teams
### Creating Custom Teams
```php
$team = $teamService->createTeam([
'name' => 'Social Media Managers',
'slug' => 'social-managers',
'description' => 'Team for managing social media accounts',
'permissions' => [
'workspace.read',
'social.read',
'social.write',
'social.delete',
'analytics.read',
],
'colour' => 'purple',
'is_default' => false,
]);
```
### Making Team Default
```php
$teamService->updateTeam($team, ['is_default' => true]);
// Other teams automatically have is_default set to false
```
### Deleting Teams
```php
// Only non-system teams can be deleted
// Teams with members cannot be deleted
if ($team->is_system) {
throw new \RuntimeException('Cannot delete system teams');
}
if ($teamService->countTeamMembers($team) > 0) {
throw new \RuntimeException('Remove members first');
}
$teamService->deleteTeam($team);
```
## Seeding Default Teams
When creating a new workspace:
```php
$teamService->forWorkspace($workspace);
$teams = $teamService->seedDefaultTeams();
// Or ensure they exist (idempotent)
$teams = $teamService->ensureDefaultTeams();
```
### Migrating Existing Members
If migrating from role-based to team-based:
```php
$migrated = $teamService->migrateExistingMembers();
// Assigns members to teams based on their role:
// owner -> Owners team
// admin -> Admins team
// member -> Members team
```
## Best Practices
### Use Middleware for Route Protection
```php
Route::middleware(['auth', 'workspace.required', 'workspace.permission:social.write'])
->group(function () {
Route::resource('posts', PostController::class);
});
```
### Check Permissions in Controllers
```php
public function store(Request $request)
{
$teamService = app(WorkspaceTeamService::class);
$teamService->forWorkspace($request->attributes->get('workspace_model'));
if (!$teamService->hasPermission($request->user(), 'social.write')) {
abort(403, 'You do not have permission to create posts');
}
// ...
}
```
### Use Policies with Teams
```php
class PostPolicy
{
public function create(User $user): bool
{
$teamService = app(WorkspaceTeamService::class);
return $teamService->hasPermission($user, 'social.write');
}
public function delete(User $user, Post $post): bool
{
$teamService = app(WorkspaceTeamService::class);
return $teamService->hasPermission($user, 'social.delete');
}
}
```
### Permission Naming Conventions
Follow the pattern: `service.action`
- `service.read` - View resources
- `service.write` - Create/edit resources
- `service.delete` - Delete resources
- `workspace.manage_*` - Workspace admin actions

View file

@ -331,4 +331,72 @@ describe('UserTwoFactorAuth Model', function () {
expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class);
});
it('encrypts secret at rest', function () {
$secretKey = 'JBSWY3DPEHPK3PXP';
$twoFactorAuth = UserTwoFactorAuth::create([
'user_id' => $this->user->id,
'secret_key' => $secretKey,
'recovery_codes' => [],
]);
// The model should return the decrypted value (via secret_key accessor)
expect($twoFactorAuth->secret_key)->toBe($secretKey);
// Also accessible via the actual column name
expect($twoFactorAuth->secret)->toBe($secretKey);
// But the raw database value should be encrypted (base64 JSON with iv, value, mac)
// Note: DB column is 'secret', not 'secret_key'
$rawValue = \Illuminate\Support\Facades\DB::table('user_two_factor_auth')
->where('id', $twoFactorAuth->id)
->value('secret');
// Raw value should not equal the plaintext
expect($rawValue)->not->toBe($secretKey);
// Raw value should look like Laravel encrypted data (starts with eyJ for base64 JSON)
expect($rawValue)->toStartWith('eyJ');
});
it('encrypts recovery_codes at rest', function () {
$codes = ['CODE1-CODE1', 'CODE2-CODE2'];
$twoFactorAuth = UserTwoFactorAuth::create([
'user_id' => $this->user->id,
'secret_key' => 'JBSWY3DPEHPK3PXP',
'recovery_codes' => $codes,
]);
// The model should return the decrypted collection
expect($twoFactorAuth->recovery_codes->toArray())->toBe($codes);
// But the raw database value should be encrypted
$rawValue = \Illuminate\Support\Facades\DB::table('user_two_factor_auth')
->where('id', $twoFactorAuth->id)
->value('recovery_codes');
// Raw value should not contain the plaintext codes
expect($rawValue)->not->toContain('CODE1');
// Raw value should look like Laravel encrypted data
expect($rawValue)->toStartWith('eyJ');
});
it('can decrypt and use encrypted secret for TOTP verification', function () {
$secretKey = 'JBSWY3DPEHPK3PXP';
UserTwoFactorAuth::create([
'user_id' => $this->user->id,
'secret_key' => $secretKey,
'recovery_codes' => [],
'confirmed_at' => now(),
]);
$this->user->refresh();
// The secret should be usable for verification
// (verifyTwoFactorCode uses the secret internally)
expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey);
});
});

View file

@ -4,11 +4,13 @@ declare(strict_types=1);
namespace Core\Tenant\Tests\Feature;
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspaceInvitation;
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
@ -33,7 +35,9 @@ class WorkspaceInvitationTest extends TestCase
'invited_by' => $owner->id,
]);
// Token should be hashed (starts with $2y$)
$this->assertNotNull($invitation->token);
$this->assertTrue(str_starts_with($invitation->token, '$2y$'));
$this->assertTrue($invitation->isPending());
$this->assertFalse($invitation->isExpired());
$this->assertFalse($invitation->isAccepted());
@ -41,6 +45,18 @@ class WorkspaceInvitationTest extends TestCase
Notification::assertSentTo($invitation, WorkspaceInvitationNotification::class);
}
public function test_invitation_token_is_hashed(): void
{
Notification::fake();
$workspace = Workspace::factory()->create();
$invitation = $workspace->invite('test@example.com', 'member');
// Token should be hashed (bcrypt format)
$this->assertTrue(str_starts_with($invitation->token, '$2y$'));
$this->assertEquals(60, strlen($invitation->token));
}
public function test_invitation_expires_after_set_days(): void
{
$workspace = Workspace::factory()->create();
@ -115,7 +131,8 @@ class WorkspaceInvitationTest extends TestCase
$second = $workspace->invite('test@example.com', 'admin', $owner);
$this->assertEquals($first->id, $second->id);
$this->assertEquals($firstToken, $second->token); // Token unchanged
// Token should change when re-inviting (new token generated and hashed)
$this->assertNotEquals($firstToken, $second->fresh()->token);
$this->assertEquals('admin', $second->role);
// Should only have one invitation
@ -127,17 +144,63 @@ class WorkspaceInvitationTest extends TestCase
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$invitation = WorkspaceInvitation::factory()->create([
'workspace_id' => $workspace->id,
'role' => 'member',
]);
// Use a known plaintext token
$plaintextToken = 'test-plaintext-token-for-acceptance-testing-1234567890';
$result = Workspace::acceptInvitation($invitation->token, $user);
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
'role' => 'member',
]);
// Accept using the plaintext token (model stores hashed version)
$result = Workspace::acceptInvitation($plaintextToken, $user);
$this->assertTrue($result);
$this->assertTrue($workspace->users()->where('user_id', $user->id)->exists());
}
public function test_find_by_token_uses_hash_check(): void
{
$workspace = Workspace::factory()->create();
$plaintextToken = 'my-secret-plaintext-token-for-testing-hash-lookup';
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
]);
// findByToken should find the invitation using the plaintext token
$found = WorkspaceInvitation::findByToken($plaintextToken);
$this->assertNotNull($found);
$this->assertEquals($invitation->id, $found->id);
// Token in database should be hashed
$this->assertTrue(str_starts_with($found->token, '$2y$'));
// Hash::check should verify the plaintext against the stored hash
$this->assertTrue(Hash::check($plaintextToken, $found->token));
}
public function test_verify_token_method(): void
{
$workspace = Workspace::factory()->create();
$plaintextToken = 'plaintext-token-for-verify-method-test';
$invitation = WorkspaceInvitation::factory()
->withPlaintextToken($plaintextToken)
->create([
'workspace_id' => $workspace->id,
]);
$this->assertTrue($invitation->verifyToken($plaintextToken));
$this->assertFalse($invitation->verifyToken('wrong-token'));
}
public function test_static_accept_with_invalid_token_returns_false(): void
{
$user = User::factory()->create();