Aggregation: Add basic authentication support

Add support for basic authentication when connecting to aggregation
sources. This allows satellite BMCs to be authenticated using
username and password credentials.

The implementation:
- Stores credentials alongside URLs in AggregationSource struct
- Validates credentials: no colons, max 40 chars, not empty strings
- Creates Basic Auth headers using base64 encoding
- Only sends Authorization header when both username and password exist
- Adds PATCH handler for updating credentials independently
- Prevents duplicate aggregation sources with same hostname
- Cleans up credentials when aggregation sources are deleted

Tested: Manual testing with authenticated aggregation sources

Change-Id: Ide17a3c08a4a8f6b90a2ffcd2c798cbbec578db8
Signed-off-by: Kamran Hasan <khasan@nvidia.com>
diff --git a/http/utility.hpp b/http/utility.hpp
index e19e258..bf65b25 100644
--- a/http/utility.hpp
+++ b/http/utility.hpp
@@ -232,6 +232,18 @@
     return out;
 }
 
+inline std::string createBasicAuthHeader(std::string_view username,
+                                         std::string_view password)
+{
+    std::string credentials = "Basic ";
+    Base64Encoder enc;
+    enc.encode(username, credentials);
+    enc.encode(":", credentials);
+    enc.encode(password, credentials);
+    enc.finalize(credentials);
+    return credentials;
+}
+
 template <bool urlsafe = false>
 inline bool base64Decode(std::string_view input, std::string& output)
 {
diff --git a/redfish-core/include/redfish_aggregator.hpp b/redfish-core/include/redfish_aggregator.hpp
index a318e4e..3ac4ed7 100644
--- a/redfish-core/include/redfish_aggregator.hpp
+++ b/redfish-core/include/redfish_aggregator.hpp
@@ -413,6 +413,13 @@
             .invalidResp = aggregationRetryHandler};
 }
 
+struct AggregationSource
+{
+    boost::urls::url url;
+    std::string username;
+    std::string password;
+};
+
 class RedfishAggregator
 {
   private:
@@ -814,9 +821,14 @@
         {
             url.set_query(targetURI.query());
         }
+
+        // Prepare request headers
+        boost::beast::http::fields requestFields =
+            prepareAggregationHeaders(thisReq.fields(), prefix);
+
         client.sendDataWithCallback(std::move(data), url,
                                     ensuressl::VerifyCertificate::Verify,
-                                    thisReq.fields(), thisReq.method(), cb);
+                                    requestFields, thisReq.method(), cb);
     }
 
     // Forward a request for a collection URI to each known satellite BMC
@@ -837,9 +849,14 @@
                 url.set_query(thisReq.url().query());
             }
             std::string data = thisReq.body();
+
+            // Prepare request headers
+            boost::beast::http::fields requestFields =
+                prepareAggregationHeaders(thisReq.fields(), sat.first);
+
             client.sendDataWithCallback(std::move(data), url,
                                         ensuressl::VerifyCertificate::Verify,
-                                        thisReq.fields(), thisReq.method(), cb);
+                                        requestFields, thisReq.method(), cb);
         }
     }
 
@@ -864,9 +881,13 @@
 
             std::string data = thisReq.body();
 
+            // Prepare request headers
+            boost::beast::http::fields requestFields =
+                prepareAggregationHeaders(thisReq.fields(), sat.first);
+
             client.sendDataWithCallback(std::move(data), url,
                                         ensuressl::VerifyCertificate::Verify,
-                                        thisReq.fields(), thisReq.method(), cb);
+                                        requestFields, thisReq.method(), cb);
         }
     }
 
@@ -889,8 +910,35 @@
         return handler;
     }
 
-    // Aggregation sources from AggregationCollection
-    std::unordered_map<std::string, boost::urls::url> currentAggregationSources;
+    // Aggregation sources with their URLs and optional credentials
+    std::unordered_map<std::string, AggregationSource> aggregationSources;
+
+    // Helper function to prepare headers for aggregated satellite BMC requests
+    boost::beast::http::fields prepareAggregationHeaders(
+        const boost::beast::http::fields& originalFields,
+        const std::string& prefix) const
+    {
+        boost::beast::http::fields fields = originalFields;
+
+        // POST AggregationService can only parse JSON
+        fields.set(boost::beast::http::field::accept, "application/json");
+
+        // Add authentication if credentials exist for this prefix
+        auto it = aggregationSources.find(prefix);
+        if (it != aggregationSources.end())
+        {
+            const auto& source = it->second;
+            // Only add auth header if both username and password are provided
+            if (!source.username.empty() && !source.password.empty())
+            {
+                std::string authHeader = crow::utility::createBasicAuthHeader(
+                    source.username, source.password);
+                fields.set(boost::beast::http::field::authorization,
+                           authHeader);
+            }
+        }
+        return fields;
+    }
 
     // Polls D-Bus to get all available satellite config information
     // Expects a handler which interacts with the returned configs
