Commit 45c4ef83 authored by Xiaoling Bao's avatar Xiaoling Bao Committed by Commit Bot

Add policy fetch support.

Bug: 1068797
Change-Id: Iaf56825d95fdb1ba502dfb2d8538f288192e3c28
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2332572
Commit-Queue: Xiaoling Bao <xiaolingbao@chromium.org>
Reviewed-by: default avatarSorin Jianu <sorin@chromium.org>
Cr-Commit-Position: refs/heads/master@{#794656}
parent 02b24a76
......@@ -262,6 +262,21 @@ if (is_win || is_mac) {
}
}
source_set("updater_tests_support") {
testonly = true
sources = [
"unittest_util.cc",
"unittest_util.h",
]
deps = [
":base",
":lib",
"//base",
]
}
test("updater_tests") {
testonly = true
......@@ -277,8 +292,6 @@ if (is_win || is_mac) {
"tag_unittest.cc",
"test/integration_tests.cc",
"test/integration_tests.h",
"unittest_util.cc",
"unittest_util.h",
"unittest_util_unittest.cc",
"updater_unittest.cc",
]
......@@ -287,6 +300,7 @@ if (is_win || is_mac) {
":base",
":lib",
":updater",
":updater_tests_support",
":version_header",
"//base",
"//base/test:test_support",
......
......@@ -69,6 +69,7 @@ source_set("unittest") {
"//base",
"//base/test:test_support",
"//chrome/updater:base",
"//chrome/updater:updater_tests_support",
"//chrome/updater/protos:omaha_proto",
"//components/update_client:update_client",
"//net:test_support",
......
......@@ -12,6 +12,7 @@
#include "base/threading/sequenced_task_runner_handle.h"
#include "build/build_config.h"
#include "chrome/updater/constants.h"
#include "chrome/updater/device_management/dm_cached_policy_info.h"
#include "chrome/updater/device_management/dm_storage.h"
#include "chrome/updater/updater_version.h"
#include "chrome/updater/util.h"
......@@ -46,6 +47,11 @@ constexpr char kAuthorizationHeader[] = "Authorization";
constexpr char kRegistrationRequestType[] = "register_policy_agent";
constexpr char kRegistrationTokenType[] = "GoogleEnrollmentToken";
// Constants for policy fetch requests.
constexpr char kPolicyFetchRequestType[] = "policy";
constexpr char kPolicyFetchTokenType[] = "GoogleDMToken";
constexpr char kGoogleUpdateMachineLevelApps[] = "google/machine-level-apps";
constexpr int kHTTPStatusOK = 200;
constexpr int kHTTPStatusGone = 410;
......@@ -154,18 +160,19 @@ void DMClient::PostRegisterRequest(DMRequestCallback request_callback) {
policy::GetOSVersion());
// Authorization token is the enrollment token for device registration.
base::flat_map<std::string, std::string> additional_headers;
additional_headers.emplace(
kAuthorizationHeader,
base::StringPrintf("%s token=%s", kRegistrationTokenType,
enrollment_token.c_str()));
const base::flat_map<std::string, std::string> additional_headers = {
{kAuthorizationHeader,
base::StringPrintf("%s token=%s", kRegistrationTokenType,
enrollment_token.c_str())},
};
network_fetcher_->PostRequest(
BuildURL(kRegistrationRequestType), data, kDMContentType,
additional_headers,
base::BindOnce(&DMClient::OnRequestStarted, base::Unretained(this)),
base::BindRepeating(&DMClient::OnRequestProgress, base::Unretained(this)),
base::BindOnce(&DMClient::OnRequestComplete, base::Unretained(this)));
base::BindOnce(&DMClient::OnRegisterRequestComplete,
base::Unretained(this)));
}
void DMClient::OnRequestStarted(int response_code, int64_t content_length) {
......@@ -180,11 +187,12 @@ void DMClient::OnRequestProgress(int64_t current) {
VLOG(1) << "POST request progess made, current bytes: " << current;
}
void DMClient::OnRequestComplete(std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec) {
void DMClient::OnRegisterRequestComplete(
std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
RequestResult request_result = RequestResult::kSuccess;
......@@ -206,7 +214,91 @@ void DMClient::OnRequestComplete(std::unique_ptr<std::string> response_body,
request_result = RequestResult::kUnexpectedResponse;
} else {
VLOG(1) << "Register request completed, got DM token: " << dm_token;
storage_->StoreDmToken(dm_token);
if (!storage_->StoreDmToken(dm_token))
request_result = RequestResult::kSerializationError;
}
}
base::SequencedTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(request_callback_), request_result));
}
void DMClient::PostPolicyFetchRequest(DMRequestCallback request_callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
RequestResult result = RequestResult::kSuccess;
const std::string dm_token = storage_->GetDmToken();
network_fetcher_ = config_->CreateNetworkFetcher();
request_callback_ = std::move(request_callback);
if (storage_->IsDeviceDeregistered()) {
result = RequestResult::kDeregistered;
} else if (dm_token.empty()) {
result = RequestResult::kNoDMToken;
} else if (storage_->GetDeviceID().empty()) {
result = RequestResult::kNoDeviceID;
} else if (!network_fetcher_) {
result = RequestResult::kFetcherError;
}
if (result != RequestResult::kSuccess) {
LOG(ERROR) << "Policy fetch skipped with DM error: "
<< static_cast<int>(result);
base::SequencedTaskRunnerHandle::Get()->PostTask(
FROM_HERE, base::BindOnce(std::move(request_callback_), result));
return;
}
cached_info_ = storage_->GetCachedPolicyInfo();
const std::string data =
GetPolicyFetchRequestData(kGoogleUpdateMachineLevelApps, *cached_info_);
// Authorization token is the DM token for policy fetch
const base::flat_map<std::string, std::string> additional_headers = {
{kAuthorizationHeader,
base::StringPrintf("%s token=%s", kPolicyFetchTokenType,
dm_token.c_str())},
};
network_fetcher_->PostRequest(
BuildURL(kPolicyFetchRequestType), data, kDMContentType,
additional_headers,
base::BindOnce(&DMClient::OnRequestStarted, base::Unretained(this)),
base::BindRepeating(&DMClient::OnRequestProgress, base::Unretained(this)),
base::BindOnce(&DMClient::OnPolicyFetchRequestComplete,
base::Unretained(this)));
}
void DMClient::OnPolicyFetchRequestComplete(
std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
RequestResult request_result = RequestResult::kSuccess;
if (net_error != 0) {
LOG(ERROR) << "DM policy fetch failed due to net error: " << net_error;
request_result = RequestResult::kNetworkError;
} else if (http_status_code_ == kHTTPStatusGone) {
VLOG(1) << "Device is now de-registered.";
storage_->DeregisterDevice();
} else if (http_status_code_ != kHTTPStatusOK) {
LOG(ERROR) << "DM policy fetch failed due to http error: "
<< http_status_code_;
request_result = RequestResult::kHttpError;
} else {
DMPolicyMap policies = ParsePolicyFetchResponse(
*response_body, *cached_info_, storage_->GetDmToken(),
storage_->GetDeviceID());
if (policies.empty()) {
request_result = RequestResult::kUnexpectedResponse;
} else {
VLOG(1) << "Policy fetch request completed, got " << policies.size()
<< " new policies.";
if (!storage_->PersistPolicies(policies))
request_result = RequestResult::kSerializationError;
}
}
......
......@@ -22,6 +22,7 @@ class NetworkFetcher;
namespace updater {
class CachedPolicyInfo;
class DMStorage;
// This class is responsible for everything related to communication with the
......@@ -63,6 +64,9 @@ class DMClient {
// Request is not sent because the device is de-registered.
kDeregistered,
// Policy fetch request is not sent because there's no DM token.
kNoDMToken,
// Request is not sent because network fetcher fails to create.
kFetcherError,
......@@ -72,6 +76,9 @@ class DMClient {
// Request failed with an HTTP error from server.
kHttpError,
// Failed to persist the response into storage.
kSerializationError,
// Got an unexpected response for the request.
kUnexpectedResponse,
};
......@@ -92,6 +99,10 @@ class DMClient {
// token is saved into the storage before |request_callback| is called.
void PostRegisterRequest(DMRequestCallback request_callback);
// Posts a policy fetch request to the server. Upon success, new polices
// are saved into the storage before |request_callback| is called.
void PostPolicyFetchRequest(DMRequestCallback request_callback);
private:
// Gets the full request URL to DM server for the given request type.
// Additional device specific values, such as device ID, platform etc. will
......@@ -101,14 +112,21 @@ class DMClient {
// Callback functions for the URLFetcher.
void OnRequestStarted(int response_code, int64_t content_length);
void OnRequestProgress(int64_t current);
void OnRequestComplete(std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec);
void OnRegisterRequestComplete(std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec);
void OnPolicyFetchRequestComplete(
std::unique_ptr<std::string> response_body,
int net_error,
const std::string& header_etag,
const std::string& header_x_cup_server_proof,
int64_t xheader_retry_after_sec);
std::unique_ptr<Configurator> config_;
scoped_refptr<DMStorage> storage_;
std::unique_ptr<CachedPolicyInfo> cached_info_;
std::unique_ptr<update_client::NetworkFetcher> network_fetcher_;
DMRequestCallback request_callback_;
......
......@@ -10,6 +10,7 @@
#include "base/strings/string_util.h"
#include "chrome/updater/device_management/dm_cached_policy_info.h"
#include "chrome/updater/protos/omaha_settings.pb.h"
#include "chrome/updater/unittest_util.h"
#include "components/policy/proto/device_management_backend.pb.h"
#include "crypto/rsa_private_key.h"
#include "crypto/signature_creator.h"
......@@ -136,6 +137,8 @@ const uint8_t kSigningKey2Signature[] = {
0xDD, 0x6F, 0x80, 0xC3,
};
} // namespace
std::unique_ptr<DMSigningKeyForTesting> GetTestKey1() {
constexpr int kFakeKeyVersion = 5;
return std::make_unique<DMSigningKeyForTesting>(
......@@ -150,8 +153,6 @@ std::unique_ptr<DMSigningKeyForTesting> GetTestKey2() {
sizeof(kSigningKey2Signature), kFakeKeyVersion, "example.com");
}
} // namespace
std::unique_ptr<
::wireless_android_enterprise_devicemanagement::OmahaSettingsClientProto>
GetDefaultTestingOmahaPolicyProto() {
......@@ -172,7 +173,7 @@ GetDefaultTestingOmahaPolicyProto() {
::wireless_android_enterprise_devicemanagement::MANUAL_UPDATES_ONLY);
::wireless_android_enterprise_devicemanagement::ApplicationSettings app;
app.set_app_guid("{8A69D345-D564-463C-AFF1-A69D9E530F96}");
app.set_app_guid(kChromeAppId);
app.set_install(
::wireless_android_enterprise_devicemanagement::INSTALL_DISABLED);
app.set_update(
......
......@@ -61,6 +61,9 @@ class DMSigningKeyForTesting {
std::string key_signature_domain_;
};
std::unique_ptr<DMSigningKeyForTesting> GetTestKey1();
std::unique_ptr<DMSigningKeyForTesting> GetTestKey2();
// Builds DM policy response.
class DMPolicyBuilderForTesting {
public:
......
......@@ -6,6 +6,7 @@
#include "build/build_config.h"
#include "chrome/updater/constants.h"
#include "chrome/updater/unittest_util.h"
#include "components/policy/proto/device_management_backend.pb.h"
#include "testing/gtest/include/gtest/gtest.h"
......@@ -138,19 +139,18 @@ TEST(DMPolicyManager, PolicyManagerFromEmptyProto) {
EXPECT_FALSE(policy_manager->GetPackageCacheExpirationTimeDays(&time_limit));
// Verify app-specific polices.
const std::string chrome_guid = "{8A69D345-D564-463C-AFF1-A69D9E530F96}";
int install_policy = -1;
EXPECT_FALSE(policy_manager->GetEffectivePolicyForAppInstalls(
chrome_guid, &install_policy));
kChromeAppId, &install_policy));
int update_policy = -1;
EXPECT_FALSE(policy_manager->GetEffectivePolicyForAppUpdates(chrome_guid,
EXPECT_FALSE(policy_manager->GetEffectivePolicyForAppUpdates(kChromeAppId,
&update_policy));
bool rollback_allowed = false;
EXPECT_FALSE(policy_manager->IsRollbackToTargetVersionAllowed(
chrome_guid, &rollback_allowed));
kChromeAppId, &rollback_allowed));
std::string target_version_prefix;
EXPECT_FALSE(policy_manager->GetTargetVersionPrefix(chrome_guid,
EXPECT_FALSE(policy_manager->GetTargetVersionPrefix(kChromeAppId,
&target_version_prefix));
}
......@@ -171,7 +171,7 @@ TEST(DMPolicyManager, PolicyManagerFromProto) {
::wireless_android_enterprise_devicemanagement::MANUAL_UPDATES_ONLY);
::wireless_android_enterprise_devicemanagement::ApplicationSettings app;
app.set_app_guid("{8A69D345-D564-463C-AFF1-A69D9E530F96}");
app.set_app_guid(kChromeAppId);
app.set_install(
::wireless_android_enterprise_devicemanagement::INSTALL_DISABLED);
app.set_update(
......@@ -223,22 +223,21 @@ TEST(DMPolicyManager, PolicyManagerFromProto) {
EXPECT_FALSE(policy_manager->GetPackageCacheExpirationTimeDays(&time_limit));
// Verify app-specific polices.
const std::string chrome_guid = "{8A69D345-D564-463C-AFF1-A69D9E530F96}";
int install_policy = -1;
EXPECT_TRUE(policy_manager->GetEffectivePolicyForAppInstalls(
chrome_guid, &install_policy));
kChromeAppId, &install_policy));
EXPECT_EQ(install_policy, kPolicyDisabled);
int update_policy = -1;
EXPECT_TRUE(policy_manager->GetEffectivePolicyForAppUpdates(chrome_guid,
EXPECT_TRUE(policy_manager->GetEffectivePolicyForAppUpdates(kChromeAppId,
&update_policy));
EXPECT_EQ(update_policy, kPolicyAutomaticUpdatesOnly);
bool rollback_allowed = false;
EXPECT_TRUE(policy_manager->IsRollbackToTargetVersionAllowed(
chrome_guid, &rollback_allowed));
kChromeAppId, &rollback_allowed));
EXPECT_TRUE(rollback_allowed);
std::string target_version_prefix;
EXPECT_TRUE(policy_manager->GetTargetVersionPrefix(chrome_guid,
EXPECT_TRUE(policy_manager->GetTargetVersionPrefix(kChromeAppId,
&target_version_prefix));
EXPECT_EQ(target_version_prefix, "81.");
......
......@@ -95,16 +95,25 @@ bool DMStorage::IsDeviceDeregistered() const {
return GetDmToken() == kInvalidTokenValue;
}
bool DMStorage::PersistPolicies(const std::string& policy_info_data,
const DMPolicyMap& policy_map) const {
bool DMStorage::PersistPolicies(const DMPolicyMap& policy_map) const {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
// Persists policy cached info
base::FilePath policy_info_file =
policy_cache_root_.AppendASCII(kPolicyInfoFileName);
if (!base::ImportantFileWriter::WriteFileAtomically(policy_info_file,
policy_info_data)) {
return false;
if (policy_map.empty())
return true;
// Each policy in the map should be signed in the same way. If a policy
// in the map contains a public key, normally it means the server rotates the
// key. In this case, we persists the policy into the cached policy info file
// for future policy fetch.
const std::string policy_info_data = policy_map.cbegin()->second;
CachedPolicyInfo cached_info;
if (cached_info.Populate(policy_info_data) &&
!cached_info.public_key().empty()) {
base::FilePath policy_info_file =
policy_cache_root_.AppendASCII(kPolicyInfoFileName);
if (!base::ImportantFileWriter::WriteFileAtomically(policy_info_file,
policy_info_data)) {
return false;
}
}
// Persists individual policies.
......@@ -135,18 +144,18 @@ bool DMStorage::PersistPolicies(const std::string& policy_info_data,
std::unique_ptr<CachedPolicyInfo> DMStorage::GetCachedPolicyInfo() {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
auto cached_info = std::make_unique<CachedPolicyInfo>();
if (!IsValidDMToken())
return nullptr;
return cached_info;
base::FilePath policy_info_file =
policy_cache_root_.AppendASCII(kPolicyInfoFileName);
std::string policy_info_data;
auto cached_info = std::make_unique<CachedPolicyInfo>();
if (!base::PathExists(policy_info_file) ||
!base::ReadFileToString(policy_info_file, &policy_info_data) ||
!cached_info->Populate(policy_info_data)) {
return nullptr;
return cached_info;
}
return cached_info;
......
......@@ -88,11 +88,11 @@ class DMStorage : public base::RefCountedThreadSafe<DMStorage> {
// Persists DM policies.
//
// |policy_info_data| is the serialized data of a PolicyFetchResponse. It will
// be saved into a fixed file named "CachedPolicyInfo" in cache root. The
// file content will be used to construct an updater::CachedPolicyInfo object
// to get public key, its version, and signing timestamp. The values will
// be used in subsequent policy fetches.
// If the first policy in the map contains a valid public key, its serialized
// data will be saved into a fixed file named "CachedPolicyInfo" in the cache
// root. The file content will be used to construct an
// updater::CachedPolicyInfo object to get public key, its version, and
// signing timestamp. The values will be used in subsequent policy fetches.
//
// Each entry in |policy_map| will be stored within a sub-directory named
// {Base64Encoded{policy_type}}, with a fixed file name of
......@@ -113,8 +113,7 @@ class DMStorage : public base::RefCountedThreadSafe<DMStorage> {
// ('Z29vZ2xlL21hY2hpbmUtbGV2ZWwtb21haGE=' is base64 encoding of
// "google/machine-level-omaha").
//
bool PersistPolicies(const std::string& policy_info_data,
const DMPolicyMap& policy_map) const;
bool PersistPolicies(const DMPolicyMap& policy_map) const;
// Creates a CachedPolicyInfo object and populates it with the public key
// information loaded from file |policy_cache_root_|\CachedPolicyInfo.
......
......@@ -12,6 +12,7 @@
#include "chrome/updater/device_management/dm_storage.h"
#include "chrome/updater/policy_manager.h"
#include "chrome/updater/protos/omaha_settings.pb.h"
#include "chrome/updater/unittest_util.h"
#include "components/policy/proto/device_management_backend.pb.h"
#include "testing/gtest/include/gtest/gtest.h"
......@@ -63,7 +64,7 @@ std::string CannedOmahaPolicyFetchResponse() {
::wireless_android_enterprise_devicemanagement::MANUAL_UPDATES_ONLY);
::wireless_android_enterprise_devicemanagement::ApplicationSettings app;
app.set_app_guid("{8A69D345-D564-463C-AFF1-A69D9E530F96}");
app.set_app_guid(kChromeAppId);
app.set_install(
::wireless_android_enterprise_devicemanagement::INSTALL_DISABLED);
......@@ -111,13 +112,10 @@ TEST(DMStorage, PersistPolicies) {
EXPECT_TRUE(base::DirectoryExists(stale_poliy));
auto storage = base::MakeRefCounted<DMStorage>(cache_root.GetPath());
EXPECT_TRUE(storage->PersistPolicies("policy-meta-data", policies));
EXPECT_TRUE(storage->PersistPolicies(policies));
base::FilePath policy_info_file =
cache_root.GetPath().AppendASCII("CachedPolicyInfo");
EXPECT_TRUE(base::PathExists(policy_info_file));
std::string policy_info_data;
EXPECT_TRUE(base::ReadFileToString(policy_info_file, &policy_info_data));
EXPECT_EQ(policy_info_data, "policy-meta-data");
EXPECT_FALSE(base::PathExists(policy_info_file));
base::FilePath omaha_policy_file =
cache_root.GetPath()
......@@ -164,8 +162,9 @@ TEST(DMStorage, GetCachedPolicyInfo) {
ASSERT_TRUE(cache_root.CreateUniqueTempDir());
auto storage = base::MakeRefCounted<DMStorage>(
cache_root.GetPath(), std::make_unique<TestTokenService>());
EXPECT_TRUE(storage->PersistPolicies(response.SerializeAsString(),
/* policies map */ {}));
EXPECT_TRUE(storage->PersistPolicies({
{"sample-policy-type", response.SerializeAsString()},
}));
auto policy_info = storage->GetCachedPolicyInfo();
ASSERT_NE(policy_info, nullptr);
......@@ -176,17 +175,14 @@ TEST(DMStorage, GetCachedPolicyInfo) {
}
TEST(DMStorage, ReadCachedOmahaPolicy) {
// Persist the default testing omaha policy.
std::string omaha_policy_data = CannedOmahaPolicyFetchResponse();
DMPolicyMap policies({
{"google/machine-level-omaha", omaha_policy_data},
{"google/machine-level-omaha", CannedOmahaPolicyFetchResponse()},
});
base::ScopedTempDir cache_root;
ASSERT_TRUE(cache_root.CreateUniqueTempDir());
auto storage = base::MakeRefCounted<DMStorage>(
cache_root.GetPath(), std::make_unique<TestTokenService>());
EXPECT_TRUE(storage->PersistPolicies(omaha_policy_data, policies));
EXPECT_TRUE(storage->PersistPolicies(policies));
auto policy_manager = storage->GetOmahaPolicyManager();
ASSERT_NE(policy_manager, nullptr);
......@@ -228,22 +224,21 @@ TEST(DMStorage, ReadCachedOmahaPolicy) {
EXPECT_FALSE(policy_manager->GetPackageCacheExpirationTimeDays(&cache_life));
// Chrome policies.
const std::string chrome_appid = "{8A69D345-D564-463C-AFF1-A69D9E530F96}";
int chrome_install_policy = -1;
EXPECT_TRUE(policy_manager->GetEffectivePolicyForAppInstalls(
chrome_appid, &chrome_install_policy));
kChromeAppId, &chrome_install_policy));
EXPECT_EQ(chrome_install_policy, kPolicyDisabled);
int chrome_update_policy = -1;
EXPECT_TRUE(policy_manager->GetEffectivePolicyForAppUpdates(
chrome_appid, &chrome_update_policy));
kChromeAppId, &chrome_update_policy));
EXPECT_EQ(chrome_update_policy, kPolicyAutomaticUpdatesOnly);
std::string target_version_prefix;
EXPECT_TRUE(policy_manager->GetTargetVersionPrefix(chrome_appid,
EXPECT_TRUE(policy_manager->GetTargetVersionPrefix(kChromeAppId,
&target_version_prefix));
EXPECT_EQ(target_version_prefix, "3.6.55");
bool rollback_allowed = false;
EXPECT_TRUE(policy_manager->IsRollbackToTargetVersionAllowed(
chrome_appid, &rollback_allowed));
kChromeAppId, &rollback_allowed));
EXPECT_TRUE(rollback_allowed);
// No app-specific policy should fallback to global.
......
......@@ -8,6 +8,9 @@
#include "chrome/updater/tag.h"
namespace updater {
const char kChromeAppId[] = "{8A69D345-D564-463C-AFF1-A69D9E530F96}";
namespace tagging {
std::ostream& operator<<(std::ostream& os, const ErrorCode& error_code) {
......
......@@ -29,6 +29,8 @@ std::ostream& operator<<(std::ostream& os, const base::Optional<T>& opt) {
// Externally-defined printers for chrome/updater-related types.
namespace updater {
extern const char kChromeAppId[];
namespace tagging {
std::ostream& operator<<(std::ostream&, const ErrorCode&);
std::ostream& operator<<(std::ostream&, const AppArgs::NeedsAdmin&);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment