Add Super-Admin tools for managing any model

- 🛠️ Introduced generic Super-Admin MCP tools, including `list-models`, `describe-model`, `list-records`, `show-record`, `create-record`, and `update-record`.
- 🛡️ Restricted modification of critical fields (e.g., passwords, roles, tokens) to enhance security.
-  Added extensive feature tests for Super-Admin functionality and access control.
- 📜 Increased pagination length to accommodate new tools on a single page.
- 🔗 Registered Super-Admin tools in `EinundzwanzigServer`.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-08 13:39:04 +02:00
parent 3a507cced2
commit 8c68b19138
14 changed files with 810 additions and 6 deletions
+11 -3
View File
@@ -211,20 +211,28 @@ final class LnurlAuthController extends Controller
// Auth::login() calls Session::migrate(destroy: true) internally,
// which wipes the previous session payload. Capture lang_country
// before the login and restore it on the fresh session so the
// dashboard URL keeps the user's chosen locale.
// and the post-login intended URL before the login and restore them
// on the fresh session. The intended URL lets the OAuth2 flow resume:
// a guest who clicked "Connect" in an MCP client is bounced to login
// and, after logging in, is sent back to /oauth/authorize instead of
// landing on the dashboard.
$langCountry = session('lang_country', config('app.domain_country'));
$intendedUrl = session('url.intended');
Auth::login($user);
session(['lang_country' => $langCountry]);
if ($intendedUrl !== null) {
session(['url.intended' => $intendedUrl]);
}
$country = str($langCountry)
->after('-')
->lower()
->value();
return redirect()->route('dashboard', ['country' => $country]);
return redirect()->intended(route('dashboard', ['country' => $country]));
}
/**
+25 -2
View File
@@ -30,6 +30,12 @@ use App\Mcp\Tools\Search\SearchCoursesTool;
use App\Mcp\Tools\Search\SearchLecturersTool;
use App\Mcp\Tools\Search\SearchMeetupsTool;
use App\Mcp\Tools\Search\SearchVenuesTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminCreateRecordTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminDescribeModelTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminListModelsTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminListRecordsTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminShowRecordTool;
use App\Mcp\Tools\SuperAdmin\SuperAdminUpdateRecordTool;
use App\Mcp\Tools\Venue\CreateVenueTool;
use App\Mcp\Tools\Venue\ListMyVenuesTool;
use App\Mcp\Tools\Venue\ShowMyVenueTool;
@@ -72,6 +78,14 @@ die ID des gewählten Eintrags übergeben ebenfalls ohne den Nutzer nach der
Die Tools lösen Namen serverseitig auf. Bei Mehrdeutigkeit oder fehlendem Treffer liefern sie
eine Liste der passenden Einträge zurück diese dem Nutzer zur Auswahl anbieten. Die *_id-
Parameter sind nur ein optionaler Fallback, falls die ID bereits bekannt ist.
Super-Admins sehen zusätzlich generische super-admin-* Tools, mit denen JEDES Model bearbeitet
werden kann (ohne Ownership-Beschränkung). Vorgehen: erst super-admin-list-models, dann
super-admin-describe-model (für die Felder), dann super-admin-list-records / -show-record zum
Finden und schließlich super-admin-create-record / -update-record zum Bearbeiten.
Sicherheitskritische Felder (Passwörter, Auth-Tokens, Rollen) lassen sich über diese Tools
NICHT setzen Rollen und Passwörter werden ausschließlich über die dafür vorgesehenen Wege
verwaltet.
TXT)]
class EinundzwanzigServer extends Server
{
@@ -81,9 +95,9 @@ class EinundzwanzigServer extends Server
* nextCursor nicht dann fehlt die Hälfte der Tools. Wir heben die Seitengröße an,
* sodass alle Tools auf eine Seite passen.
*/
public int $maxPaginationLength = 100;
public int $maxPaginationLength = 1000;
public int $defaultPaginationLength = 100;
public int $defaultPaginationLength = 1000;
/**
* The tools registered with this MCP server.
@@ -138,5 +152,14 @@ class EinundzwanzigServer extends Server
SearchLecturersTool::class,
SearchCoursesTool::class,
ListCountriesTool::class,
// Super-Admin: generische Tools für ALLE Models (nur für Super-Admins sichtbar,
// via shouldRegister; created_by/Ownership-Beschränkungen entfallen hier bewusst).
SuperAdminListModelsTool::class,
SuperAdminDescribeModelTool::class,
SuperAdminListRecordsTool::class,
SuperAdminShowRecordTool::class,
SuperAdminCreateRecordTool::class,
SuperAdminUpdateRecordTool::class,
];
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Mcp\Support;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use ReflectionClass;
/**
* Registry aller bearbeitbaren Eloquent-Models unter App\Models. Dient den generischen
* Super-Admin-MCP-Tools, damit ein Super-Admin jede Entität per Name ansprechen kann
* ohne pro Model ein eigenes Tool zu pflegen.
*/
class SuperAdminModels
{
/**
* Attribute, die niemals über die generischen Super-Admin-Tools geschrieben werden
* dürfen: Passwörter & Rollen, Auth-Credentials/-Tokens (remember_token, two_factor_*,
* OAuth token/refresh_token/secret, lnurl-auth k1), die E-Mail-Verifizierung
* (email_verified_at) sowie der Nostr-Pubkey (Identitäts-Spoofing). lnurl und
* reputation bleiben bewusst editierbar.
*
* @var list<string>
*/
public const PROTECTED_ATTRIBUTES = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
'role',
'roles',
'token',
'refresh_token',
'secret',
'k1',
'email_verified_at',
'nostr',
];
/**
* Kanonische Map Modelklasse Tabellenname. Einmal pro Prozess ermittelt.
*
* @var array<class-string<Model>, string>|null
*/
private static ?array $models = null;
/**
* Normalisierter Name (Kurzname ODER Tabelle) Modelklasse. Lazy aus $models gebaut.
*
* @var array<string, class-string<Model>>|null
*/
private static ?array $index = null;
/**
* Liste aller bearbeitbaren Models.
*
* @return array<int, array{key: string, class: class-string<Model>, table: string}>
*/
public static function list(): array
{
$models = [];
foreach (self::models() as $class => $table) {
$models[] = [
'key' => Str::kebab(class_basename($class)),
'class' => $class,
'table' => $table,
];
}
usort($models, fn (array $a, array $b): int => $a['key'] <=> $b['key']);
return $models;
}
/**
* Löst einen Model-Namen (Kurzname, FQCN, kebab-case oder Tabellenname) zur Klasse auf.
*
* @return class-string<Model>|null
*/
public static function resolve(?string $name): ?string
{
if (! is_string($name) || trim($name) === '') {
return null;
}
return self::index()[self::normalize(Str::of($name)->afterLast('\\')->value())] ?? null;
}
/**
* Verfügbare Model-Keys (kebab-case) als Auswahlhilfe.
*
* @return array<int, string>
*/
public static function keys(): array
{
return array_map(fn (array $m): string => $m['key'], self::list());
}
/**
* Ermittelt einmalig alle ladbaren, konkreten Models unter App\Models samt Tabellenname.
*
* @return array<class-string<Model>, string>
*/
private static function models(): array
{
if (self::$models !== null) {
return self::$models;
}
$models = [];
foreach (glob(app_path('Models').'/*.php') ?: [] as $file) {
$class = 'App\\Models\\'.basename($file, '.php');
try {
// Manche Model-Stubs erben von nicht (mehr) installierten Vendor-Klassen
// (z. B. Jetstream) und lassen sich nicht laden diese überspringen wir.
if (! class_exists($class) || ! is_subclass_of($class, Model::class)) {
continue;
}
if ((new ReflectionClass($class))->isAbstract()) {
continue;
}
$models[$class] = (new $class)->getTable();
} catch (\Throwable) {
continue;
}
}
return self::$models = $models;
}
/**
* Baut den Auflösungs-Index: Kurzname UND Tabellenname (jeweils normalisiert) Klasse.
*
* @return array<string, class-string<Model>>
*/
private static function index(): array
{
if (self::$index !== null) {
return self::$index;
}
$index = [];
foreach (self::models() as $class => $table) {
$index[self::normalize(class_basename($class))] = $class;
$index[self::normalize($table)] = $class;
}
return self::$index = $index;
}
private static function normalize(string $value): string
{
return Str::of($value)->lower()->replace(['-', '_', ' '], '')->value();
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Mcp\Tools\SuperAdmin\Concerns;
use App\Mcp\Support\SuperAdminModels;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
/**
* Gemeinsame Autorisierung für die generischen Super-Admin-Tools: Sie werden nur für
* angemeldete Super-Admins registriert (shouldRegister) und prüfen die Rolle zusätzlich
* in handle() (Defense in Depth).
*/
trait AuthorizesSuperAdmin
{
/**
* Tool nur registrieren, wenn der verbundene Nutzer ein Super-Admin ist andere
* Nutzer sehen die Super-Admin-Tools gar nicht erst.
*/
public function shouldRegister(Request $request): bool
{
return $this->superAdmin($request) !== null;
}
private function superAdmin(?Request $request): ?User
{
$user = $request?->user();
return $user instanceof User && $user->hasRole('super-admin') ? $user : null;
}
private function denyUnlessSuperAdmin(Request $request): ?Response
{
return $this->superAdmin($request) === null
? Response::error('Diese Funktion ist nur für Super-Admins verfügbar.')
: null;
}
/**
* Löst den 'model'-Parameter zur Eloquent-Klasse auf oder liefert eine Auswahlliste.
*
* @return class-string<Model>|Response
*/
private function resolveModel(Request $request): string|Response
{
$class = SuperAdminModels::resolve($request->get('model'));
if ($class === null) {
return Response::error(
'Unbekanntes Model: "'.$request->get('model').'". Verfügbar: '
.implode(', ', SuperAdminModels::keys()).'.'
);
}
return $class;
}
/**
* Lehnt das Setzen sicherheitskritischer Felder (Passwörter, Auth-Tokens, Rollen)
* über die generischen Super-Admin-Tools ab.
*
* @param array<string, mixed> $attributes
*/
private function rejectProtectedAttributes(array $attributes): ?Response
{
$blocked = array_values(array_filter(
array_keys($attributes),
fn (string $key): bool => in_array(strtolower($key), SuperAdminModels::PROTECTED_ATTRIBUTES, true)
));
if ($blocked !== []) {
return Response::error(
'Diese Felder können nicht über die Super-Admin-Tools geändert werden: '
.implode(', ', $blocked).'. Passwörter und Rollen werden über die dafür '
.'vorgesehenen Wege verwaltet.'
);
}
return null;
}
/**
* Gemeinsamer "model"-Parameter für die Super-Admin-Tools.
*/
private function modelParameter(JsonSchema $schema): Type
{
return $schema->string()
->description('Model-Name, Kurzform oder Tabelle (z. B. "meetup", "Meetup" oder "meetups"). Siehe super-admin-list-models.')
->required();
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Throwable;
#[Description('NUR SUPER-ADMIN: Legt einen Datensatz für ein beliebiges Model an. Die Felder werden als "attributes"-Objekt übergeben (Mass-Assignment-Schutz wird bewusst umgangen). Vorher per super-admin-describe-model die Pflichtfelder prüfen.')]
class SuperAdminCreateRecordTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
$class = $this->resolveModel($request);
if ($class instanceof Response) {
return $class;
}
$attributes = (array) ($request->get('attributes') ?? []);
if ($attributes === []) {
return Response::error('Bitte "attributes" mit den zu setzenden Feldern angeben.');
}
if ($blocked = $this->rejectProtectedAttributes($attributes)) {
return $blocked;
}
try {
/** @var Model $record */
$record = new $class;
$record->forceFill($attributes)->save();
} catch (Throwable $e) {
return Response::error('Anlegen fehlgeschlagen: '.$e->getMessage());
}
return Response::json($record->fresh()->toArray());
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'model' => $this->modelParameter($schema),
'attributes' => $schema->object()->description('Objekt {spalte: wert} mit den zu setzenden Feldern.')->required(),
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Facades\Schema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('NUR SUPER-ADMIN: Beschreibt ein Model: Spalten (Name, Typ, nullable, Default), Primärschlüssel und Casts. So weißt du, welche Felder du bei super-admin-create-record / super-admin-update-record setzen kannst.')]
class SuperAdminDescribeModelTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
$class = $this->resolveModel($request);
if ($class instanceof Response) {
return $class;
}
/** @var Model $model */
$model = new $class;
$table = $model->getTable();
$columns = collect(Schema::getColumns($table))->map(fn (array $column): array => [
'name' => $column['name'],
'type' => $column['type_name'] ?? $column['type'] ?? null,
'nullable' => $column['nullable'] ?? null,
'default' => $column['default'] ?? null,
])->values();
return Response::json([
'model' => class_basename($class),
'class' => $class,
'table' => $table,
'primary_key' => $model->getKeyName(),
'columns' => $columns,
'casts' => $model->getCasts(),
]);
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'model' => $this->modelParameter($schema),
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Support\SuperAdminModels;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('NUR SUPER-ADMIN: Listet alle bearbeitbaren Models (key, Klasse, Tabelle). Ausgangspunkt, um anschließend per super-admin-describe-model die Felder zu sehen und Datensätze zu bearbeiten.')]
class SuperAdminListModelsTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
return Response::json(SuperAdminModels::list());
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Facades\Schema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('NUR SUPER-ADMIN: Listet Datensätze eines Models (neueste zuerst), optional gefiltert nach exakten Spaltenwerten. Zum Finden der zu bearbeitenden Datensätze und ihrer IDs.')]
class SuperAdminListRecordsTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
$class = $this->resolveModel($request);
if ($class instanceof Response) {
return $class;
}
/** @var Model $model */
$model = new $class;
$columns = Schema::getColumnListing($model->getTable());
$filters = collect((array) ($request->get('filters') ?? []))
->only($columns);
$limit = max(1, min(100, (int) ($request->get('limit') ?? 25)));
$records = $class::query()
->where($filters->all())
->latest($model->getKeyName())
->limit($limit)
->get();
return Response::json([
'model' => class_basename($class),
'count' => $records->count(),
'records' => $records->map->toArray()->all(),
]);
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'model' => $this->modelParameter($schema),
'filters' => $schema->object()->description('Optionale exakte Filter als Objekt {spalte: wert}. Unbekannte Spalten werden ignoriert.'),
'limit' => $schema->integer()->description('Maximale Anzahl Datensätze (1100, Default 25).'),
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('NUR SUPER-ADMIN: Zeigt einen einzelnen Datensatz eines beliebigen Models per ID (alle Attribute).')]
class SuperAdminShowRecordTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
$class = $this->resolveModel($request);
if ($class instanceof Response) {
return $class;
}
$record = $class::query()->find($request->get('id'));
if ($record === null) {
return Response::error('Datensatz mit ID '.$request->get('id').' in '.class_basename($class).' nicht gefunden.');
}
return Response::json($record->toArray());
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'model' => $this->modelParameter($schema),
'id' => $schema->integer()->description('Primärschlüssel des Datensatzes.')->required(),
];
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Mcp\Tools\SuperAdmin;
use App\Mcp\Tools\SuperAdmin\Concerns\AuthorizesSuperAdmin;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Throwable;
#[Description('NUR SUPER-ADMIN: Aktualisiert einen Datensatz eines beliebigen Models per ID. Die zu ändernden Felder werden als "attributes"-Objekt übergeben (Mass-Assignment-Schutz wird bewusst umgangen).')]
class SuperAdminUpdateRecordTool extends Tool
{
use AuthorizesSuperAdmin;
public function handle(Request $request): Response
{
if ($denied = $this->denyUnlessSuperAdmin($request)) {
return $denied;
}
$class = $this->resolveModel($request);
if ($class instanceof Response) {
return $class;
}
$record = $class::query()->find($request->get('id'));
if ($record === null) {
return Response::error('Datensatz mit ID '.$request->get('id').' in '.class_basename($class).' nicht gefunden.');
}
$attributes = (array) ($request->get('attributes') ?? []);
if ($attributes === []) {
return Response::error('Bitte "attributes" mit den zu ändernden Feldern angeben.');
}
if ($blocked = $this->rejectProtectedAttributes($attributes)) {
return $blocked;
}
try {
$record->forceFill($attributes)->save();
} catch (Throwable $e) {
return Response::error('Aktualisieren fehlgeschlagen: '.$e->getMessage());
}
return Response::json($record->fresh()->toArray());
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'model' => $this->modelParameter($schema),
'id' => $schema->integer()->description('Primärschlüssel des zu ändernden Datensatzes.')->required(),
'attributes' => $schema->object()->description('Objekt {spalte: wert} mit den zu ändernden Feldern.')->required(),
];
}
}