@@ -902,8 +950,12 @@
     {
         BMCWEB_LOG_DEBUG("Gathering satellite configs");
 
-        std::unordered_map<std::string, boost::urls::url> satelliteInfo(
-            currentAggregationSources);
+        // Extract just the URLs from aggregationSources for the handler
+        std::unordered_map<std::string, boost::urls::url> satelliteInfo;
+        for (const auto& [prefix, source] : aggregationSources)
+        {
+            satelliteInfo.emplace(prefix, source.url);
+        }
 
         sdbusplus::message::object_path path("/xyz/openbmc_project/inventory");
         dbus::utility::getManagedObjects(
@@ -1370,7 +1422,7 @@
     bool segmentHasPrefix(const std::string& urlSegment) const
     {
         // TODO: handle this better
-        // For now 5B247A_ wont be in the currentAggregationSources map so
+        // For now 5B247A_ wont be in the aggregationSources map so
         // check explicitly for now
         if (urlSegment.starts_with("5B247A_"))
         {
@@ -1388,7 +1440,7 @@
         std::string prefix = urlSegment.substr(0, underscorePos);
 
         // Check if this prefix exists
-        return currentAggregationSources.contains(prefix);
+        return aggregationSources.contains(prefix);
     }
 };
 
diff --git a/redfish-core/lib/aggregation_service.hpp b/redfish-core/lib/aggregation_service.hpp
index 14c545e..65d3ac7 100644
--- a/redfish-core/lib/aggregation_service.hpp
+++ b/redfish-core/lib/aggregation_service.hpp
@@ -191,6 +191,19 @@
     std::string hostName(sat->second.encoded_origin());
     asyncResp->res.jsonValue["HostName"] = std::move(hostName);
 
+    // Include UserName property, defaulting to null
+    auto& aggregator = RedfishAggregator::getInstance();
+    auto it = aggregator.aggregationSources.find(aggregationSourceId);
+    if (it != aggregator.aggregationSources.end() &&
+        !it->second.username.empty())
+    {
+        asyncResp->res.jsonValue["UserName"] = it->second.username;
+    }
+    else
+    {
+        asyncResp->res.jsonValue["UserName"] = nullptr;
+    }
+
     // The Redfish spec requires Password to be null in responses
     asyncResp->res.jsonValue["Password"] = nullptr;
 }
@@ -229,6 +242,36 @@
                      aggregationSourceId);
 }
 
+inline bool validateCredentialField(const std::optional<std::string>& field,
+                                    const std::string& fieldName,
+                                    crow::Response& res)
+{
+    if (!field.has_value())
+    {
+        return true; // Field not provided, that's okay
+    }
+
+    if (field->empty())
+    {
+        messages::stringValueTooShort(res, fieldName, "1");
+        return false;
+    }
+
+    if (field->find(':') != std::string::npos)
+    {
+        messages::propertyValueIncorrect(res, *field, fieldName);
+        return false;
+    }
+
+    if (field->length() > 40)
+    {
+        messages::stringValueTooLong(res, fieldName, 40);
+        return false;
+    }
+
+    return true;
+}
+
 inline void handleAggregationSourceCollectionPost(
     App& app, const crow::Request& req,
     const std::shared_ptr<bmcweb::AsyncResp>& asyncResp)
@@ -238,10 +281,15 @@
         return;
     }
     std::string hostname;
-    if (!json_util::readJsonPatch(req, asyncResp->res, "HostName", hostname))
+    std::optional<std::string> username;
+    std::optional<std::string> password;
+
+    if (!json_util::readJsonPatch(req, asyncResp->res, "HostName", hostname,
+                                  "UserName", username, "Password", password))
     {
         return;
     }
+
     boost::system::result<boost::urls::url> url =
         boost::urls::parse_absolute_uri(hostname);
     if (!url)
@@ -256,9 +304,35 @@
         return;
     }
     crow::utility::setPortDefaults(*url);
