Skip to content

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:

TypeDescriptionExample
booleanOn/off accessBeta features
limitNumeric limit with usage tracking100 AI credits/month
unlimitedNo limitUnlimited social accounts

Reset Types

TypeDescription
noneNo reset (cumulative)
monthlyResets at billing cycle start
rollingRolling 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 TypeDescription
add_limitAdds to existing limit
enableEnables a boolean feature
unlimitedMakes feature unlimited
Duration TypeDescription
cycle_boundExpires at billing cycle end
durationExpires after set period
permanentNever 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:

EventTrigger
limit_warningUsage at 80% or 90%
limit_reachedUsage at 100%
package_changedPackage add/change/remove
boost_activatedBoost provisioned
boost_expiredBoost 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

TablePurpose
usersUser accounts
workspacesTenant organisations
user_workspaceUser-workspace pivot
namespacesProduct boundaries

Entitlement Tables

TablePurpose
entitlement_featuresFeature definitions
entitlement_packagesPackage definitions
entitlement_package_featuresPackage-feature pivot
entitlement_workspace_packagesWorkspace package assignments
entitlement_namespace_packagesNamespace package assignments
entitlement_boostsActive boosts
entitlement_usage_recordsUsage tracking
entitlement_logsAudit log

Team Tables

TablePurpose
workspace_teamsTeam definitions
workspace_invitationsPending 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

Released under the EUPL-1.2 License.