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 memberEntitlementService
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 nameKey test files:
EntitlementServiceTest.php- Core entitlement logicWorkspaceSecurityTest.php- Tenant isolationWorkspaceCacheTest.php- Caching behaviourTwoFactorAuthenticatableTest.php- 2FA flows