+
+    // Check for duplicate hostname
+    auto& aggregator = RedfishAggregator::getInstance();
+    for (const auto& [existingPrefix, existingSource] :
+         aggregator.aggregationSources)
+    {
+        if (existingSource.url == *url)
+        {
+            messages::resourceAlreadyExists(asyncResp->res, "AggregationSource",
+                                            "HostName", url->buffer());
+            return;
+        }
+    }
+
+    // Validate username and password
+    if (!validateCredentialField(username, "UserName", asyncResp->res))
+    {
+        return;
+    }
+    if (!validateCredentialField(password, "Password", asyncResp->res))
+    {
+        return;
+    }
+
     std::string prefix = bmcweb::getRandomIdOfLength(8);
-    RedfishAggregator::getInstance().currentAggregationSources.emplace(
-        prefix, *url);
+    aggregator.aggregationSources.emplace(
+        prefix,
+        AggregationSource{*url, username.value_or(""), password.value_or("")});
+
     BMCWEB_LOG_DEBUG("Emplaced {} with url {}", prefix, url->buffer());
     asyncResp->res.addHeader(
         boost::beast::http::field::location,
@@ -267,6 +341,82 @@
     messages::created(asyncResp->res);
 }
 
+inline void handleAggregationSourcePatch(
+    App& app, const crow::Request& req,
+    const std::shared_ptr<bmcweb::AsyncResp>& asyncResp,
+    const std::string& aggregationSourceId)
+{
+    if (!redfish::setUpRedfishRoute(app, req, asyncResp))
+    {
+        return;
+    }
+
+    std::optional<std::string> username;
+    std::optional<std::string> password;
+
+    if (!json_util::readJsonPatch(req, asyncResp->res, "UserName", username,
+                                  "Password", password))
+    {
+        return;
+    }
+
+    // Validate username and password
+    if (!validateCredentialField(username, "UserName", asyncResp->res))
+    {
+        return;
+    }
+    if (!validateCredentialField(password, "Password", asyncResp->res))
+    {
+        return;
+    }
+
+    // Check if the aggregation source exists in writable sources
+    auto& aggregator = RedfishAggregator::getInstance();
+    auto it = aggregator.aggregationSources.find(aggregationSourceId);
+    if (it != aggregator.aggregationSources.end())
+    {
+        // Update only the fields that were provided
+        if (username.has_value())
+        {
+            it->second.username = *username;
+        }
+        if (password.has_value())
+        {
+            it->second.password = *password;
+        }
+
+        messages::success(asyncResp->res);
+        return;
+    }
+
+    // Not in writable sources, query D-Bus to check if it exists in
+    // Entity Manager sources
+    RedfishAggregator::getInstance().getSatelliteConfigs(
+        [asyncResp, aggregationSourceId](
+            const boost::system::error_code& ec,
+            const std::unordered_map<std::string, boost::urls::url>&
+                satelliteInfo) {
+            // Something went wrong while querying dbus
+            if (ec)
+            {
+                messages::internalError(asyncResp->res);
+                return;
+            }
+
+            // Check if it exists in Entity Manager sources
+            if (satelliteInfo.contains(aggregationSourceId))
+            {
+                // Source exists but is read-only (from Entity Manager)
+                messages::propertyNotWritable(asyncResp->res, "UserName");
+                return;
+            }
+
+            // Doesn't exist anywhere
+            messages::resourceNotFound(asyncResp->res, "AggregationSource",
+                                       aggregationSourceId);
+        });
+}
+
 inline void handleAggregationSourceDelete(
     App& app, const crow::Request& req,
     const std::shared_ptr<bmcweb::AsyncResp>& asyncResp,
@@ -280,9 +430,8 @@
         boost::beast::http::field::link,
         "</redfish/v1/JsonSchemas/AggregationService/AggregationSource.json>; rel=describedby");
 
-    size_t deleted =
-        RedfishAggregator::getInstance().currentAggregationSources.erase(
-            aggregationSourceId);
+    size_t deleted = RedfishAggregator::getInstance().aggregationSources.erase(
+        aggregationSourceId);
     if (deleted == 0)
     {
         messages::resourceNotFound(asyncResp->res, "AggregationSource",
@@ -303,6 +452,12 @@
 
     BMCWEB_ROUTE(app,
                  "/redfish/v1/AggregationService/AggregationSources/<str>/")
+        .privileges(redfish::privileges::patchAggregationSource)
+        .methods(boost::beast::http::verb::patch)(
+            std::bind_front(handleAggregationSourcePatch, std::ref(app)));
+
+    BMCWEB_ROUTE(app,
+                 "/redfish/v1/AggregationService/AggregationSources/<str>/")
         .privileges(redfish::privileges::deleteAggregationSource)
         .methods(boost::beast::http::verb::delete_)(
             std::bind_front(handleAggregationSourceDelete, std::ref(app)));