php-tenant/docs/architecture.md
Snider a35cbc9306 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>
2026-01-29 12:20:53 +00:00

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 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:

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:

$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):

  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:

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 logic
  • WorkspaceSecurityTest.php - Tenant isolation
  • WorkspaceCacheTest.php - Caching behaviour
  • TwoFactorAuthenticatableTest.php - 2FA flows