Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,49 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.10.0] — 2026-03-16

### Added

**Space Management & Configuration** ([Issue #45](https://github.com/byte5digital/numen/issues/45))

Multi-tenant space management enabling isolated configurations, settings, and API credentials per space. Each space is a completely isolated instance with separate content, personas, pipelines, users, and API configurations.

**Features:**
- **Multi-tenancy:** Each space is a standalone instance with complete data isolation
- **Encrypted API config:** Store provider credentials (API keys, tokens) per-space with transparent encryption at rest using Laravel's `Crypt` facade
- **Space switching:** Admins can switch between spaces from the admin navigation bar; current space is tracked via `X-Space-Id` header in admin requests
- **Request-scoped space:** `ResolveCurrentSpace` middleware automatically determines active space from header (admins) or defaults to first/primary space (normal users)
- **Data isolation:** All content, briefs, pipelines, personas, webhooks, and settings queries automatically scope to the current space
- **Robust deletion:** Prevents deletion of the last space (blocking at DB level); uses database transactions + `lockForUpdate()` to prevent TOCTOU race conditions
- **Admin UI:** Full CRUD interface at `/admin/spaces` with Create, Read, Update, Delete operations
- **Security hardening:** `EnsureUserIsAdmin` middleware guards all `/admin/*` routes unconditionally; API config values are masked in edit forms (decrypted only on write)

**Migration:**
- New `spaces` table with ULID primary key, name, slug, description, default_locale, settings (JSON), api_config (encrypted), created_at, updated_at
- All tenant-scoped tables (content, personas, pipelines, etc.) have `space_id` foreign key

**Admin Routes:**
- `GET /admin/spaces` — List all spaces
- `GET /admin/spaces/create` — Show create form
- `POST /admin/spaces` — Store new space
- `GET /admin/spaces/{space}/edit` — Show edit form
- `PATCH /admin/spaces/{space}` — Update space
- `DELETE /admin/spaces/{space}` — Delete space (blocked if only one exists)
- `POST /admin/spaces/switch` — Switch current space (header-based)

**Environment variables:**
- `SPACE_DEFAULT_LOCALE` — Default locale for new spaces (default: `en`)

**Security notes:**
- API config encrypted at rest; decryption happens only in accessor, never exposed in API responses
- `X-Space-Id` header is admin-only; normal users cannot override their assigned space
- Null space detection in middleware aborts with HTTP 503 (Service Unavailable)
- Delete-last-space prevention uses database-level locking to prevent race conditions

**Breaking changes:** None.

---
## [0.9.0] — 2026-03-15

### Added
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ Manage webhook endpoints and event subscriptions directly from the admin panel (
### Plugin & Extension System
First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package.

### Space Management & Configuration
**New in v0.10.0.** Multi-tenant space management with isolated configurations, settings, and API credentials per space.
- **Multi-tenancy:** Each space is a separate instance with its own content, personas, pipelines, and users
- **Encrypted API config:** Store provider credentials (API keys) per-space with transparent encryption at rest
- **Space switching:** Admins can switch between spaces from the admin nav; sessions are scoped to the current space
- **Isolated data:** Content, briefs, pipelines, and users belong to a single space; queries automatically filter by the active space
- **Robust deletion:** Cannot delete the last space; uses database transactions + row-level locks to prevent TOCTOU race conditions
- **Admin UI:** Full CRUD interface at `/admin/spaces` for managing spaces, their settings, and configurations
- **Middleware hardening:** `EnsureUserIsAdmin` guards all `/admin/*` routes; `ResolveCurrentSpace` enforces space isolation in requests

### Plugin & Extension System
First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package.

Expand Down
5 changes: 2 additions & 3 deletions app/Http/Controllers/Admin/BriefAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\ContentPipeline;
use App\Models\ContentType;
use App\Models\Persona;
use App\Models\Space;
use App\Pipelines\PipelineExecutor;
use Illuminate\Http\Request;
use Inertia\Inertia;
Expand All @@ -25,9 +24,9 @@ public function index()
]);
}

