- 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>
11 KiB
| title | description | updated |
|---|---|---|
| Architecture | Technical architecture of the core-tenant multi-tenancy package | 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 identifierdomain- Optional custom domainsettings- JSON configuration blobstripe_customer_id/btcpay_customer_id- Billing integration
Relationships:
users()- Members via pivot tableworkspacePackages()- Active entitlement packagesboosts()- Temporary limit increasesnamespaces()- Owned namespaces (polymorphic)
Namespace
The Namespace_ model provides a universal product boundary. Products belong to namespaces rather than directly to users/workspaces.
Ownership Patterns:
- User-owned: Individual creator with personal namespace
- Workspace-owned: Agency managing client namespaces
- User with workspace billing: Personal namespace but billed to workspace
Entitlement Cascade:
- Check namespace-level packages first
- Fall back to workspace pool (if namespace has workspace_id)
- Fall back to user tier (for user-owned namespaces)
BelongsToWorkspace Trait
Models that are workspace-scoped should use the BelongsToWorkspace trait:
class Account extends Model
{
use BelongsToWorkspace;
}
Security Features:
- Auto-assigns
workspace_idon 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:
$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:
$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:
$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:
$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:
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):
- Request attribute
workspace_model Workspace::current()(session/auth)- Request input
workspace_id - Header
X-Workspace-ID - Query param
workspace
CheckWorkspacePermission
Checks user has specific permissions:
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:
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:
$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:
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:
// config/core.php
return [
'workspace_cache' => [
'enabled' => true,
'ttl' => 300,
'prefix' => 'workspace_cache',
'use_tags' => true,
],
];
Testing
Tests are in tests/Feature/ using Pest:
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 logicWorkspaceSecurityTest.php- Tenant isolationWorkspaceCacheTest.php- Caching behaviourTwoFactorAuthenticatableTest.php- 2FA flows