| #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; |
| } |
| } |
| |
| 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 |