public function create()
public function create(\Illuminate\Http\Request $request)
{
$space = Space::first();
$space = $request->space();

return Inertia::render('Briefs/Create', [
'contentTypes' => ContentType::where('space_id', $space?->id)->get(['name', 'slug']),
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use App\Http\Controllers\Controller;
use App\Models\Content;
use App\Models\PipelineRun;
use App\Models\Space;
use App\Services\AI\Providers\AnthropicProvider;
use App\Services\AI\Providers\AzureOpenAIProvider;
use App\Services\AI\Providers\OpenAIProvider;
Expand All @@ -19,6 +18,7 @@ public function index(
AnthropicProvider $anthropic,
OpenAIProvider $openai,
AzureOpenAIProvider $azure,
\Illuminate\Http\Request $request,
) {
$defaultProvider = config('numen.default_provider', 'anthropic');
$fallbackChain = config('numen.fallback_chain', ['anthropic', 'openai', 'azure']);
Expand Down Expand Up @@ -90,7 +90,7 @@ public function index(
'costToday' => $costTracker->getDailySpend(),
'providers' => $providers,
'fallbackChain' => $fallbackChain,
'defaultSpaceId' => (Space::first() !== null ? Space::first()->id : ''),
'defaultSpaceId' => ($request->space() !== null ? $request->space()->id : ''),
]);
}
}
5 changes: 3 additions & 2 deletions app/Http/Controllers/Admin/GraphController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

use App\Http\Controllers\Controller;
use App\Models\Space;
use Illuminate\Http\Request;
use Inertia\Inertia;

class GraphController extends Controller
{
public function index(): \Inertia\Response
public function index(Request $request): \Inertia\Response
{
/** @var Space|null $firstSpace */
$firstSpace = Space::first();
$firstSpace = $request->space();
$spaceId = $firstSpace !== null ? $firstSpace->id : '';

return Inertia::render('Graph/Index', [
Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/Admin/LocaleAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Space;
use App\Services\LocaleService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class LocaleAdminController extends Controller
{
public function __construct(private readonly LocaleService $localeService) {}

public function index(): Response
public function index(Request $request): Response
{
$space = Space::first();
$space = $request->space();
$locales = $space ? $this->localeService->getLocalesForSpace($space) : collect();

$supportedRaw = $this->localeService->getSupportedLocales();
Expand Down
3 changes: 1 addition & 2 deletions app/Http/Controllers/Admin/PageAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Models\ContentPipeline;
use App\Models\Page;
use App\Models\PageComponent;
use App\Models\Space;
use App\Pipelines\PipelineExecutor;
use Illuminate\Http\Request;
use Inertia\Inertia;
Expand Down Expand Up @@ -136,7 +135,7 @@ public function generateComponent(Request $request, string $id, string $componen
{
$component = PageComponent::where('page_id', $id)->findOrFail($componentId);
$page = $component->page;
$space = Space::first();
$space = $request->space();

$validated = $request->validate([
'brief_description' => 'required|string|max:2000',
Expand Down
146 changes: 146 additions & 0 deletions app/Http/Controllers/Admin/SpaceAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Space;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;

class SpaceAdminController extends Controller
{
/**
* List all spaces, marking the current active space.
*/
public function index(Request $request): Response
{
$currentSpace = $request->attributes->get('space');

$spaces = Space::orderBy('created_at', 'asc')
->get()
->map(fn (Space $space) => [
'id' => $space->id,
'name' => $space->name,
'slug' => $space->slug,
'description' => $space->description,
'default_locale' => $space->default_locale,
'created_at' => $space->created_at->toIso8601String(),
'is_current' => $currentSpace && $currentSpace->id === $space->id,
]);

return Inertia::render('Admin/Spaces/Index', [
'spaces' => $spaces,
]);
}

/**
* Show the form for creating a new space.
*/
public function create(): Response
{
return Inertia::render('Admin/Spaces/Create');
}

/**
* Store a newly created space.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')],
'description' => ['nullable', 'string', 'max:1000'],
'default_locale' => ['required', 'string', 'max:10'],
]);

Space::create($validated);

return redirect()->route('admin.spaces.index')
->with('success', 'Space created successfully.');
}

/**
* Show the form for editing the specified space.
*
* api_config values are masked (keys preserved, values replaced with '***')
* to prevent sensitive credentials from being exposed to the browser.
*/
public function edit(Space $space): Response
{
$apiConfig = $space->api_config;
$maskedApiConfig = null;

if (is_array($apiConfig)) {
$maskedApiConfig = array_map(fn () => '***', $apiConfig);
}

return Inertia::render('Admin/Spaces/Edit', [
'space' => [
'id' => $space->id,
'name' => $space->name,
'slug' => $space->slug,
'description' => $space->description,
'default_locale' => $space->default_locale,
'settings' => $space->settings,
'api_config' => $maskedApiConfig,
],
]);
}

/**
* Update the specified space.
*/
public function update(Request $request, Space $space): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('spaces', 'slug')->ignore($space->id)],
'description' => ['nullable', 'string', 'max:1000'],
'default_locale' => ['required', 'string', 'max:10'],
'settings' => ['nullable', 'array'],
'api_config' => ['nullable', 'array'],
]);

// If api_config was submitted as all-masked values (all '***'), don't overwrite stored secrets
if (isset($validated['api_config']) && is_array($validated['api_config'])) {
$allMasked = collect($validated['api_config'])->every(fn ($v) => $v === '***');
if ($allMasked) {
unset($validated['api_config']);
}
}

$space->update($validated);

return redirect()->route('admin.spaces.index')
->with('success', 'Space updated successfully.');
}

/**
* Delete the specified space, blocking if it is the last one.
* Uses a database transaction with a row lock to prevent TOCTOU race conditions.
*/
public function destroy(string $id): RedirectResponse
{
$earlyReturn = null;

DB::transaction(function () use ($id, &$earlyReturn): void {
$space = Space::lockForUpdate()->findOrFail($id);

if (Space::count() <= 1) {
$earlyReturn = redirect()->route('admin.spaces.index')
->with('error', 'Cannot delete the last space.');

return;
}

$space->delete();
});

return $earlyReturn ?? redirect()->route('admin.spaces.index')
->with('success', 'Space deleted successfully.');
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Admin/SpaceSwitcherController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Space;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class SpaceSwitcherController extends Controller
{
/**
* Switch the active space for the current session.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'space_id' => ['required', 'string', 'exists:spaces,id'],
]);

session(['current_space_id' => $validated['space_id']]);

return redirect()->back();
}
}
4 changes: 2 additions & 2 deletions app/Http/Controllers/Admin/TaxonomyAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ public function __construct(private readonly TaxonomyService $taxonomy) {}
/**
* List all vocabularies for the default space.
*/
public function index(): Response
public function index(\Illuminate\Http\Request $request): Response
{
$space = Space::first();
$space = $request->space();

$vocabularies = $space
? Vocabulary::forSpace($space->id)->withCount('terms')->ordered()->get()
Expand Down
16 changes: 2 additions & 14 deletions app/Http/Controllers/Admin/WebhookAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,7 @@ public function index(Request $request): Response
*/
public function store(Request $request): RedirectResponse
{
// @phpstan-ignore-next-line
$spaceId = $request->user()->roles()->first()?->pivot->space_id;

if (! $spaceId) {
abort(403, 'No accessible space found.');
}
$spaceId = $request->space()?->id ?? abort(403, 'No space context.');

$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

Expand Down Expand Up @@ -196,14 +191,7 @@ public function redeliver(Request $request, string $id, string $deliveryId): Jso
*/
private function resolveSpaceId(Request $request): string
{
// @phpstan-ignore-next-line
$spaceId = $request->user()->roles()->first()?->pivot->space_id;

if (! $spaceId) {
abort(403, 'No accessible space found.');
}

return $spaceId;
return $request->space()?->id ?? abort(403, 'No space context.');
}

/**
Expand Down
Loading