$scope] ); } return $context; } /** * Wie requireScope(), bricht aber nicht ab, sondern liefert null. * * @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}|null */ public static function resolve( PDO $db, string $scope, ?string $environment = null, bool $allowSession = true ): ?array { // 0. IP-Sperre pruefen $ip = Http::clientIp(); $ipLockout = Auth::lockoutSeconds($db, $ip); if ($ipLockout > 0) { SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', Auth::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['context' => 'api']); Http::fail(429, 'locked_out', 'IP-Adresse aufgrund wiederholter Fehlversuche vorübergehend gesperrt.', null, ['retry_after_seconds' => $ipLockout]); } $presented = Http::bearerToken(); // 1. Shared Key $sharedKey = (string)Config::get('security.shared_key', ''); if ($presented !== null && $sharedKey !== '' && hash_equals($sharedKey, $presented)) { return [ 'method' => 'shared_key', 'actor' => 'system:shared-key', 'token' => null, 'project_slug' => null, 'environment' => null, ]; } // 2. WebUI-Session if ($allowSession && Auth::isLoggedIn()) { return [ 'method' => 'session', 'actor' => Auth::username(), 'token' => null, 'project_slug' => null, 'environment' => null, ]; } // 3. Agenten-Token if ($presented !== null) { $manager = new TokenManager($db); $token = $manager->validateToken($presented, $scope, $environment); if (is_array($token)) { $name = isset($token['name']) && $token['name'] !== '' ? (string)$token['name'] : (string)$token['token_id']; return [ 'method' => 'token', 'actor' => 'agent:' . $name, 'token' => $token, 'project_slug' => self::stringOrNull($token['project_slug'] ?? null), 'environment' => self::stringOrNull($token['environment'] ?? null), ]; } SecurityLogger::apiAuthFail($ip, Http::path(), 'token_rejected', ['scope' => $scope]); // Drosselung bei wiederholtem Vorlegen ungueltiger Tokens $failLimiter = new RateLimiter($db, 20, 300, 'api_token_failures'); if (!$failLimiter->check($ip)) { SecurityLogger::log('API_BRUTE_FORCE', $ip, [ 'path' => Http::path(), 'reason' => 'too_many_invalid_tokens', ]); Auth::triggerSecurityAlert($db, 'security.attack', [ 'ip' => $ip, 'details' => 'Mehr als 20 ungültige API-Tokens innerhalb von 5 Minuten übermittelt.', 'target' => Http::path(), 'type' => 'API-Token-Brute-Force', ]); Http::fail(429, 'rate_limited', 'Zu viele fehlerhafte Authentifizierungsversuche. Bitte 5 Minuten warten.'); } Logger::warning('Token abgelehnt', [ 'scope' => $scope, 'ip' => $ip, 'path' => Http::path(), ]); } return null; } /** * Ist das ein Werkbank-Token? * * Der Unterschied, der zaehlt, ist nicht "welches Projekt", sondern WO das * Token liegt: * * - Laufzeit-Token stecken im ausgelieferten Client, also auf fremder * Hardware. Sie sind extrahierbar. Ein solches Token darf ausschliesslich * in sein eigenes Projekt schreiben - sonst koennte ein Kunde mit einem * ausgelesenen Token in fremde Tracker schreiben. * - Werkbank-Token (owner_type "dev_agent") liegen auf einer eigenen * Maschine: Claude Code, der MCP-Adapter, die CI. Sie duerfen ueber die * Projekte hinweg arbeiten. * * Die Instanz fuehrt ausschliesslich eigene Projekte; eine geteilte Instanz * fuer Fremde ist ausgeschlossen. Deshalb genuegt der Token-Typ als * Unterscheidung - ohne Mandantentrennung und ohne Liste erlaubter Ziele. * * @param array{token:?array} $context */ public static function isWorkbench(array $context): bool { $token = $context['token'] ?? null; return is_array($token) && ($token['owner_type'] ?? null) === 'dev_agent'; } /** * Das Projekt, in dem der Aufrufer zuhause ist - unabhaengig davon, welches * Projekt er gerade anspricht. * * Auch ein Werkbank-Token bleibt an sein Heimatprojekt gebunden, obwohl es * fremde ansprechen darf. Das ist der Punkt: die Herkunft einer * Fremdmeldung (reported_from) wird daraus abgeleitet und ist damit * bezeugt statt behauptet. Koennte ein Agent sie frei angeben, waere das * Signal "drei Projekte melden dasselbe" wertlos. * * @param array{project_slug:?string} $context */ public static function homeProject(array $context): ?string { $bound = $context['project_slug'] ?? null; return is_string($bound) && $bound !== '' ? $bound : null; } /** * Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes * Projekt zugreift. Bricht sonst mit 403 ab. * * $allowWorkbench oeffnet die Schranke fuer Werkbank-Token. Der Vorgabewert * false ist Absicht: die Ausnahme wird an jeder Aufrufstelle einzeln * gesetzt, nie global. Ein Werkbank-Token darf melden und kommentieren - * aber nicht in einem fremden Projekt veroeffentlichen, einrichten oder * Items abschliessen. Ein pauschaler Freibrief haette genau das getan. * * @param array{project_slug:?string,method:string,token:?array} $context */ public static function enforceProject( array $context, ?string $requestedSlug, bool $allowWorkbench = false ): void { $bound = $context['project_slug'] ?? null; if ($bound === null || $bound === '') { return; // Token ist nicht projektgebunden } if ($requestedSlug === null || $requestedSlug === '' || $requestedSlug === $bound) { return; } if ($allowWorkbench && self::isWorkbench($context)) { return; } Http::fail( 403, 'project_forbidden', sprintf('Dieses Token ist an das Projekt "%s" gebunden und darf nicht auf "%s" zugreifen.', $bound, $requestedSlug), null, ['bound_project' => $bound] ); } /** * Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss, * oder null bei uneingeschraenktem Zugriff. * * $allowWorkbench hebt die Einschraenkung fuer Werkbank-Token auf. Beim * Lesen ist das gewollt: ein Agent, der entscheiden soll, ob ein Fehler ins * eigene oder ins fremde Projekt gehoert, muss die Landschaft sehen. Beim * Schreiben bleibt der Vorgabewert false stehen - dort schuetzt genau diese * Einschraenkung fremde Items vor Zugriff. * * @param array{project_slug:?string,token:?array} $context */ public static function projectFilter(array $context, bool $allowWorkbench = false): ?string { if ($allowWorkbench && self::isWorkbench($context)) { return null; } $bound = $context['project_slug'] ?? null; return is_string($bound) && $bound !== '' ? $bound : null; } /** * Sagt es, wenn eine Antwort ein anderes Projekt betrifft als das angefragte. * * DAS PROBLEM IST DIE STILLE, nicht die Verengung. Ein an Projekt X * gebundenes Token, das nach Projekt Y fragt, bekommt beim SCHREIBEN eine * klare Absage (403 project_forbidden) und beim LESEN HTTP 200 - mit den * Items von X und einem Umschlag, in dem der Projektfilter bereits auf X * umgeschrieben ist. Die Antwort sieht damit aus wie eine gueltige * Auskunft ueber Y. * * Das ist keine theoretische Verwechslung: Die Prompt-Vorlage schickt * jeden Agenten vor jeder Meldung durch dc_search ("gibt es das schon?"). * Mit der falschen Tokenart bekommt er auf "gibt es das im * Deploymentcenter schon?" eine leere, plausibel aussehende Antwort - und * legt das Duplikat an. Genau so ist es einem Melder beinahe passiert; * tatsaechlich waren es 71 Items. * * Die Schranke bleibt unveraendert. Sie sagt jetzt nur, dass sie zugegangen * ist. (#131) * * @param ?string $requested Was der Aufrufer angefragt hat, oder null. * @param string $actual Worueber tatsaechlich geantwortet wird. * @return array{scoped_to?:string,scope_note?:string} Leer, wenn nichts verengt wurde. */ public static function scopeNotice(?string $requested, string $actual): array { if ($requested === null || $requested === '' || $requested === $actual) { return []; } return [ 'scoped_to' => $actual, 'scope_note' => sprintf( 'Angefragt war "%s", geantwortet wird ueber "%s": Dieses Token ist an "%s" ' . 'gebunden. Die Antwort sagt NICHTS ueber "%s" aus - insbesondere heisst eine ' . 'leere Liste hier nicht, dass es dort nichts gibt.', $requested, $actual, $actual, $requested ), ]; } private static function stringOrNull($value): ?string { if (!is_string($value)) { return null; } return $value === '' ? null : $value; } }