Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 1 | // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | #include "chrome/browser/chromeos/policy/auto_enrollment_client.h" |
| 6 | |
| 7 | #include "base/bind.h" |
| 8 | #include "base/command_line.h" |
| 9 | #include "base/guid.h" |
| 10 | #include "base/location.h" |
| 11 | #include "base/logging.h" |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 12 | #include "base/message_loop/message_loop_proxy.h" |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 13 | #include "base/metrics/histogram.h" |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 14 | #include "base/metrics/sparse_histogram.h" |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 15 | #include "base/prefs/pref_registry_simple.h" |
| 16 | #include "base/prefs/pref_service.h" |
| 17 | #include "base/strings/string_number_conversions.h" |
| 18 | #include "chrome/browser/browser_process.h" |
| 19 | #include "chrome/browser/chromeos/policy/device_cloud_policy_manager_chromeos.h" |
| 20 | #include "chrome/browser/policy/browser_policy_connector.h" |
| 21 | #include "chrome/browser/policy/cloud/device_management_service.h" |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 22 | #include "chrome/common/pref_names.h" |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 23 | #include "chromeos/chromeos_switches.h" |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 24 | #include "crypto/sha2.h" |
| 25 | |
| 26 | namespace em = enterprise_management; |
| 27 | |
| 28 | namespace { |
| 29 | |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 30 | // UMA histogram names. |
| 31 | const char kUMAProtocolTime[] = "Enterprise.AutoEnrollmentProtocolTime"; |
| 32 | const char kUMAExtraTime[] = "Enterprise.AutoEnrollmentExtraTime"; |
| 33 | const char kUMARequestStatus[] = "Enterprise.AutoEnrollmentRequestStatus"; |
| 34 | const char kUMANetworkErrorCode[] = |
| 35 | "Enterprise.AutoEnrollmentRequestNetworkErrorCode"; |
| 36 | |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 37 | // The modulus value is sent in an int64 field in the protobuf, whose maximum |
| 38 | // value is 2^63-1. So 2^64 and 2^63 can't be represented as moduli and the |
| 39 | // max is 2^62 (when the moduli are restricted to powers-of-2). |
| 40 | const int kMaximumPower = 62; |
| 41 | |
| 42 | // Returns the int value of the |switch_name| argument, clamped to the [0, 62] |
| 43 | // interval. Returns 0 if the argument doesn't exist or isn't an int value. |
| 44 | int GetSanitizedArg(const std::string& switch_name) { |
| 45 | CommandLine* command_line = CommandLine::ForCurrentProcess(); |
| 46 | if (!command_line->HasSwitch(switch_name)) |
| 47 | return 0; |
| 48 | std::string value = command_line->GetSwitchValueASCII(switch_name); |
| 49 | int int_value; |
| 50 | if (!base::StringToInt(value, &int_value)) { |
| 51 | LOG(ERROR) << "Switch \"" << switch_name << "\" is not a valid int. " |
| 52 | << "Defaulting to 0."; |
| 53 | return 0; |
| 54 | } |
| 55 | if (int_value < 0) { |
| 56 | LOG(ERROR) << "Switch \"" << switch_name << "\" can't be negative. " |
| 57 | << "Using 0"; |
| 58 | return 0; |
| 59 | } |
| 60 | if (int_value > kMaximumPower) { |
| 61 | LOG(ERROR) << "Switch \"" << switch_name << "\" can't be greater than " |
| 62 | << kMaximumPower << ". Using " << kMaximumPower; |
| 63 | return kMaximumPower; |
| 64 | } |
| 65 | return int_value; |
| 66 | } |
| 67 | |
| 68 | // Returns the power of the next power-of-2 starting at |value|. |
| 69 | int NextPowerOf2(int64 value) { |
| 70 | for (int i = 0; i <= kMaximumPower; ++i) { |
| 71 | if ((GG_INT64_C(1) << i) >= value) |
| 72 | return i; |
| 73 | } |
| 74 | // No other value can be represented in an int64. |
| 75 | return kMaximumPower + 1; |
| 76 | } |
| 77 | |
| 78 | } // namespace |
| 79 | |
| 80 | namespace policy { |
| 81 | |
| 82 | AutoEnrollmentClient::AutoEnrollmentClient(const base::Closure& callback, |
| 83 | DeviceManagementService* service, |
| 84 | PrefService* local_state, |
| 85 | const std::string& serial_number, |
| 86 | int power_initial, |
| 87 | int power_limit) |
| 88 | : completion_callback_(callback), |
| 89 | should_auto_enroll_(false), |
| 90 | device_id_(base::GenerateGUID()), |
| 91 | power_initial_(power_initial), |
| 92 | power_limit_(power_limit), |
| 93 | requests_sent_(0), |
| 94 | device_management_service_(service), |
| 95 | local_state_(local_state) { |
| 96 | DCHECK_LE(power_initial_, power_limit_); |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 97 | DCHECK(!completion_callback_.is_null()); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 98 | if (!serial_number.empty()) |
| 99 | serial_number_hash_ = crypto::SHA256HashString(serial_number); |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 100 | net::NetworkChangeNotifier::AddNetworkChangeObserver(this); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 101 | } |
| 102 | |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 103 | AutoEnrollmentClient::~AutoEnrollmentClient() { |
| 104 | net::NetworkChangeNotifier::RemoveNetworkChangeObserver(this); |
| 105 | } |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 106 | |
| 107 | // static |
| 108 | void AutoEnrollmentClient::RegisterPrefs(PrefRegistrySimple* registry) { |
| 109 | registry->RegisterBooleanPref(prefs::kShouldAutoEnroll, false); |
| 110 | registry->RegisterIntegerPref(prefs::kAutoEnrollmentPowerLimit, -1); |
| 111 | } |
| 112 | |
| 113 | // static |
| 114 | bool AutoEnrollmentClient::IsDisabled() { |
| 115 | CommandLine* command_line = CommandLine::ForCurrentProcess(); |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 116 | return !command_line->HasSwitch( |
| 117 | chromeos::switches::kEnterpriseEnrollmentInitialModulus) && |
| 118 | !command_line->HasSwitch( |
| 119 | chromeos::switches::kEnterpriseEnrollmentModulusLimit); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 120 | } |
| 121 | |
| 122 | // static |
| 123 | AutoEnrollmentClient* AutoEnrollmentClient::Create( |
| 124 | const base::Closure& completion_callback) { |
| 125 | // The client won't do anything if |service| is NULL. |
| 126 | DeviceManagementService* service = NULL; |
| 127 | if (IsDisabled()) { |
| 128 | VLOG(1) << "Auto-enrollment is disabled"; |
| 129 | } else { |
| 130 | std::string url = BrowserPolicyConnector::GetDeviceManagementUrl(); |
| 131 | if (!url.empty()) { |
| 132 | service = new DeviceManagementService(url); |
| 133 | service->ScheduleInitialization(0); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | int power_initial = GetSanitizedArg( |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 138 | chromeos::switches::kEnterpriseEnrollmentInitialModulus); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 139 | int power_limit = GetSanitizedArg( |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 140 | chromeos::switches::kEnterpriseEnrollmentModulusLimit); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 141 | if (power_initial > power_limit) { |
| 142 | LOG(ERROR) << "Initial auto-enrollment modulus is larger than the limit, " |
| 143 | << "clamping to the limit."; |
| 144 | power_initial = power_limit; |
| 145 | } |
| 146 | |
| 147 | return new AutoEnrollmentClient( |
| 148 | completion_callback, |
| 149 | service, |
| 150 | g_browser_process->local_state(), |
| 151 | DeviceCloudPolicyManagerChromeOS::GetMachineID(), |
| 152 | power_initial, |
| 153 | power_limit); |
| 154 | } |
| 155 | |
| 156 | // static |
| 157 | void AutoEnrollmentClient::CancelAutoEnrollment() { |
| 158 | PrefService* local_state = g_browser_process->local_state(); |
| 159 | local_state->SetBoolean(prefs::kShouldAutoEnroll, false); |
| 160 | local_state->CommitPendingWrite(); |
| 161 | } |
| 162 | |
| 163 | void AutoEnrollmentClient::Start() { |
| 164 | // Drop the previous job and reset state. |
| 165 | request_job_.reset(); |
| 166 | should_auto_enroll_ = false; |
| 167 | time_start_ = base::Time(); // reset to null. |
| 168 | |
| 169 | if (GetCachedDecision()) { |
| 170 | VLOG(1) << "AutoEnrollmentClient: using cached decision: " |
| 171 | << should_auto_enroll_; |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 172 | } else if (device_management_service_) { |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 173 | if (serial_number_hash_.empty()) { |
| 174 | LOG(ERROR) << "Failed to get the hash of the serial number, " |
| 175 | << "will not attempt to auto-enroll."; |
| 176 | } else { |
| 177 | time_start_ = base::Time::Now(); |
| 178 | SendRequest(power_initial_); |
| 179 | // Don't invoke the callback now. |
| 180 | return; |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // Auto-enrollment can't even start, so we're done. |
| 185 | OnProtocolDone(); |
| 186 | } |
| 187 | |
| 188 | void AutoEnrollmentClient::CancelAndDeleteSoon() { |
| 189 | if (time_start_.is_null()) { |
| 190 | // The client isn't running, just delete it. |
| 191 | delete this; |
| 192 | } else { |
| 193 | // Client still running, but our owner isn't interested in the result |
| 194 | // anymore. Wait until the protocol completes to measure the extra time |
| 195 | // needed. |
| 196 | time_extra_start_ = base::Time::Now(); |
| 197 | completion_callback_.Reset(); |
| 198 | } |
| 199 | } |
| 200 | |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 201 | void AutoEnrollmentClient::OnNetworkChanged( |
| 202 | net::NetworkChangeNotifier::ConnectionType type) { |
| 203 | if (GetCachedDecision()) { |
| 204 | // A previous request already obtained a definitive response from the |
| 205 | // server, so there is no point in retrying; it will get the same decision. |
| 206 | return; |
| 207 | } |
| 208 | |
| 209 | if (type != net::NetworkChangeNotifier::CONNECTION_NONE && |
| 210 | !completion_callback_.is_null() && |
| 211 | !request_job_ && |
| 212 | device_management_service_ && |
| 213 | !serial_number_hash_.empty()) { |
| 214 | VLOG(1) << "Retrying auto enrollment check after network changed"; |
| 215 | time_start_ = base::Time::Now(); |
| 216 | SendRequest(power_initial_); |
| 217 | } |
| 218 | } |
| 219 | |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 220 | bool AutoEnrollmentClient::GetCachedDecision() { |
| 221 | const PrefService::Preference* should_enroll_pref = |
| 222 | local_state_->FindPreference(prefs::kShouldAutoEnroll); |
| 223 | const PrefService::Preference* previous_limit_pref = |
| 224 | local_state_->FindPreference(prefs::kAutoEnrollmentPowerLimit); |
| 225 | bool should_auto_enroll = false; |
| 226 | int previous_limit = -1; |
| 227 | |
| 228 | if (!should_enroll_pref || |
| 229 | should_enroll_pref->IsDefaultValue() || |
| 230 | !should_enroll_pref->GetValue()->GetAsBoolean(&should_auto_enroll) || |
| 231 | !previous_limit_pref || |
| 232 | previous_limit_pref->IsDefaultValue() || |
| 233 | !previous_limit_pref->GetValue()->GetAsInteger(&previous_limit) || |
| 234 | power_limit_ > previous_limit) { |
| 235 | return false; |
| 236 | } |
| 237 | |
| 238 | should_auto_enroll_ = should_auto_enroll; |
| 239 | return true; |
| 240 | } |
| 241 | |
| 242 | void AutoEnrollmentClient::SendRequest(int power) { |
| 243 | if (power < 0 || power > power_limit_ || serial_number_hash_.empty()) { |
| 244 | NOTREACHED(); |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 245 | OnRequestDone(); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 246 | return; |
| 247 | } |
| 248 | |
| 249 | requests_sent_++; |
| 250 | |
| 251 | // Only power-of-2 moduli are supported for now. These are computed by taking |
| 252 | // the lower |power| bits of the hash. |
| 253 | uint64 remainder = 0; |
| 254 | for (int i = 0; 8 * i < power; ++i) { |
| 255 | uint64 byte = serial_number_hash_[31 - i] & 0xff; |
| 256 | remainder = remainder | (byte << (8 * i)); |
| 257 | } |
| 258 | remainder = remainder & ((GG_UINT64_C(1) << power) - 1); |
| 259 | |
| 260 | request_job_.reset( |
| 261 | device_management_service_->CreateJob( |
| 262 | DeviceManagementRequestJob::TYPE_AUTO_ENROLLMENT)); |
| 263 | request_job_->SetClientID(device_id_); |
| 264 | em::DeviceAutoEnrollmentRequest* request = |
| 265 | request_job_->GetRequest()->mutable_auto_enrollment_request(); |
| 266 | request->set_remainder(remainder); |
| 267 | request->set_modulus(GG_INT64_C(1) << power); |
| 268 | request_job_->Start(base::Bind(&AutoEnrollmentClient::OnRequestCompletion, |
| 269 | base::Unretained(this))); |
| 270 | } |
| 271 | |
| 272 | void AutoEnrollmentClient::OnRequestCompletion( |
| 273 | DeviceManagementStatus status, |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 274 | int net_error, |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 275 | const em::DeviceManagementResponse& response) { |
| 276 | if (status != DM_STATUS_SUCCESS || !response.has_auto_enrollment_response()) { |
| 277 | LOG(ERROR) << "Auto enrollment error: " << status; |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 278 | UMA_HISTOGRAM_SPARSE_SLOWLY(kUMARequestStatus, status); |
| 279 | if (status == DM_STATUS_REQUEST_FAILED) |
| 280 | UMA_HISTOGRAM_SPARSE_SLOWLY(kUMANetworkErrorCode, -net_error); |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 281 | // The client will retry if a network change is detected. |
| 282 | OnRequestDone(); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 283 | return; |
| 284 | } |
| 285 | |
| 286 | const em::DeviceAutoEnrollmentResponse& enrollment_response = |
| 287 | response.auto_enrollment_response(); |
| 288 | if (enrollment_response.has_expected_modulus()) { |
| 289 | // Server is asking us to retry with a different modulus. |
| 290 | int64 modulus = enrollment_response.expected_modulus(); |
| 291 | int power = NextPowerOf2(modulus); |
| 292 | if ((GG_INT64_C(1) << power) != modulus) { |
| 293 | LOG(WARNING) << "Auto enrollment: the server didn't ask for a power-of-2 " |
| 294 | << "modulus. Using the closest power-of-2 instead " |
| 295 | << "(" << modulus << " vs 2^" << power << ")"; |
| 296 | } |
| 297 | if (requests_sent_ >= 2) { |
| 298 | LOG(ERROR) << "Auto enrollment error: already retried with an updated " |
| 299 | << "modulus but the server asked for a new one again: " |
| 300 | << power; |
| 301 | } else if (power > power_limit_) { |
| 302 | LOG(ERROR) << "Auto enrollment error: the server asked for a larger " |
| 303 | << "modulus than the client accepts (" << power << " vs " |
| 304 | << power_limit_ << ")."; |
| 305 | } else { |
| 306 | // Retry at most once with the modulus that the server requested. |
| 307 | if (power <= power_initial_) { |
| 308 | LOG(WARNING) << "Auto enrollment: the server asked to use a modulus (" |
| 309 | << power << ") that isn't larger than the first used (" |
| 310 | << power_initial_ << "). Retrying anyway."; |
| 311 | } |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 312 | // Remember this value, so that eventual retries start with the correct |
| 313 | // modulus. |
| 314 | power_initial_ = power; |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 315 | SendRequest(power); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 316 | return; |
| 317 | } |
| 318 | } else { |
| 319 | // Server should have sent down a list of hashes to try. |
| 320 | should_auto_enroll_ = IsSerialInProtobuf(enrollment_response.hash()); |
Torne (Richard Coles) | 90dce4d | 2013-05-29 14:40:03 +0100 | [diff] [blame] | 321 | // Cache the current decision in local_state, so that it is reused in case |
| 322 | // the device reboots before enrolling. |
| 323 | local_state_->SetBoolean(prefs::kShouldAutoEnroll, should_auto_enroll_); |
| 324 | local_state_->SetInteger(prefs::kAutoEnrollmentPowerLimit, power_limit_); |
| 325 | local_state_->CommitPendingWrite(); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 326 | LOG(INFO) << "Auto enrollment complete, should_auto_enroll = " |
| 327 | << should_auto_enroll_; |
| 328 | } |
| 329 | |
| 330 | // Auto-enrollment done. |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 331 | UMA_HISTOGRAM_SPARSE_SLOWLY(kUMARequestStatus, DM_STATUS_SUCCESS); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 332 | OnProtocolDone(); |
| 333 | } |
| 334 | |
| 335 | bool AutoEnrollmentClient::IsSerialInProtobuf( |
| 336 | const google::protobuf::RepeatedPtrField<std::string>& hashes) { |
| 337 | for (int i = 0; i < hashes.size(); ++i) { |
| 338 | if (hashes.Get(i) == serial_number_hash_) |
| 339 | return true; |
| 340 | } |
| 341 | return false; |
| 342 | } |
| 343 | |
| 344 | void AutoEnrollmentClient::OnProtocolDone() { |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 345 | // The mininum time can't be 0, must be at least 1. |
| 346 | static const base::TimeDelta kMin = base::TimeDelta::FromMilliseconds(1); |
| 347 | static const base::TimeDelta kMax = base::TimeDelta::FromMinutes(5); |
| 348 | // However, 0 can still be sampled. |
| 349 | static const base::TimeDelta kZero = base::TimeDelta::FromMilliseconds(0); |
| 350 | static const int kBuckets = 50; |
| 351 | |
| 352 | base::Time now = base::Time::Now(); |
| 353 | if (!time_start_.is_null()) { |
| 354 | base::TimeDelta delta = now - time_start_; |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 355 | UMA_HISTOGRAM_CUSTOM_TIMES(kUMAProtocolTime, delta, kMin, kMax, kBuckets); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 356 | } |
| 357 | base::TimeDelta delta = kZero; |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 358 | if (!time_extra_start_.is_null()) |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 359 | delta = now - time_extra_start_; |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 360 | // This samples |kZero| when there was no need for extra time, so that we can |
| 361 | // measure the ratio of users that succeeded without needing a delay to the |
| 362 | // total users going through OOBE. |
Torne (Richard Coles) | b2df76e | 2013-05-13 16:52:09 +0100 | [diff] [blame] | 363 | UMA_HISTOGRAM_CUSTOM_TIMES(kUMAExtraTime, delta, kMin, kMax, kBuckets); |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 364 | |
| 365 | if (!completion_callback_.is_null()) |
| 366 | completion_callback_.Run(); |
Ben Murdoch | bb1529c | 2013-08-08 10:24:53 +0100 | [diff] [blame^] | 367 | |
| 368 | OnRequestDone(); |
| 369 | } |
| 370 | |
| 371 | void AutoEnrollmentClient::OnRequestDone() { |
| 372 | request_job_.reset(); |
| 373 | time_start_ = base::Time(); |
| 374 | |
| 375 | if (completion_callback_.is_null()) { |
| 376 | // CancelAndDeleteSoon() was invoked before. |
| 377 | base::MessageLoopProxy::current()->DeleteSoon(FROM_HERE, this); |
| 378 | } |
Torne (Richard Coles) | 2a99a7e | 2013-03-28 15:31:22 +0000 | [diff] [blame] | 379 | } |
| 380 | |
| 381 | } // namespace policy |