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:
parent
8be7516d3a
commit
a35cbc9306
15 changed files with 2797 additions and 23 deletions
4
Boot.php
4
Boot.php
|
|
@ -175,5 +175,9 @@ class Boot extends ServiceProvider
|
||||||
$event->command(Console\Commands\ProcessAccountDeletions::class);
|
$event->command(Console\Commands\ProcessAccountDeletions::class);
|
||||||
$event->command(Console\Commands\CheckUsageAlerts::class);
|
$event->command(Console\Commands\CheckUsageAlerts::class);
|
||||||
$event->command(Console\Commands\ResetBillingCycles::class);
|
$event->command(Console\Commands\ResetBillingCycles::class);
|
||||||
|
|
||||||
|
// Security migration commands
|
||||||
|
$event->command(Console\Commands\EncryptTwoFactorSecrets::class);
|
||||||
|
$event->command(Console\Commands\HashInvitationTokens::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
Console/Commands/EncryptTwoFactorSecrets.php
Normal file
149
Console/Commands/EncryptTwoFactorSecrets.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
Console/Commands/HashInvitationTokens.php
Normal file
179
Console/Commands/HashInvitationTokens.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ namespace Core\Tenant\Database\Factories;
|
||||||
|
|
||||||
use Core\Tenant\Models\WorkspaceInvitation;
|
use Core\Tenant\Models\WorkspaceInvitation;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,6 +16,13 @@ class WorkspaceInvitationFactory extends Factory
|
||||||
{
|
{
|
||||||
protected $model = WorkspaceInvitation::class;
|
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.
|
* Define the model's default state.
|
||||||
*
|
*
|
||||||
|
|
@ -22,9 +30,13 @@ class WorkspaceInvitationFactory extends Factory
|
||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
// Store the plaintext token so tests can access it if needed
|
||||||
|
static::$lastPlaintextToken = Str::random(64);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'token' => Str::random(64),
|
// Token will be hashed by the model's creating event
|
||||||
|
'token' => static::$lastPlaintextToken,
|
||||||
'role' => 'member',
|
'role' => 'member',
|
||||||
'invited_by' => null,
|
'invited_by' => null,
|
||||||
'expires_at' => now()->addDays(7),
|
'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.
|
* Indicate the invitation has been accepted.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,52 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
* User two-factor authentication record.
|
* User two-factor authentication record.
|
||||||
*
|
*
|
||||||
* Stores TOTP secrets and recovery codes for 2FA.
|
* 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
|
class UserTwoFactorAuth extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'user_two_factor_auth';
|
protected $table = 'user_two_factor_auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fillable attributes.
|
||||||
|
*
|
||||||
|
* Note: secret_key is an alias for the 'secret' column, handled by mutator.
|
||||||
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'secret_key',
|
'secret',
|
||||||
|
'secret_key', // Alias handled by setSecretKeyAttribute
|
||||||
'recovery_codes',
|
'recovery_codes',
|
||||||
'confirmed_at',
|
'confirmed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'recovery_codes' => 'collection',
|
'secret' => 'encrypted',
|
||||||
|
'recovery_codes' => 'encrypted:collection',
|
||||||
'confirmed_at' => 'datetime',
|
'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.
|
* Get the user this 2FA belongs to.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -660,27 +660,38 @@ class Workspace extends Model
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Update existing invitation
|
// Update existing invitation (keep existing hashed token)
|
||||||
$existing->update([
|
$existing->update([
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
'invited_by' => $invitedBy?->id,
|
'invited_by' => $invitedBy?->id,
|
||||||
'expires_at' => now()->addDays($expiresInDays),
|
'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;
|
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([
|
$invitation = $this->invitations()->create([
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'token' => WorkspaceInvitation::generateToken(),
|
'token' => $plaintextToken,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
'invited_by' => $invitedBy?->id,
|
'invited_by' => $invitedBy?->id,
|
||||||
'expires_at' => now()->addDays($expiresInDays),
|
'expires_at' => now()->addDays($expiresInDays),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Send notification
|
// Send notification with the plaintext token (not the hashed one)
|
||||||
$invitation->notify(new \Core\Tenant\Notifications\WorkspaceInvitationNotification($invitation));
|
$invitation->notify(new \Core\Tenant\Notifications\WorkspaceInvitationNotification($invitation, $plaintextToken));
|
||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class WorkspaceInvitation extends Model
|
class WorkspaceInvitation extends Model
|
||||||
|
|
@ -35,6 +36,22 @@ class WorkspaceInvitation extends Model
|
||||||
'accepted_at' => 'datetime',
|
'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.
|
* Get the workspace this invitation is for.
|
||||||
*/
|
*/
|
||||||
|
|
@ -103,30 +120,65 @@ class WorkspaceInvitation extends Model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique token for this invitation.
|
* Generate a unique token for this invitation.
|
||||||
|
*
|
||||||
|
* Returns the plaintext token. The token will be hashed when stored.
|
||||||
*/
|
*/
|
||||||
public static function generateToken(): string
|
public static function generateToken(): string
|
||||||
{
|
{
|
||||||
do {
|
// Generate a cryptographically secure random token
|
||||||
$token = Str::random(64);
|
// No need to check for uniqueness since hashed tokens are unique
|
||||||
} while (static::where('token', $token)->exists());
|
return Str::random(64);
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find invitation by token.
|
* 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
|
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.
|
* 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
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,15 @@ class WorkspaceInvitationNotification extends Notification implements ShouldQueu
|
||||||
{
|
{
|
||||||
use Queueable;
|
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(
|
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
|
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;
|
$workspaceName = $this->invitation->workspace->name;
|
||||||
$inviterName = $this->invitation->inviter?->name ?? 'A team member';
|
$inviterName = $this->invitation->inviter?->name ?? 'A team member';
|
||||||
$roleName = ucfirst($this->invitation->role);
|
$roleName = ucfirst($this->invitation->role);
|
||||||
|
|
|
||||||
542
TODO.md
Normal file
542
TODO.md
Normal 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
422
docs/architecture.md
Normal 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
465
docs/entitlements.md
Normal 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
309
docs/security.md
Normal 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
447
docs/teams-permissions.md
Normal 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
|
||||||
|
|
@ -331,4 +331,72 @@ describe('UserTwoFactorAuth Model', function () {
|
||||||
|
|
||||||
expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Core\Tenant\Tests\Feature;
|
namespace Core\Tenant\Tests\Feature;
|
||||||
|
|
||||||
|
use Core\Tenant\Database\Factories\WorkspaceInvitationFactory;
|
||||||
use Core\Tenant\Models\User;
|
use Core\Tenant\Models\User;
|
||||||
use Core\Tenant\Models\Workspace;
|
use Core\Tenant\Models\Workspace;
|
||||||
use Core\Tenant\Models\WorkspaceInvitation;
|
use Core\Tenant\Models\WorkspaceInvitation;
|
||||||
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
|
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -33,7 +35,9 @@ class WorkspaceInvitationTest extends TestCase
|
||||||
'invited_by' => $owner->id,
|
'invited_by' => $owner->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Token should be hashed (starts with $2y$)
|
||||||
$this->assertNotNull($invitation->token);
|
$this->assertNotNull($invitation->token);
|
||||||
|
$this->assertTrue(str_starts_with($invitation->token, '$2y$'));
|
||||||
$this->assertTrue($invitation->isPending());
|
$this->assertTrue($invitation->isPending());
|
||||||
$this->assertFalse($invitation->isExpired());
|
$this->assertFalse($invitation->isExpired());
|
||||||
$this->assertFalse($invitation->isAccepted());
|
$this->assertFalse($invitation->isAccepted());
|
||||||
|
|
@ -41,6 +45,18 @@ class WorkspaceInvitationTest extends TestCase
|
||||||
Notification::assertSentTo($invitation, WorkspaceInvitationNotification::class);
|
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
|
public function test_invitation_expires_after_set_days(): void
|
||||||
{
|
{
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
@ -115,7 +131,8 @@ class WorkspaceInvitationTest extends TestCase
|
||||||
$second = $workspace->invite('test@example.com', 'admin', $owner);
|
$second = $workspace->invite('test@example.com', 'admin', $owner);
|
||||||
|
|
||||||
$this->assertEquals($first->id, $second->id);
|
$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);
|
$this->assertEquals('admin', $second->role);
|
||||||
|
|
||||||
// Should only have one invitation
|
// Should only have one invitation
|
||||||
|
|
@ -127,17 +144,63 @@ class WorkspaceInvitationTest extends TestCase
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$invitation = WorkspaceInvitation::factory()->create([
|
// Use a known plaintext token
|
||||||
'workspace_id' => $workspace->id,
|
$plaintextToken = 'test-plaintext-token-for-acceptance-testing-1234567890';
|
||||||
'role' => 'member',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$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($result);
|
||||||
$this->assertTrue($workspace->users()->where('user_id', $user->id)->exists());
|
$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
|
public function test_static_accept_with_invalid_token_returns_false(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue