mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-11 02:50:29 +00:00
✨ 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:
@@ -211,20 +211,28 @@ final class LnurlAuthController extends Controller
|
|||||||
|
|
||||||
// Auth::login() calls Session::migrate(destroy: true) internally,
|
// Auth::login() calls Session::migrate(destroy: true) internally,
|
||||||
// which wipes the previous session payload. Capture lang_country
|
// which wipes the previous session payload. Capture lang_country
|
||||||
// before the login and restore it on the fresh session so the
|
// and the post-login intended URL before the login and restore them
|
||||||
// dashboard URL keeps the user's chosen locale.
|
// 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'));
|
$langCountry = session('lang_country', config('app.domain_country'));
|
||||||
|
$intendedUrl = session('url.intended');
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
|
|
||||||
session(['lang_country' => $langCountry]);
|
session(['lang_country' => $langCountry]);
|
||||||
|
|
||||||
|
if ($intendedUrl !== null) {
|
||||||
|
session(['url.intended' => $intendedUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
$country = str($langCountry)
|
$country = str($langCountry)
|
||||||
->after('-')
|
->after('-')
|
||||||
->lower()
|
->lower()
|
||||||
->value();
|
->value();
|
||||||
|
|
||||||
return redirect()->route('dashboard', ['country' => $country]);
|
return redirect()->intended(route('dashboard', ['country' => $country]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ use App\Mcp\Tools\Search\SearchCoursesTool;
|
|||||||
use App\Mcp\Tools\Search\SearchLecturersTool;
|
use App\Mcp\Tools\Search\SearchLecturersTool;
|
||||||
use App\Mcp\Tools\Search\SearchMeetupsTool;
|
use App\Mcp\Tools\Search\SearchMeetupsTool;
|
||||||
use App\Mcp\Tools\Search\SearchVenuesTool;
|
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\CreateVenueTool;
|
||||||
use App\Mcp\Tools\Venue\ListMyVenuesTool;
|
use App\Mcp\Tools\Venue\ListMyVenuesTool;
|
||||||
use App\Mcp\Tools\Venue\ShowMyVenueTool;
|
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
|
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-
|
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.
|
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)]
|
TXT)]
|
||||||
class EinundzwanzigServer extends Server
|
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,
|
* nextCursor nicht – dann fehlt die Hälfte der Tools. Wir heben die Seitengröße an,
|
||||||
* sodass alle Tools auf eine Seite passen.
|
* 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.
|
* The tools registered with this MCP server.
|
||||||
@@ -138,5 +152,14 @@ class EinundzwanzigServer extends Server
|
|||||||
SearchLecturersTool::class,
|
SearchLecturersTool::class,
|
||||||
SearchCoursesTool::class,
|
SearchCoursesTool::class,
|
||||||
ListCountriesTool::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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (1–100, 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,27 @@ it('completes a Lightning login and redirects to the dashboard when a recent Log
|
|||||||
$this->assertAuthenticatedAs($user);
|
$this->assertAuthenticatedAs($user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resumes the intended OAuth url after a Lightning login instead of going to the dashboard', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$k1 = bin2hex(random_bytes(32));
|
||||||
|
LoginKey::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'k1' => $k1,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$intended = url('/oauth/authorize?client_id=1&response_type=code&scope=mcp:use');
|
||||||
|
|
||||||
|
$response = $this->withSession([
|
||||||
|
'lang_country' => 'de-DE',
|
||||||
|
'locale' => 'de',
|
||||||
|
'url.intended' => $intended,
|
||||||
|
])->get(route('auth.ln.complete', ['k1' => $k1]));
|
||||||
|
|
||||||
|
$response->assertRedirect($intended);
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
it('redirects to login when the LoginKey is older than 5 minutes', function () {
|
it('redirects to login when the LoginKey is older than 5 minutes', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$k1 = bin2hex(random_bytes(32));
|
$k1 = bin2hex(random_bytes(32));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ it('registers every domain tool on the server', function () {
|
|||||||
$property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools');
|
$property = (new ReflectionClass(EinundzwanzigServer::class))->getProperty('tools');
|
||||||
$tools = $property->getDefaultValue();
|
$tools = $property->getDefaultValue();
|
||||||
|
|
||||||
expect($tools)->toHaveCount(32)
|
expect($tools)->toHaveCount(38)
|
||||||
->and($tools)->toContain(CreateMeetupTool::class)
|
->and($tools)->toContain(CreateMeetupTool::class)
|
||||||
->and($tools)->toContain(UpdateCourseEventTool::class)
|
->and($tools)->toContain(UpdateCourseEventTool::class)
|
||||||
->and($tools)->toContain(SearchCitiesTool::class);
|
->and($tools)->toContain(SearchCitiesTool::class);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Laravel\Passport\ClientRepository;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
it('configures the passport-backed api guard', function () {
|
it('configures the passport-backed api guard', function () {
|
||||||
@@ -97,3 +98,25 @@ it('rejects an authorize request that uses plain PKCE instead of S256', function
|
|||||||
$this->get('/oauth/authorize?response_type=code&client_id=1&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcb&code_challenge=abc123&code_challenge_method=plain')
|
$this->get('/oauth/authorize?response_type=code&client_id=1&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcb&code_challenge=abc123&code_challenge_method=plain')
|
||||||
->assertStatus(400);
|
->assertStatus(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects a guest from the authorize endpoint to login and stores the intended url', function () {
|
||||||
|
$clients = app(ClientRepository::class);
|
||||||
|
$client = $clients->createAuthorizationCodeGrantClient(
|
||||||
|
name: 'Claude',
|
||||||
|
redirectUris: ['https://claude.ai/api/mcp/auth_callback'],
|
||||||
|
confidential: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->get('/oauth/authorize?'.http_build_query([
|
||||||
|
'client_id' => $client->getKey(),
|
||||||
|
'redirect_uri' => 'https://claude.ai/api/mcp/auth_callback',
|
||||||
|
'response_type' => 'code',
|
||||||
|
'scope' => 'mcp:use',
|
||||||
|
'state' => 'xyz',
|
||||||
|
'code_challenge' => str_repeat('a', 43),
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
expect(session('url.intended'))->toContain('/oauth/authorize');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mcp\Servers\EinundzwanzigServer;
|
||||||
|
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\Models\Country;
|
||||||
|
use App\Models\Meetup;
|
||||||
|
use App\Models\User;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
|
function superAdmin(): User
|
||||||
|
{
|
||||||
|
Role::findOrCreate('super-admin');
|
||||||
|
|
||||||
|
return User::factory()->create()->assignRole('super-admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lets a super-admin update a meetup created by someone else', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$meetup = Meetup::factory()->create(['name' => 'Altname', 'created_by' => $owner->id]);
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminUpdateRecordTool::class, [
|
||||||
|
'model' => 'meetup',
|
||||||
|
'id' => $meetup->id,
|
||||||
|
'attributes' => ['name' => 'Vom Admin geändert'],
|
||||||
|
])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meetups', [
|
||||||
|
'id' => $meetup->id,
|
||||||
|
'name' => 'Vom Admin geändert',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a super-admin create any model', function () {
|
||||||
|
$country = Country::factory()->create();
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminCreateRecordTool::class, [
|
||||||
|
'model' => 'city',
|
||||||
|
'attributes' => [
|
||||||
|
'name' => 'Adminstadt',
|
||||||
|
'country_id' => $country->id,
|
||||||
|
'longitude' => 1.0,
|
||||||
|
'latitude' => 2.0,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('cities', ['name' => 'Adminstadt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists editable models and describes their columns', function () {
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminListModelsTool::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('meetup');
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminDescribeModelTool::class, ['model' => 'meetup'])
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('telegram_link');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists and shows records of any model', function () {
|
||||||
|
$meetup = Meetup::factory()->create(['name' => 'Sichtbar']);
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminListRecordsTool::class, ['model' => 'meetup'])
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Sichtbar');
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminShowRecordTool::class, ['model' => 'meetup', 'id' => $meetup->id])
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Sichtbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown model with the list of available ones', function () {
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminShowRecordTool::class, ['model' => 'gibtsnicht', 'id' => 1])
|
||||||
|
->assertHasErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write password or role fields via super-admin tools', function () {
|
||||||
|
$target = User::factory()->create(['name' => 'Unverändert']);
|
||||||
|
$originalPassword = $target->password;
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminUpdateRecordTool::class, [
|
||||||
|
'model' => 'user',
|
||||||
|
'id' => $target->id,
|
||||||
|
'attributes' => ['name' => 'Neu', 'password' => 'gehackt'],
|
||||||
|
])
|
||||||
|
->assertHasErrors();
|
||||||
|
|
||||||
|
$fresh = $target->fresh();
|
||||||
|
expect($fresh->name)->toBe('Unverändert')
|
||||||
|
->and($fresh->password)->toBe($originalPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write further protected fields like nostr or email verification', function () {
|
||||||
|
$target = User::factory()->create(['nostr' => 'npub-original']);
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(superAdmin())
|
||||||
|
->tool(SuperAdminUpdateRecordTool::class, [
|
||||||
|
'model' => 'user',
|
||||||
|
'id' => $target->id,
|
||||||
|
'attributes' => ['nostr' => 'npub-gefälscht'],
|
||||||
|
])
|
||||||
|
->assertHasErrors();
|
||||||
|
|
||||||
|
expect($target->fresh()->nostr)->toBe('npub-original');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a non super-admin from using the super-admin tools', function () {
|
||||||
|
$meetup = Meetup::factory()->create(['name' => 'Geschützt']);
|
||||||
|
|
||||||
|
EinundzwanzigServer::actingAs(User::factory()->create())
|
||||||
|
->tool(SuperAdminUpdateRecordTool::class, [
|
||||||
|
'model' => 'meetup',
|
||||||
|
'id' => $meetup->id,
|
||||||
|
'attributes' => ['name' => 'Hijack'],
|
||||||
|
])
|
||||||
|
->assertHasErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meetups', ['id' => $meetup->id, 'name' => 'Geschützt']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user