| #include "bmcweb_authorizer_singleton.h" | |
| #include <array> | |
| #include <cstddef> | |
| #include <cstdint> | |
| #include <fstream> | |
| #include <iostream> | |
| #include <iterator> | |
| #include <map> | |
| #include <string> | |
| #include <string_view> | |
| #include <unordered_set> | |
| #include <utility> | |
| #include <vector> | |
| #include "gmi/machine_identity.pb.h" | |
| #include "tlbmc/anycast_user.h" | |
| #include "one/network_interfaces.pb.h" | |
| #include "one/offline_node_entities.pb.h" | |
| #include "one/resolved_entities.pb.h" | |
| #include "one/public_offline_node_entities.h" | |
| #include "absl/base/no_destructor.h" | |
| #include "absl/container/flat_hash_set.h" | |
| #include "absl/log/log.h" | |
| #include "absl/strings/match.h" | |
| #include "absl/strings/str_cat.h" | |
| #include "absl/strings/str_join.h" | |
| #include "absl/strings/substitute.h" | |
| #include "boost/beast/http/verb.hpp" // NOLINT | |
| #include "authorizer_enums.h" | |
| #include "redfish_v1.pb.h" | |
| #include <nlohmann/json.hpp> | |
| #include "config_parser.h" | |
| #include "oauth_utils.h" | |
| #include "peer_identity_grpc.h" | |
| #include "redfish_authorizer.h" | |
| #include "redfish_privileges.h" | |
| #include "g3/grpc_headers.h" | |
| #include "zatar/certificate_metadata.h" | |
| namespace milotic::authz { | |
| namespace { | |
| using ::ecclesia::Operation; | |
| using ::ecclesia::ResourceEntity; | |
| using ::milotic::redfish::CertificateMetadata; | |
| using ::milotic::redfish::CertificateMetadataParser; | |
| using ::production_msv::node_entities::ReadOfflineNodeEntityInformation; | |
| using ::production_msv::node_entities_proto::NetworkInterface; | |
| using ::production_msv::node_entities_proto::OfflineNodeEntityInformation; | |
| using ::production_msv::node_entities_proto::ResolvedNodeEntity; | |
| using ::security_prodid::GoogleMachineIdentityProto; | |
| } // namespace | |
| BmcWebAuthorizerSingleton& BmcWebAuthorizerSingleton::Initialize( | |
| const AuthorizerOptions& options, std::string_view oauth_key_path) { | |
| static absl::NoDestructor<BmcWebAuthorizerSingleton> singleton( | |
| options, oauth_key_path); | |
| return *singleton; | |
| } | |
| void BmcWebAuthorizerSingleton::ReloadRedfishAuthorizer( | |
| const std::string& configuration_path) { | |
| redfish_authorizer_.ReloadConfiguration(configuration_path); | |
| } | |
| void BmcWebAuthorizerSingleton::ReloadRedfishAuthorizer() { | |
| redfish_authorizer_.ReloadConfiguration(); | |
| } | |
| void BmcWebAuthorizerSingleton::RegisterAnycastUser( | |
| const std::string& service, | |
| platforms_syshealth::collection::feed::AnycastUser* user) { | |
| redfish_authorizer_.RegisterAnycastUser(service, user); | |
| } | |
| BmcWebAuthorizerSingleton& BmcWebAuthorizerSingleton::GetInstance() { | |
| return Initialize(/*options=*/{}, /*oauth_key_path=*/""); | |
| } | |
| BmcWebAuthorizerSingleton::BmcWebAuthorizerSingleton( | |
| const AuthorizerOptions& options, std::string_view oauth_key_path) | |
| : tlbmc_trust_bundle_install_module_is_enabled_( | |
| options.tlbmc_trust_bundle_install_module_is_enabled), | |
| gmi_path_(options.google_machine_identity_path), | |
| oauth_key_path_(oauth_key_path), | |
| offline_node_entity_path_(options.offline_node_entities_path), | |
| redfish_authorizer_(options) { | |
| ReloadConfiguration(); | |
| } | |
| void BmcWebAuthorizerSingleton::ReloadGmiPaths(std::string_view gmi_path) { | |
| gmi_path_ = gmi_path; | |
| } | |
| void BmcWebAuthorizerSingleton::ReloadConfiguration() { | |
| LOG(WARNING) << "Reload configuration started.."; | |
| redfish_authorizer_.ReloadConfiguration(); | |
| std::string primary_fqdn = ReadPrimaryFqdnFromGmi(); | |
| SetPrimaryFqdn(primary_fqdn); | |
| SetOauthKey(ReadOauthKeyFromFile()); | |
| ReadOfflineNodeEntity(primary_fqdn); | |
| LOG(WARNING) << "Reload configuration done."; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::RecordNewSubscription( | |
| const PeerSpiffeIdentity& peer) { | |
| if (absl::Status status = redfish_authorizer_.RecordNewSubscription(peer); | |
| !status.ok()) { | |
| return grpc::Status(grpc::StatusCode::PERMISSION_DENIED, status.ToString()); | |
| } | |
| return grpc::Status::OK; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::RecordNewUnsubscription( | |
| const PeerSpiffeIdentity& peer) { | |
| if (absl::Status status = redfish_authorizer_.RecordNewUnsubscription(peer); | |
| !status.ok()) { | |
| return grpc::Status(grpc::StatusCode::PERMISSION_DENIED, status.ToString()); | |
| } | |
| return grpc::Status::OK; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::Authorize( | |
| std::string_view uri, boost::beast::http::verb verb, | |
| const RequestState& request_state) const { | |
| if (!request_state.peer_authenticated) { | |
| if (request_state.with_trust_bundle) { | |
| return grpc::Status( | |
| grpc::StatusCode::UNAUTHENTICATED, | |
| "Peer must be authenticated if BMC has trust bundle!"); | |
| } | |
| if (tlbmc_trust_bundle_install_module_is_enabled_) { | |
| return RecoveryAuthorizeForBloom(uri, verb); | |
| } | |
| return AuthorizeWithoutTrustBundle(uri, verb); | |
| } | |
| return AuthorizeWithTrustBundle(uri, verb, request_state); | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::AuthorizeWithTrustBundle( | |
| std::string_view uri, boost::beast::http::verb verb, | |
| const RequestState& request_state) const { | |
| Operation operation = BoostVerbToOperation(verb); | |
| RedfishPrivileges peer_privileges = | |
| RedfishPrivileges(request_state.peer_privileges); | |
| if (!redfish_authorizer_.IsPeerAuthorized(uri, operation, peer_privileges)) { | |
| return grpc::Status(grpc::StatusCode::PERMISSION_DENIED, | |
| "Client doesn't have enough permissions: " + | |
| peer_privileges.GetDebugString()); | |
| } | |
| return grpc::Status::OK; | |
| } | |
| Operation BmcWebAuthorizerSingleton::BoostVerbToOperation( | |
| boost::beast::http::verb verb) { | |
| switch (verb) { | |
| case boost::beast::http::verb::get: | |
| return Operation::kGet; | |
| case boost::beast::http::verb::post: | |
| return Operation::kPost; | |
| case boost::beast::http::verb::delete_: | |
| return Operation::kDelete; | |
| case boost::beast::http::verb::put: | |
| return Operation::kPut; | |
| case boost::beast::http::verb::patch: | |
| return Operation::kPatch; | |
| case boost::beast::http::verb::head: | |
| return Operation::kHead; | |
| default: | |
| return Operation::kUndefined; | |
| } | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::AuthorizeWithoutTrustBundle( | |
| std::string_view uri, boost::beast::http::verb verb) const { | |
| constexpr std::array<std::pair<ResourceEntity, boost::beast::http::verb>, 10> | |
| kRecoveryOperations = { | |
| std::pair<ResourceEntity, boost::beast::http::verb>{ | |
| ResourceEntity::kServiceRoot, boost::beast::http::verb::get}, | |
| {ResourceEntity::kChassisCollection, boost::beast::http::verb::get}, | |
| {ResourceEntity::kChassis, boost::beast::http::verb::get}, | |
| {ResourceEntity::kActionInfo, boost::beast::http::verb::get}, | |
| {ResourceEntity::kChassis, boost::beast::http::verb::post}, | |
| {ResourceEntity::kComputerSystem, boost::beast::http::verb::get}, | |
| {ResourceEntity::kComputerSystemCollection, | |
| boost::beast::http::verb::get}, | |
| {ResourceEntity::kComputerSystem, boost::beast::http::verb::post}, | |
| {ResourceEntity::kManagerCollection, boost::beast::http::verb::get}, | |
| {ResourceEntity::kManager, boost::beast::http::verb::get}, | |
| }; | |
| ResourceEntity entity = GetEntityTypeFromRedfishUri(uri); | |
| bool authorized = false; | |
| for (auto const& [allowed_entity, allowed_verb] : kRecoveryOperations) { | |
| if (entity == allowed_entity && verb == allowed_verb) { | |
| authorized = true; | |
| break; | |
| } | |
| } | |
| // Specifically hardcode the Manager.Reset operations to be allowed. | |
| if ((uri == "/redfish/v1/Managers/bmc/Actions/Manager.Reset") && | |
| verb == boost::beast::http::verb::post) { | |
| authorized = true; | |
| } | |
| if (!authorized) { | |
| return grpc::Status( | |
| grpc::StatusCode::PERMISSION_DENIED, | |
| "Without trust bundle, only recovery operations are allowed"); | |
| } | |
| return grpc::Status::OK; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::RecoveryAuthorizeForBloom( | |
| std::string_view uri, boost::beast::http::verb verb) const { | |
| // allowlist during trust bundle installation | |
| static const absl::NoDestructor<absl::flat_hash_set< | |
| std::pair<std::string_view, boost::beast::http::verb>>> | |
| kAllowedEndpoints( | |
| {{"/redfish/v1", boost::beast::http::verb::get}, | |
| {"/redfish/v1/CertificateService", boost::beast::http::verb::get}, | |
| {"/redfish/v1/CertificateService/ReplaceCertificateActionInfo", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/CertificateService/Actions/" | |
| "CertificateService.ReplaceCertificate", | |
| boost::beast::http::verb::post}, | |
| {"/redfish/v1/Managers/bmc/Actions/Manager.Reset", | |
| boost::beast::http::verb::post}, | |
| {"/redfish/v1/UpdateService/MultipartUpdate", | |
| boost::beast::http::verb::post}, | |
| {"/redfish/v1/UpdateService/Oem/Google/DebugLogs", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/UpdateService/FirmwareInventory/BMC_dauntless", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/UpdateService/FirmwareInventory/tray_dauntless_BMC", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/UpdateService/FirmwareInventory/BMC", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/TaskService", boost::beast::http::verb::get}, | |
| {"/redfish/v1/TaskService/Tasks", boost::beast::http::verb::get}, | |
| {"/redfish/v1/TaskService/Tasks/FirmwareBundleDownload", | |
| boost::beast::http::verb::get}, | |
| {"/redfish/v1/TaskService/Tasks/FirmwareBundleUpdate", | |
| boost::beast::http::verb::get}}); | |
| if (kAllowedEndpoints->contains({uri, verb})) { | |
| return grpc::Status::OK; | |
| } | |
| return grpc::Status( | |
| grpc::StatusCode::PERMISSION_DENIED, | |
| "Please install your client *CA* certificate as trust bundle first."); | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::GetPeerRoleFromAuthContext( | |
| const grpc::AuthContext& context, std::string& peer_role) const { | |
| PeerSpiffeIdentity peer_identity; | |
| if (grpc::Status status = | |
| GetPeerIdentityFromAuthContext(context, peer_identity); | |
| !status.ok()) { | |
| peer_role.clear(); | |
| return status; | |
| } | |
| peer_role = redfish_authorizer_.GetPeerRedfishRole(peer_identity); | |
| if (peer_role.empty()) { | |
| return grpc::Status( | |
| grpc::StatusCode::PERMISSION_DENIED, | |
| absl::StrCat( | |
| "Peer role is not specified in the auth config: peer_identity=", | |
| peer_identity.spiffe_id)); | |
| } | |
| return grpc::Status::OK; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::GetPrivilegesViaOAuth( | |
| const grpc::AuthContext& context, const std::string& token, | |
| std::unordered_set<std::string>& peer_privileges) const { | |
| PeerSpiffeIdentity peer_identity; | |
| if (grpc::Status status = | |
| GetPeerIdentityFromAuthContext(context, peer_identity); | |
| !status.ok()) { | |
| return status; | |
| } | |
| std::string redfish_role; | |
| constexpr const char* kExpectedIssuer = "BMC Local Authorization Server"; | |
| if (grpc::Status status = VerifyAndExtractRoleFromToken( | |
| token, GetOauthKey(), GenerateOAuthSubject(peer_identity), | |
| GetPrimaryFqdn(), kExpectedIssuer, redfish_role); | |
| !status.ok()) { | |
| return status; | |
| } | |
| peer_privileges = redfish_authorizer_.GetRedfishPrivileges(redfish_role); | |
| return grpc::Status::OK; | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::GetPrivilegesViaMTls( | |
| const grpc::AuthContext& context, | |
| std::unordered_set<std::string>& peer_privileges) const { | |
| PeerSpiffeIdentity peer_identity; | |
| if (grpc::Status status = | |
| GetPeerIdentityFromAuthContext(context, peer_identity); | |
| !status.ok()) { | |
| return status; | |
| } | |
| LOG(INFO) << "Peer identity: " << peer_identity.spiffe_id; | |
| peer_privileges = redfish_authorizer_.GetPeerRedfishPrivileges(peer_identity); | |
| return grpc::Status::OK; | |
| } | |
| std::string BmcWebAuthorizerSingleton::ReadPrimaryFqdnFromGmi() const { | |
| std::ifstream gmi_file(gmi_path_); | |
| if (!gmi_file.is_open()) { | |
| LOG(WARNING) << "GMI file at '" << gmi_path_ << "' is missing." << '\n'; | |
| return ""; | |
| } | |
| GoogleMachineIdentityProto gmi; | |
| if (!gmi.ParseFromIstream(&gmi_file)) { | |
| std::cerr << "GMI parsing failed at '" << gmi_path_ << "'" << '\n'; | |
| return ""; | |
| } | |
| return gmi.fqdn(); | |
| } | |
| std::string BmcWebAuthorizerSingleton::ReadOauthKeyFromFile() const { | |
| std::ifstream oauth_key_file(oauth_key_path_); | |
| if (!oauth_key_file.is_open()) { | |
| LOG(WARNING) << "OAuth key file at '" << oauth_key_path_ << "' is missing." | |
| << '\n'; | |
| return ""; | |
| } | |
| std::string oauth_key = {std::istreambuf_iterator<char>(oauth_key_file), | |
| std::istreambuf_iterator<char>()}; | |
| return oauth_key; | |
| } | |
| void BmcWebAuthorizerSingleton::ReadOfflineNodeEntity( | |
| std::string_view primary_fqdn) { | |
| std::ifstream offline_node_entity_file(offline_node_entity_path_); | |
| std::vector<std::string> interface_fqdns; | |
| bool has_bmc_direct_ethernet = false; | |
| if (!offline_node_entity_file.is_open()) { | |
| LOG(WARNING) << "Offline Node Entity file at '" << offline_node_entity_path_ | |
| << "' is missing." << '\n'; | |
| SetInterfaceFqdns(std::move(interface_fqdns)); | |
| SetHasBmcDirectEthernet(has_bmc_direct_ethernet); | |
| return; | |
| } | |
| absl::StatusOr<OfflineNodeEntityInformation> one = | |
| ReadOfflineNodeEntityInformation(offline_node_entity_path_); | |
| if (!one.ok()) { | |
| LOG(WARNING) << "Could not read offline node entities information: " | |
| << one.status().message(); | |
| SetInterfaceFqdns(std::move(interface_fqdns)); | |
| SetHasBmcDirectEthernet(has_bmc_direct_ethernet); | |
| return; | |
| } | |
| const auto& entities = one->resolved_config().entities(); | |
| // Find target entity by current entity tag. | |
| const ResolvedNodeEntity* target_entity = nullptr; | |
| if (!one->entity_tag().empty()) { | |
| auto it = entities.find(one->entity_tag()); | |
| if (it != entities.end()) { | |
| target_entity = &it->second; | |
| } | |
| } | |
| if (target_entity != nullptr) { | |
| for (const NetworkInterface& intf : | |
| target_entity->network_interfaces().network_interface()) { | |
| if (intf.has_bmc_interface_role() && | |
| intf.bmc_interface_role() == | |
| NetworkInterface::BMC_IR_DIRECT_ETHERNET) { | |
| has_bmc_direct_ethernet = true; | |
| } | |
| interface_fqdns.push_back(intf.hostname() + ".prod.google.com"); | |
| } | |
| } | |
| SetInterfaceFqdns(std::move(interface_fqdns)); | |
| SetHasBmcDirectEthernet(has_bmc_direct_ethernet); | |
| } | |
| uint64_t BmcWebAuthorizerSingleton::GetSampleRateLimit( | |
| const std::string& peer_role) const { | |
| return redfish_authorizer_.GetSampleRateLimit(peer_role); | |
| } | |
| std::string BmcWebAuthorizerSingleton::GetAnycastAddress( | |
| const std::string& peer_role) const { | |
| return redfish_authorizer_.GetAnycastAddress(peer_role); | |
| } | |
| ResourceEntity BmcWebAuthorizerSingleton::GetEntityTypeFromRedfishUri( | |
| std::string_view uri) const { | |
| return redfish_authorizer_.GetEntityTypeFromRedfishUri(uri); | |
| } | |
| std::size_t BmcWebAuthorizerSingleton::GetNodeIndexInPatternArray( | |
| std::string_view uri) const { | |
| return redfish_authorizer_.GetNodeIndexInPatternArray(uri); | |
| } | |
| nlohmann::json BmcWebAuthorizerSingleton::GetRedfishPrivilegeRegistry() const { | |
| return redfish_authorizer_.GetRedfishPrivilegeRegistry(); | |
| } | |
| bool BmcWebAuthorizerSingleton::IsBasePrivilegeRegistryFound() const { | |
| nlohmann::json config_json = redfish_authorizer_.ParseAuthConfig(); | |
| if (config_json.empty()) { | |
| return false; | |
| } | |
| std::string base_privilege_registry_path = | |
| redfish_authorizer_.FindBasePrivilegeRegistryPath(config_json); | |
| return !base_privilege_registry_path.empty(); | |
| } | |
| void BmcWebAuthorizerSingleton::SetBasePrivilegesFolder( | |
| std::string_view base_privileges_folder) { | |
| redfish_authorizer_.SetBasePrivilegesFolder(base_privileges_folder); | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::GetPeerIdentityFromAuthContext( | |
| const grpc::AuthContext& context, PeerSpiffeIdentity& peer_identity) { | |
| return ExtractPeerIdentityFromAuthContext(context, peer_identity); | |
| } | |
| grpc::Status BmcWebAuthorizerSingleton::CheckTarget( | |
| const grpc::AuthContext& grpc_context, | |
| const std::multimap<grpc::string_ref, grpc::string_ref>& client_metadata, | |
| const ::redfish::v1::Request& request) const { | |
| PeerSpiffeIdentity peer_identity; | |
| if (grpc::Status status = | |
| GetPeerIdentityFromAuthContext(grpc_context, peer_identity); | |
| !status.ok()) { | |
| return status; | |
| } | |
| CertificateMetadata peer_metadata; | |
| if (absl::Status status = metadata_parser_.GetContextAndRoleFromSpiffe( | |
| peer_identity.spiffe_id, peer_metadata.context, peer_metadata.role); | |
| !status.ok()) { | |
| return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, status.ToString()); | |
| } | |
| // If the service (BMCWeb) has a legitimate GMI, and peer is not a node (e.g., | |
| // it's a Borg job), check that the intended recipient of the request matches | |
| // the identity in the certificate. This is to prevent redirect attacks in | |
| // powercycle trust model, where clients never verify server. | |
| std::string primary_fqdn = GetPrimaryFqdn(); | |
| if (primary_fqdn.empty()) { | |
| LOG(WARNING) << "Can't parse primary fqdn. GMI is probably broken."; | |
| return grpc::Status::OK; | |
| } | |
| if (peer_metadata.context == CertificateMetadataParser::kCaContextNode) { | |
| return grpc::Status::OK; | |
| } | |
| std::string host; | |
| for (auto const& [key, val] : request.headers()) { | |
| // Both field name of the Authorization header and the token type are case | |
| // insensitive | |
| // References | |
| // https://www.rfc-editor.org/rfc/rfc7230#section-3.2 | |
| // https://www.rfc-editor.org/rfc/rfc6749#section-5.1 | |
| if (absl::EqualsIgnoreCase(key, "Host")) { | |
| host = val; | |
| } | |
| } | |
| // To keep backward compatibility, extract host from metadata as well if it's | |
| // not in headers. | |
| if (host.empty()) { | |
| auto [it, end] = client_metadata.equal_range("target"); | |
| if (it != end) { | |
| host = {it->second.data(), it->second.length()}; | |
| } | |
| } | |
| // DNS should be case insensitive | |
| // Reference: https://www.rfc-editor.org/rfc/rfc4343 | |
| if (absl::EqualsIgnoreCase(host, primary_fqdn)) { | |
| return grpc::Status::OK; | |
| } | |
| // We could have multiple FQDNs on BMC and they can be different from the | |
| // hostname. Matching any of them should pass the validation | |
| for (const std::string& fqdn : GetInterfaceFqdns()) { | |
| if (absl::EqualsIgnoreCase(host, fqdn)) { | |
| return grpc::Status::OK; | |
| } | |
| } | |
| return grpc::Status( | |
| grpc::StatusCode::PERMISSION_DENIED, | |
| absl::Substitute( | |
| "Unauthorized user: request target doesn't match server; request's " | |
| "host header='$0'; BMC's primary FQDN='$1'; BMC's interface " | |
| "FQDNs='$2'", | |
| host, primary_fqdn, absl::StrJoin(GetInterfaceFqdns(), " "))); | |
| } | |
| void BmcWebAuthorizerSingleton::DeregisterAnycastUser( | |
| const std::string& service) { | |
| redfish_authorizer_.DeregisterAnycastUser(service); | |
| } | |
| nlohmann::json BmcWebAuthorizerSingleton::GetConfig() const { | |
| return redfish_authorizer_.GetConfig(); | |
| } | |
| nlohmann::json BmcWebAuthorizerSingleton::GetPrivilegeRegistry() const { | |
| return redfish_authorizer_.GetPrivilegeRegistry(); | |
| } | |
| } // namespace milotic::authz |