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/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)));