user(); $token = $user?->currentAccessToken(); $teamId = $token?->team_id; if (! $user || ! $token || is_null($teamId)) { return response()->json(['message' => 'Invalid token.'], 401); } $team = $user->teams() ->where('teams.id', $teamId) ->first(); if (! $team) { return response()->json(['message' => 'Invalid token.'], 401); } if (! self::tokenWithinCreatorAccess($user, $token, (int) $teamId)) { auditLog('api.auth.token_exceeds_access', [ 'token_id' => $token->getKey(), 'team_id' => (int) $teamId, 'abilities' => $token->abilities ?? [], ], 'warning'); // MCP clients expect JSON-RPC envelopes (often only parsed on HTTP 200). // Keep REST API clients on plain 403 JSON. if ($request->is('mcp') || $request->is('mcp/*')) { return response()->json([ 'jsonrpc' => '2.0', 'id' => $request->input('id'), 'error' => [ 'code' => -32003, 'message' => self::EXCEEDS_ACCESS_MESSAGE, ], ]); } return response()->json(['message' => self::EXCEEDS_ACCESS_MESSAGE], 403); } return $next($request); } /** * A token can never do more than its owner can do in the token team, also after the owner lost access. * Owners and admins hold every level. A member must hold every token ability on every token scope. */ public static function tokenWithinCreatorAccess(User $user, SanctumPersonalAccessToken $token, int $teamId): bool { if ($user->isAdminOfTeam($teamId)) { return true; } // The deploy canvas token is built in memory from the session user's role, never from user input. if (! $token->exists) { return true; } $abilities = array_values($token->abilities ?? []); $levels = AccessLevel::parseMany($abilities); // Abilities outside the access levels (write:sensitive, *) are for owners and admins only. if (count($levels) !== count(array_unique($abilities))) { return false; } $grants = $token instanceof PersonalAccessToken ? AccessResolver::tokenGrants($token) : null; return app(AccessResolver::class)->userHolds($user, $teamId, $grants ?? [Grant::team($levels)]); } }