|  | #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 "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/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 "grpc/grpc_security_constants.h" | 
|  | #include "grpcpp/security/auth_context.h" | 
|  | #include "grpcpp/support/status.h" | 
|  | #include "grpcpp/support/string_ref.h" | 
|  | #include "nlohmann/json.hpp" | 
|  | #include "config_parser.h" | 
|  | #include "oauth_utils.h" | 
|  | #include "redfish_authorizer.h" | 
|  | #include "redfish_privileges.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); | 
|  | } | 
|  |  | 
|  | BmcWebAuthorizerSingleton& BmcWebAuthorizerSingleton::GetInstance() { | 
|  | return Initialize(/*options=*/{}, /*oauth_key_path=*/""); | 
|  | } | 
|  |  | 
|  | BmcWebAuthorizerSingleton::BmcWebAuthorizerSingleton( | 
|  | const AuthorizerOptions& options, std::string_view oauth_key_path) | 
|  | : 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()); | 
|  | SetInterfaceFqdns(ReadDomainNameFromOfflineNodeEntity(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 (grpc::Status status = AuthorizeWithoutTrustBundle(uri, verb); | 
|  | !status.ok()) { | 
|  | return status; | 
|  | } | 
|  | return grpc::Status::OK; | 
|  | } | 
|  |  | 
|  | 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 operation 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::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; | 
|  | } | 
|  |  | 
|  | std::vector<std::string> | 
|  | BmcWebAuthorizerSingleton::ReadDomainNameFromOfflineNodeEntity( | 
|  | std::string_view primary_fqdn) const { | 
|  | std::ifstream offline_node_entity_file(offline_node_entity_path_); | 
|  | std::vector<std::string> interface_fqdns; | 
|  | if (!offline_node_entity_file.is_open()) { | 
|  | LOG(WARNING) << "Offline Node Entity file at '" << offline_node_entity_path_ | 
|  | << "' is missing." << '\n'; | 
|  | return interface_fqdns; | 
|  | } | 
|  | absl::StatusOr<OfflineNodeEntityInformation> one = | 
|  | ReadOfflineNodeEntityInformation(offline_node_entity_path_); | 
|  | if (!one.ok()) { | 
|  | LOG(WARNING) << "Could not read offline node entities information: " | 
|  | << one.status().message(); | 
|  | return interface_fqdns; | 
|  | } | 
|  | for (const std::pair<const std::string, ResolvedNodeEntity>& entity : | 
|  | (*one).resolved_config().entities()) { | 
|  | if (absl::StrCat(entity.second.hostname(), ".prod.google.com") != | 
|  | primary_fqdn) | 
|  | continue; | 
|  | for (const NetworkInterface& intf : | 
|  | entity.second.network_interfaces().network_interface()) { | 
|  | interface_fqdns.push_back(intf.hostname() + ".prod.google.com"); | 
|  | } | 
|  | break; | 
|  | } | 
|  | return interface_fqdns; | 
|  | } | 
|  |  | 
|  | uint64_t BmcWebAuthorizerSingleton::GetSampleRateLimit( | 
|  | const std::string& peer_role) const { | 
|  | return redfish_authorizer_.GetSampleRateLimit(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) { | 
|  | if (!context.IsPeerAuthenticated()) { | 
|  | return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, | 
|  | "Peer is unauthenticated"); | 
|  | } | 
|  | if (context.GetPeerIdentityPropertyName() != GRPC_X509_SAN_PROPERTY_NAME) { | 
|  | return grpc::Status(grpc::StatusCode::PERMISSION_DENIED, | 
|  | "Peer identity isn't X.509 SAN"); | 
|  | } | 
|  | for (const grpc::string_ref& val : context.GetPeerIdentity()) { | 
|  | std::string_view str(val.data(), val.size()); | 
|  | // As per SPIFFE specification, SPIFFE schema and trust domain are case | 
|  | // insensitive. | 
|  | // https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md#24-spiffe-id-parsing | 
|  | if (absl::StartsWithIgnoreCase(str, "spiffe://")) { | 
|  | peer_identity.spiffe_id = str; | 
|  | } else if (absl::EndsWith(str, ".prod.google.com")) { | 
|  | peer_identity.fqdn = str; | 
|  | } | 
|  | } | 
|  | return grpc::Status::OK; | 
|  | } | 
|  |  | 
|  | 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 = client_metadata.find("target"); | 
|  | if (it != client_metadata.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 hostname='$1'; BMC's FQDNs='$2'", | 
|  | host, primary_fqdn, absl::StrJoin(GetInterfaceFqdns(), " "))); | 
|  | } | 
|  |  | 
|  | }  // namespace milotic::authz |