From d1f38a81f2272f22a8892552a12f27497b2decf0 Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Mon, 1 May 2017 15:48:20 -0700 Subject: [PATCH 1/9] Temp keys onboarding --- .../Controllers/SecurityPolicyController.cs | 109 ++++++++++++ .../ViewModels/SecurityPolicySearchResult.cs | 38 ++++ .../ViewModels/SecurityPolicyViewModel.cs | 28 +++ .../Areas/Admin/Views/Home/Index.cshtml | 11 ++ .../Admin/Views/SecurityPolicy/Index.cshtml | 168 ++++++++++++++++++ src/NuGetGallery/NuGetGallery.csproj | 6 + .../Security/UserSecurityPolicyExtensions.cs | 77 ++++++++ .../Security/UserSecurityPolicyGroup.cs | 81 +++++++++ .../NuGetGallery.Facts.csproj | 2 + .../UserSecurityPolicyExtensionsFacts.cs | 134 ++++++++++++++ .../Security/UserSecurityPolicyGroupFacts.cs | 68 +++++++ 11 files changed, 722 insertions(+) create mode 100644 src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs create mode 100644 src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs create mode 100644 src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs create mode 100644 src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml create mode 100644 src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs create mode 100644 src/NuGetGallery/Security/UserSecurityPolicyGroup.cs create mode 100644 tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs create mode 100644 tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs new file mode 100644 index 0000000000..76fa0a0d94 --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web.Mvc; +using NuGetGallery.Areas.Admin.ViewModels; +using NuGetGallery.Security; + +namespace NuGetGallery.Areas.Admin.Controllers +{ + public class SecurityPolicyController : AdminControllerBase + { + public IEntitiesContext EntitiesContext { get; } + + public SecurityPolicyController(IEntitiesContext entitiesContext) + { + EntitiesContext = entitiesContext; + } + + [HttpGet] + public virtual ActionResult Index() + { + var model = new SecurityPolicyViewModel() + { + PolicyGroups = UserSecurityPolicyGroup.Instances.Select(pg => pg.Name) + }; + + return View(model); + } + + [HttpGet] + public virtual ActionResult Search(string query) + { + // Parse query and look for users in the DB. + var usernames = GetUsernamesFromQuery(query); + var users = FindUsers(usernames); + var usersNotFound = usernames + .Where(name => !users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + var results = new SecurityPolicySearchResult() + { + // Found users and enrollment status for each policy group. + Users = users.Select(u => new SecurityPolicyEnrollments() + { + Username = u.Username, + Enrollments = UserSecurityPolicyGroup.Instances.ToDictionary( + pg => pg.Name, + pg => u.IsEnrolled(pg)) + }), + // Usernames that weren't found in the DB. + UsersNotFound = usersNotFound + }; + + return Json(results, JsonRequestBehavior.AllowGet); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public ActionResult Enroll(SecurityPolicyViewModel viewModel) + { + // Parse 'username|policyGroup' into enrollment requests by user. + var enrollments = viewModel.Enrollments + .Select(e => e.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + .GroupBy(e => /*username*/e[0]) + .ToDictionary( + g => g.Key, + g => g.Select(e => /*policyGroup*/e[1]) + ); + + // Iterate all users and policies to identity groups for both enrollment and unenrollment. + var usernames = GetUsernamesFromQuery(viewModel.Query); + var users = FindUsers(usernames); + foreach (var user in users) + { + foreach (var policyGroup in UserSecurityPolicyGroup.Instances) + { + if (enrollments[user.Username].Contains(policyGroup.Name)) + { + user.EnsureEnrolled(policyGroup); + } + else + { + user.EnsureUnenrolled(policyGroup); + } + } + } + + EntitiesContext.SaveChangesAsync(); + + return RedirectToRoute("Admin"); + } + + private static string[] GetUsernamesFromQuery(string query) + { + return query.Split(new[] { ',', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(username => username.Trim()).ToArray(); + } + + private IEnumerable FindUsers(string[] usernames) + { + return EntitiesContext.Users + .Where(u => usernames.Any(name => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs new file mode 100644 index 0000000000..c79b485ac8 --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace NuGetGallery.Areas.Admin.ViewModels +{ + /// + /// User search results for the security policies admin view. + /// + public class SecurityPolicySearchResult + { + /// + /// Found users, with security policy group enrollments. + /// + public IEnumerable Users { get; set; } + + /// + /// Usernames not found in the database. + /// + public IEnumerable UsersNotFound { get; set; } + } + + /// + /// Security policy group enrollments for a user. + /// + public class SecurityPolicyEnrollments + { + public int UserId { get; set; } + + public string Username { get; set; } + + /// + /// Dictionary of security policy group names (key) and whether the user is enrolled. + /// + public IDictionary Enrollments { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs new file mode 100644 index 0000000000..e559fcb23d --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace NuGetGallery.Areas.Admin.ViewModels +{ + /// + /// View model for the security policies admin view. + /// + public class SecurityPolicyViewModel + { + /// + /// Users search query. + /// + public string Query { get; set; } + + /// + /// Available security policy groups. + /// + public IEnumerable PolicyGroups { get; set; } + + /// + /// Requested user enrollments to make. String format is 'username|policygroup'. + /// + public IEnumerable Enrollments { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/Views/Home/Index.cshtml b/src/NuGetGallery/Areas/Admin/Views/Home/Index.cshtml index 8c8d4f5429..ffbf5cc04c 100644 --- a/src/NuGetGallery/Areas/Admin/Views/Home/Index.cshtml +++ b/src/NuGetGallery/Areas/Admin/Views/Home/Index.cshtml @@ -83,5 +83,16 @@ Clear Content Cache

+
  • +

    + + + Security Policies + +

    +

    + Manage User Security Policies +

    +
  • diff --git a/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml new file mode 100644 index 0000000000..431caf8faf --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml @@ -0,0 +1,168 @@ +@model SecurityPolicyViewModel + +@{ + ViewBag.Title = "Security Policies"; +} + +
    +
    +

    User Security Policies

    + +
    +
    + +

    + + @using (Html.BeginForm("Enroll", "SecurityPolicy", new { area = "Admin" }, FormMethod.Post, new { id = "delete-form" })) + { +
    + + + + +
    + + + + + @foreach (var policyGroup in Model.PolicyGroups) + { + + } + + + + + + @foreach (var policyGroup in Model.PolicyGroups) + { + + } + + +
    Username@policyGroup
    +
    + + + +
    + } +
    +
    + +@section BottomScripts { + + +} \ No newline at end of file diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index 11698c8ae6..d08969f140 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -643,6 +643,7 @@ + Url_Edit.ascx @@ -678,6 +679,8 @@ + + @@ -749,6 +752,8 @@ 201705032101231_SecurityPoliciesFix.cs + + @@ -1714,6 +1719,7 @@ + diff --git a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs new file mode 100644 index 0000000000..20c7fe0dc4 --- /dev/null +++ b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NuGetGallery.Security +{ + public static class UserSecurityPolicyExtensions + { + /// + /// Determine whether two security policies are equivalent. + /// + public static bool Matches(this UserSecurityPolicy first, UserSecurityPolicy second) + { + return first.Name.Equals(second.Name, StringComparison.OrdinalIgnoreCase) && + ( + (string.IsNullOrEmpty(first.Value) && string.IsNullOrEmpty(second.Value)) || + (first.Value.Equals(second.Value, StringComparison.OrdinalIgnoreCase)) + ); + } + + /// + /// Check whether a user has the security policies required for a policy group. + /// + /// True if enrolled (has all policies), false otherwise. + public static bool IsEnrolled(this User user, UserSecurityPolicyGroup policyGroup) + { + return !user.FindPolicies(policyGroup).Any(p => p == null); + } + + /// + /// Ensure user is enrolled in the security policy group. + /// + /// User to enroll. + /// User security policy group to enroll in. + public static void EnsureEnrolled(this User user, UserSecurityPolicyGroup policyGroup) + { + // Add policies, if not already enrolled in all group policies. + if (!user.IsEnrolled(policyGroup)) + { + foreach (var policy in policyGroup.Policies) + { + user.SecurityPolicies.Add(policy); + } + policyGroup.OnEnroll?.Invoke(user); + } + } + + /// + /// Ensure user is unenrolled from the security policy group. + /// + /// User to unenroll. + /// User security policy group to unenroll from. + public static void EnsureUnenrolled(this User user, UserSecurityPolicyGroup policyGroup) + { + // Remove policies, only if enrolled in all group policies. + var matches = user.FindPolicies(policyGroup); + if (!matches.Any(p => p == null)) + { + foreach (var policy in matches) + { + user.SecurityPolicies.Remove(policy); + } + } + } + + /// + /// Find user security policies which are part of a security policy group. + /// + private static IEnumerable FindPolicies(this User user, UserSecurityPolicyGroup policyGroup) + { + return policyGroup.Policies.Select(gp => user.SecurityPolicies.FirstOrDefault(up => up.Matches(gp))); + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs b/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs new file mode 100644 index 0000000000..4c607513d1 --- /dev/null +++ b/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGetGallery.Authentication; + +namespace NuGetGallery.Security +{ + /// + /// Grouping of one or more user security policies for enrollment. + /// + public class UserSecurityPolicyGroup + { + public const string SecurePush = "SecurePush"; + + /// + /// Policy group name. + /// + public string Name { get; set; } + + /// + /// Required policies. + /// + public IEnumerable Policies { get; set; } + + /// + /// Action to take on user enrollment. + /// + public Action OnEnroll { get; set; } + + /// + /// Get supported user security policy groups. + /// + private static List _instances; + public static List Instances + { + get + { + if (_instances == null) + { + _instances = CreateUserPolicyGroups().ToList(); + } + return _instances; + } + } + + private static IEnumerable CreateUserPolicyGroups() + { + yield return new UserSecurityPolicyGroup() + { + Name = UserSecurityPolicyGroup.SecurePush, + Policies = new [] + { + new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName), + new UserSecurityPolicy(RequireMinClientVersionForPushPolicy.PolicyName) { Value = "{\"v\":\"4.1.0\"}" } + }, + OnEnroll = OnEnroll_SecurePush + }; + } + + private static void OnEnroll_SecurePush(User user) + { + var pushKeys = user.Credentials.Where(c => + c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) && !c.HasExpired && + ( + c.Scopes.Count == 0 || + c.Scopes.Any(s => + s.AllowedAction.Equals(NuGetScopes.PackagePush, StringComparison.OrdinalIgnoreCase) || + s.AllowedAction.Equals(NuGetScopes.PackagePushVersion, StringComparison.OrdinalIgnoreCase) + )) + ); + + foreach (var key in pushKeys) + { + key.Expires = DateTime.Now.AddDays(7); + } + } + } +} \ No newline at end of file diff --git a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj index 100854b412..142bed6cc3 100644 --- a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj +++ b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj @@ -411,6 +411,8 @@ + + diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs new file mode 100644 index 0000000000..f81c59bd44 --- /dev/null +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs @@ -0,0 +1,134 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Xunit; +using Newtonsoft.Json; + +namespace NuGetGallery.Security +{ + public class UserSecurityPolicyExtensionsFacts + { + [Theory] + [InlineData("", "")] + [InlineData(null, "")] + [InlineData(null, null)] + public void MatchesReturnsTrue(string value1, string value2) + { + // Arrange. + var policy1 = new UserSecurityPolicy("A") { Value = value1 }; + var policy2 = new UserSecurityPolicy("A") { Value = value2 }; + + // Act & Assert. + Assert.True(policy1.Matches(policy2)); + } + + [Fact] + public void MatchesReturnsFalseIfNameDiffers() + { + // Arrange. + var policy1 = new UserSecurityPolicy("A"); + var policy2 = new UserSecurityPolicy("B"); + + // Act & Assert. + Assert.False(policy1.Matches(policy2)); + } + + [Fact] + public void MatchesReturnsFalseIfValueDiffers() + { + // Arrange. + var policy1 = new UserSecurityPolicy("A") { Value = "B" }; + var policy2 = new UserSecurityPolicy("A") { Value = "C" }; + + // Act & Assert. + Assert.False(policy1.Matches(policy2)); + } + + [Theory] + [InlineData("[[\"A\",\"\"]]", "[[\"A\",null]]")] + [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]", "[[\"E\",\"\"],[\"A\",\"B\"],[\"C\",\"D\"]]")] + public void IsEnrolledReturnsTrue(string groupPolicies, string userPolicies) + { + // Arrange. + var group = new UserSecurityPolicyGroup() + { + Policies = LoadPolicies(groupPolicies) + }; + var user = new User(); + LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); + + // Act & Assert. + Assert.True(user.IsEnrolled(group)); + } + + [Theory] + [InlineData("[[\"A\",\"B\"],[\"E\",null]]", "[]")] + [InlineData("[[\"A\",\"B\"],[\"E\",null]]", "[[\"A\",\"B\"],[\"C\",\"D\"]]")] + public void IsEnrolledReturnsFalse(string groupPolicies, string userPolicies) + { + // Arrange. + var group = new UserSecurityPolicyGroup() + { + Policies = LoadPolicies(groupPolicies) + }; + var user = new User(); + LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); + + // Act & Assert. + Assert.False(user.IsEnrolled(group)); + } + + [Theory] + [InlineData("[[\"A\",\"\"]]")] + [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]")] + public void EnsureEnrolledAddsPolicies(string groupPolicies) + { + // Arrange. + var group = new UserSecurityPolicyGroup() + { + Policies = LoadPolicies(groupPolicies) + }; + var user = new User(); + + // Act. + user.EnsureEnrolled(group); + + // Assert. + Assert.Equal(group.Policies.Count(), user.SecurityPolicies.Count()); + } + + [Theory] + [InlineData("[[\"A\",\"\"]]", "[[\"A\",null]]", 0)] + [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]", "[[\"E\",\"\"],[\"A\",\"B\"],[\"C\",\"D\"]]", 1)] + public void EnsureUnEnrolledAddsPolicies(string groupPolicies, string userPolicies, int expectedCount) + { + // Arrange. + var group = new UserSecurityPolicyGroup() + { + Policies = LoadPolicies(groupPolicies) + }; + var user = new User(); + LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); + + // Act. + user.EnsureUnenrolled(group); + + // Assert. + Assert.Equal(expectedCount, user.SecurityPolicies.Count()); + } + + private IEnumerable LoadPolicies(string policiesString) + { + var policies = (string[][])JsonConvert.DeserializeObject(policiesString); + if (policies != null) + { + foreach (var p in policies) + { + yield return new UserSecurityPolicy(p[0]) { Value = p[1] }; + } + } + } + } +} diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs new file mode 100644 index 0000000000..72d70c517b --- /dev/null +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NuGet.Packaging; +using Xunit; + +namespace NuGetGallery.Security +{ + public class UserSecurityPolicyGroupFacts + { + [Theory] + [InlineData("")] + [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] + [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] + [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] + public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) + { + // Arrange. + var group = UserSecurityPolicyGroup.Instances.First( + g => g.Name.Equals(UserSecurityPolicyGroup.SecurePush, StringComparison.OrdinalIgnoreCase)); + + var credential = new Credential(CredentialTypes.ApiKey.V2, string.Empty, TimeSpan.FromDays(10)); + if (!string.IsNullOrWhiteSpace(scopes)) + { + credential.Scopes.AddRange(JsonConvert.DeserializeObject>(scopes)); + } + var user = new User(); + user.Credentials.Add(credential); + + // Act. + user.EnsureEnrolled(group); + + // Assert. + Assert.Equal(2, user.SecurityPolicies.Count()); + Assert.True(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + } + + [Theory] + [InlineData("password.v3", "")] + [InlineData("apikey.v2", "[{\"a\":\"package:unlist\", \"s\":\"theId\"}]")] + [InlineData("apikey.verify.v1", "[{\"a\":\"package:verify\", \"s\":\"theId\"}]")] + public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, string scopes) + { + // Arrange. + var group = UserSecurityPolicyGroup.Instances.First( + g => g.Name.Equals(UserSecurityPolicyGroup.SecurePush, StringComparison.OrdinalIgnoreCase)); + + var credential = new Credential(type, string.Empty, TimeSpan.FromDays(10)); + if (!string.IsNullOrWhiteSpace(scopes)) + { + credential.Scopes.AddRange(JsonConvert.DeserializeObject>(scopes)); + } + var user = new User(); + user.Credentials.Add(credential); + + // Act. + user.EnsureEnrolled(group); + + // Assert. + Assert.Equal(2, user.SecurityPolicies.Count()); + Assert.False(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + } + } +} From 82af4f4d577a3ed35f6eb8d658813f9f8243213c Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Mon, 1 May 2017 17:08:43 -0700 Subject: [PATCH 2/9] Bug fixes --- .../Entities/EntitiesContext.cs | 1 + .../Entities/IEntitiesContext.cs | 3 ++- .../Entities/UserSecurityPolicy.cs | 6 ++++++ .../Controllers/SecurityPolicyController.cs | 19 ++++++++++++------- .../Security/UserSecurityPolicyExtensions.cs | 7 ++++--- .../UserSecurityPolicyExtensionsFacts.cs | 5 +++-- .../Security/UserSecurityPolicyGroupFacts.cs | 5 +++-- .../TestUtils/FakeEntitiesContext.cs | 12 ++++++++++++ 8 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/NuGetGallery.Core/Entities/EntitiesContext.cs b/src/NuGetGallery.Core/Entities/EntitiesContext.cs index f11172287a..0737e8e8df 100644 --- a/src/NuGetGallery.Core/Entities/EntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/EntitiesContext.cs @@ -42,6 +42,7 @@ public EntitiesContext(string connectionString, bool readOnly) public IDbSet Credentials { get; set; } public IDbSet Scopes { get; set; } public IDbSet Users { get; set; } + public IDbSet UserSecurityPolicies { get; set; } IDbSet IEntitiesContext.Set() { diff --git a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs index 20c3d645c0..e24dd4089b 100644 --- a/src/NuGetGallery.Core/Entities/IEntitiesContext.cs +++ b/src/NuGetGallery.Core/Entities/IEntitiesContext.cs @@ -13,8 +13,9 @@ public interface IEntitiesContext IDbSet PackageRegistrations { get; set; } IDbSet Credentials { get; set; } IDbSet Scopes { get; set; } - IDbSet Users { get; set; } + IDbSet UserSecurityPolicies { get; set; } + Task SaveChangesAsync(); [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "Set", Justification="This is to match the EF terminology.")] IDbSet Set() where T : class; diff --git a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs index 1ee4721575..649b8372c2 100644 --- a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs +++ b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs @@ -19,6 +19,12 @@ public UserSecurityPolicy(string name) Name = name; } + public UserSecurityPolicy(string name, string value) + { + Name = name; + Value = value; + } + /// /// Policy key. /// diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index 76fa0a0d94..dea37ee4d9 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using System.Web.Mvc; using NuGetGallery.Areas.Admin.ViewModels; using NuGetGallery.Security; @@ -59,10 +60,10 @@ public virtual ActionResult Search(string query) [HttpPost] [ValidateAntiForgeryToken] - public ActionResult Enroll(SecurityPolicyViewModel viewModel) + public async Task Enroll(SecurityPolicyViewModel viewModel) { // Parse 'username|policyGroup' into enrollment requests by user. - var enrollments = viewModel.Enrollments + var enrollments = viewModel.Enrollments? .Select(e => e.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) .GroupBy(e => /*username*/e[0]) .ToDictionary( @@ -77,20 +78,24 @@ public ActionResult Enroll(SecurityPolicyViewModel viewModel) { foreach (var policyGroup in UserSecurityPolicyGroup.Instances) { - if (enrollments[user.Username].Contains(policyGroup.Name)) + if (enrollments != null && enrollments[user.Username].Contains(policyGroup.Name)) { - user.EnsureEnrolled(policyGroup); + user.AddPolicies(policyGroup); } else { - user.EnsureUnenrolled(policyGroup); + var removedPolicies = user.RemovePolicies(policyGroup); + foreach (var p in removedPolicies) + { + EntitiesContext.UserSecurityPolicies.Remove(p); + } } } } - EntitiesContext.SaveChangesAsync(); + await EntitiesContext.SaveChangesAsync(); - return RedirectToRoute("Admin"); + return RedirectToAction("Index"); } private static string[] GetUsernamesFromQuery(string query) diff --git a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs index 20c7fe0dc4..e15768d30d 100644 --- a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs +++ b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs @@ -35,14 +35,14 @@ public static bool IsEnrolled(this User user, UserSecurityPolicyGroup policyGrou /// /// User to enroll. /// User security policy group to enroll in. - public static void EnsureEnrolled(this User user, UserSecurityPolicyGroup policyGroup) + public static void AddPolicies(this User user, UserSecurityPolicyGroup policyGroup) { // Add policies, if not already enrolled in all group policies. if (!user.IsEnrolled(policyGroup)) { foreach (var policy in policyGroup.Policies) { - user.SecurityPolicies.Add(policy); + user.SecurityPolicies.Add(new UserSecurityPolicy(policy.Name, policy.Value)); } policyGroup.OnEnroll?.Invoke(user); } @@ -53,7 +53,7 @@ public static void EnsureEnrolled(this User user, UserSecurityPolicyGroup policy /// /// User to unenroll. /// User security policy group to unenroll from. - public static void EnsureUnenrolled(this User user, UserSecurityPolicyGroup policyGroup) + public static IEnumerable RemovePolicies(this User user, UserSecurityPolicyGroup policyGroup) { // Remove policies, only if enrolled in all group policies. var matches = user.FindPolicies(policyGroup); @@ -62,6 +62,7 @@ public static void EnsureUnenrolled(this User user, UserSecurityPolicyGroup poli foreach (var policy in matches) { user.SecurityPolicies.Remove(policy); + yield return policy; } } } diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs index f81c59bd44..0be05643be 100644 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs @@ -5,6 +5,7 @@ using System.Linq; using Xunit; using Newtonsoft.Json; +using System.Web.Mvc; namespace NuGetGallery.Security { @@ -93,7 +94,7 @@ public void EnsureEnrolledAddsPolicies(string groupPolicies) var user = new User(); // Act. - user.EnsureEnrolled(group); + user.AddPolicies(group); // Assert. Assert.Equal(group.Policies.Count(), user.SecurityPolicies.Count()); @@ -113,7 +114,7 @@ public void EnsureUnEnrolledAddsPolicies(string groupPolicies, string userPolici LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); // Act. - user.EnsureUnenrolled(group); + user.RemovePolicies(group); // Assert. Assert.Equal(expectedCount, user.SecurityPolicies.Count()); diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs index 72d70c517b..e0b606a78b 100644 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using NuGet.Packaging; using Xunit; +using System.Web.Mvc; namespace NuGetGallery.Security { @@ -32,7 +33,7 @@ public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) user.Credentials.Add(credential); // Act. - user.EnsureEnrolled(group); + user.AddPolicies(group); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); @@ -58,7 +59,7 @@ public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, stri user.Credentials.Add(credential); // Act. - user.EnsureEnrolled(group); + user.AddPolicies(group); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); diff --git a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs index 969583c167..9afd485f80 100644 --- a/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs +++ b/tests/NuGetGallery.Facts/TestUtils/FakeEntitiesContext.cs @@ -98,6 +98,18 @@ public IDbSet Users } } + public IDbSet UserSecurityPolicies + { + get + { + return Set(); + } + set + { + throw new NotSupportedException(); + } + } + public Task SaveChangesAsync() { _areChangesSaved = true; From 003161f159ecbcfb8aaf3e6be3e50b8b68e642ba Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Mon, 1 May 2017 17:17:34 -0700 Subject: [PATCH 3/9] Cleanup --- .../Areas/Admin/Controllers/SecurityPolicyController.cs | 2 +- .../Security/UserSecurityPolicyExtensionsFacts.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index dea37ee4d9..ec7b615854 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -71,7 +71,7 @@ public async Task Enroll(SecurityPolicyViewModel viewModel) g => g.Select(e => /*policyGroup*/e[1]) ); - // Iterate all users and policies to identity groups for both enrollment and unenrollment. + // Iterate all users and groups for enrollment or unenrollment. var usernames = GetUsernamesFromQuery(viewModel.Query); var users = FindUsers(usernames); foreach (var user in users) diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs index 0be05643be..57c09230e5 100644 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs @@ -84,7 +84,7 @@ public void IsEnrolledReturnsFalse(string groupPolicies, string userPolicies) [Theory] [InlineData("[[\"A\",\"\"]]")] [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]")] - public void EnsureEnrolledAddsPolicies(string groupPolicies) + public void AddPoliciesAddsPolicies(string groupPolicies) { // Arrange. var group = new UserSecurityPolicyGroup() @@ -103,7 +103,7 @@ public void EnsureEnrolledAddsPolicies(string groupPolicies) [Theory] [InlineData("[[\"A\",\"\"]]", "[[\"A\",null]]", 0)] [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]", "[[\"E\",\"\"],[\"A\",\"B\"],[\"C\",\"D\"]]", 1)] - public void EnsureUnEnrolledAddsPolicies(string groupPolicies, string userPolicies, int expectedCount) + public void RemovePoliciesRemovesPolicies(string groupPolicies, string userPolicies, int expectedCount) { // Arrange. var group = new UserSecurityPolicyGroup() From c3bc293c8611b4925ee0e5ce4680bf1e026be1b4 Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Tue, 2 May 2017 11:14:31 -0700 Subject: [PATCH 4/9] PR feedback --- .../Areas/Admin/Controllers/SecurityPolicyController.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index ec7b615854..ba767c572f 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -100,8 +100,9 @@ public async Task Enroll(SecurityPolicyViewModel viewModel) private static string[] GetUsernamesFromQuery(string query) { - return query.Split(new[] { ',', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(username => username.Trim()).ToArray(); + return query.Split(',', '\r', '\n') + .Select(username => username.Trim()) + .Where(username => !string.IsNullOrEmpty(username)).ToArray(); } private IEnumerable FindUsers(string[] usernames) From e334a41f6d2429324053b4fa2c83d86a31f8314a Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Tue, 2 May 2017 11:35:43 -0700 Subject: [PATCH 5/9] Fix test failure with ToList --- src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs index e15768d30d..48b653f28a 100644 --- a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs +++ b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs @@ -56,15 +56,15 @@ public static void AddPolicies(this User user, UserSecurityPolicyGroup policyGro public static IEnumerable RemovePolicies(this User user, UserSecurityPolicyGroup policyGroup) { // Remove policies, only if enrolled in all group policies. - var matches = user.FindPolicies(policyGroup); + var matches = user.FindPolicies(policyGroup).ToList(); if (!matches.Any(p => p == null)) { foreach (var policy in matches) { user.SecurityPolicies.Remove(policy); - yield return policy; } } + return matches; } /// From a89ad4227a7c354ae890db50b61783a40f30da2e Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Tue, 2 May 2017 14:27:13 -0700 Subject: [PATCH 6/9] PR feedback --- .../Controllers/SecurityPolicyController.cs | 12 +++-- .../Admin/Views/SecurityPolicy/Index.cshtml | 2 +- .../RequireMinClientVersionForPushPolicy.cs | 12 +++++ .../Security/UserSecurityPolicyGroup.cs | 16 +++++-- .../Security/UserSecurityPolicyGroupFacts.cs | 48 +++++++++++-------- 5 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index ba767c572f..e346616ad2 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Threading.Tasks; using System.Web.Mvc; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NuGetGallery.Areas.Admin.ViewModels; using NuGetGallery.Security; @@ -62,16 +64,16 @@ public virtual ActionResult Search(string query) [ValidateAntiForgeryToken] public async Task Enroll(SecurityPolicyViewModel viewModel) { - // Parse 'username|policyGroup' into enrollment requests by user. + // Group enrollment requests by user. var enrollments = viewModel.Enrollments? - .Select(e => e.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) - .GroupBy(e => /*username*/e[0]) + .Select(json => JsonConvert.DeserializeObject(json)) + .GroupBy(obj => obj["u"].ToString()) .ToDictionary( g => g.Key, - g => g.Select(e => /*policyGroup*/e[1]) + g => g.Select(obj => obj["g"].ToString()) ); - // Iterate all users and groups for enrollment or unenrollment. + // Iterate all users and groups to handle both enrollment and unenrollment. var usernames = GetUsernamesFromQuery(viewModel.Query); var users = FindUsers(usernames); foreach (var user in users) diff --git a/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml index 431caf8faf..b1ea6737cd 100644 --- a/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml +++ b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml @@ -128,7 +128,7 @@ }; this.generateValue = function (user, policyGroup) { - return user.Username + '|' + policyGroup; + return JSON.stringify({ "u": user.Username, "g": policyGroup }) }; this.generateUserUrl = function (user) { diff --git a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs b/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs index f6ca242159..4ebf5027ab 100644 --- a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs +++ b/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs @@ -30,6 +30,18 @@ public RequireMinClientVersionForPushPolicy() { } + /// + /// Create a user security policy that requires a minimum client version. + /// + public static UserSecurityPolicy CreatePolicy(NuGetVersion minClientVersion) + { + var value = JsonConvert.SerializeObject(new State() { + MinClientVersion = minClientVersion + }); + + return new UserSecurityPolicy(PolicyName, value); + } + /// /// In case of multiple, select the max of the minimum required client versions. /// diff --git a/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs b/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs index 4c607513d1..d1f804ad10 100644 --- a/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs +++ b/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using NuGet.Versioning; using NuGetGallery.Authentication; namespace NuGetGallery.Security @@ -14,6 +15,7 @@ namespace NuGetGallery.Security public class UserSecurityPolicyGroup { public const string SecurePush = "SecurePush"; + public const string SecurePushVersion = "4.1.0"; /// /// Policy group name. @@ -51,10 +53,10 @@ private static IEnumerable CreateUserPolicyGroups() yield return new UserSecurityPolicyGroup() { Name = UserSecurityPolicyGroup.SecurePush, - Policies = new [] + Policies = new[] { new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName), - new UserSecurityPolicy(RequireMinClientVersionForPushPolicy.PolicyName) { Value = "{\"v\":\"4.1.0\"}" } + RequireMinClientVersionForPushPolicy.CreatePolicy(new NuGetVersion(SecurePushVersion)) }, OnEnroll = OnEnroll_SecurePush }; @@ -63,7 +65,7 @@ private static IEnumerable CreateUserPolicyGroups() private static void OnEnroll_SecurePush(User user) { var pushKeys = user.Credentials.Where(c => - c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) && !c.HasExpired && + c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) && ( c.Scopes.Count == 0 || c.Scopes.Any(s => @@ -71,10 +73,14 @@ private static void OnEnroll_SecurePush(User user) s.AllowedAction.Equals(NuGetScopes.PackagePushVersion, StringComparison.OrdinalIgnoreCase) )) ); - + foreach (var key in pushKeys) { - key.Expires = DateTime.Now.AddDays(7); + var maxExpiration = DateTime.UtcNow.AddDays(7); + if (!key.Expires.HasValue || key.Expires > maxExpiration) + { + key.Expires = maxExpiration; + } } } } diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs index e0b606a78b..80fa8fc704 100644 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs +++ b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs @@ -7,7 +7,6 @@ using Newtonsoft.Json; using NuGet.Packaging; using Xunit; -using System.Web.Mvc; namespace NuGetGallery.Security { @@ -16,24 +15,11 @@ public class UserSecurityPolicyGroupFacts [Theory] [InlineData("")] [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] - [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) { - // Arrange. - var group = UserSecurityPolicyGroup.Instances.First( - g => g.Name.Equals(UserSecurityPolicyGroup.SecurePush, StringComparison.OrdinalIgnoreCase)); - - var credential = new Credential(CredentialTypes.ApiKey.V2, string.Empty, TimeSpan.FromDays(10)); - if (!string.IsNullOrWhiteSpace(scopes)) - { - credential.Scopes.AddRange(JsonConvert.DeserializeObject>(scopes)); - } - var user = new User(); - user.Credentials.Add(credential); - - // Act. - user.AddPolicies(group); + // Arrange & Act. + var user = EnrollUserInSecurePush(CredentialTypes.ApiKey.V2, scopes); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); @@ -45,12 +31,36 @@ public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) [InlineData("apikey.v2", "[{\"a\":\"package:unlist\", \"s\":\"theId\"}]")] [InlineData("apikey.verify.v1", "[{\"a\":\"package:verify\", \"s\":\"theId\"}]")] public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, string scopes) + { + // Arrange & Act. + var user = EnrollUserInSecurePush(type, scopes); + + // Assert. + Assert.Equal(2, user.SecurityPolicies.Count()); + Assert.False(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + } + + [Theory] + [InlineData("")] + [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] + [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] + public void SecurePush_OnEnrollDoesNotChangeExpiringPushCredentials(string scopes) + { + // Arrange & Act. + var user = EnrollUserInSecurePush(CredentialTypes.ApiKey.V2, scopes, expiresInDays: 2); + + // Assert. + Assert.Equal(2, user.SecurityPolicies.Count()); + Assert.True(DateTime.UtcNow.AddDays(2) >= user.Credentials.First().Expires); + } + + private User EnrollUserInSecurePush(string type, string scopes, int expiresInDays = 10) { // Arrange. var group = UserSecurityPolicyGroup.Instances.First( g => g.Name.Equals(UserSecurityPolicyGroup.SecurePush, StringComparison.OrdinalIgnoreCase)); - var credential = new Credential(type, string.Empty, TimeSpan.FromDays(10)); + var credential = new Credential(type, string.Empty, TimeSpan.FromDays(expiresInDays)); if (!string.IsNullOrWhiteSpace(scopes)) { credential.Scopes.AddRange(JsonConvert.DeserializeObject>(scopes)); @@ -61,9 +71,7 @@ public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, stri // Act. user.AddPolicies(group); - // Assert. - Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.False(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + return user; } } } From 3f840d33a195cce8e65322fd2aba008396d6eee1 Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Thu, 4 May 2017 14:56:00 -0700 Subject: [PATCH 7/9] PR feedback --- .../Entities/UserSecurityPolicy.cs | 45 ++- .../Controllers/SecurityPolicyController.cs | 55 +-- .../ViewModels/SecurityPolicySearchResult.cs | 38 -- .../ViewModels/SecurityPolicyViewModel.cs | 8 +- .../UserSecurityPolicySearchResult.cs | 23 ++ .../UserSecurityPolicySubscriptions.cs | 22 ++ .../Admin/Views/SecurityPolicy/Index.cshtml | 50 +-- src/NuGetGallery/ExtensionMethods.cs | 3 +- ...ityPolicies_SubscriptionColumn.Designer.cs | 29 ++ ...UserSecurityPolicies_SubscriptionColumn.cs | 18 + ...erSecurityPolicies_SubscriptionColumn.resx | 126 ++++++ src/NuGetGallery/NuGetGallery.csproj | 14 +- .../Security/ISecurityPolicyService.cs | 24 +- .../IUserSecurityPolicySubscription.cs | 34 ++ .../RequireMinClientVersionForPushPolicy.cs | 7 +- .../Security/SecurePushSubscription.cs | 77 ++++ .../Security/SecurityPolicyService.cs | 146 ++++++- .../Security/UserSecurityPolicyExtensions.cs | 78 ---- .../Security/UserSecurityPolicyGroup.cs | 87 ----- .../Entities/UserSecurityPolicyFacts.cs | 70 ++++ .../NuGetGallery.Core.Facts.csproj | 1 + .../NuGetGallery.Facts.csproj | 3 +- ...quireMinClientVersionForPushPolicyFacts.cs | 33 +- .../RequirePackageVerifyScopePolicyFacts.cs | 2 +- ...acts.cs => SecurePushSubscriptionFacts.cs} | 50 ++- .../Security/SecurityPolicyServiceFacts.cs | 364 ++++++++++++++++-- .../UserSecurityPolicyExtensionsFacts.cs | 135 ------- 27 files changed, 1054 insertions(+), 488 deletions(-) delete mode 100644 src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs create mode 100644 src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySearchResult.cs create mode 100644 src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySubscriptions.cs create mode 100644 src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.Designer.cs create mode 100644 src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.cs create mode 100644 src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.resx create mode 100644 src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs create mode 100644 src/NuGetGallery/Security/SecurePushSubscription.cs delete mode 100644 src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs delete mode 100644 src/NuGetGallery/Security/UserSecurityPolicyGroup.cs create mode 100644 tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs rename tests/NuGetGallery.Facts/Security/{UserSecurityPolicyGroupFacts.cs => SecurePushSubscriptionFacts.cs} (50%) delete mode 100644 tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs diff --git a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs index 649b8372c2..d935b43d51 100644 --- a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs +++ b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.ComponentModel.DataAnnotations; namespace NuGetGallery @@ -8,22 +9,34 @@ namespace NuGetGallery /// /// User-subscribed security policy. /// - public class UserSecurityPolicy : IEntity + public class UserSecurityPolicy : IEntity, IEquatable { public UserSecurityPolicy() { } - public UserSecurityPolicy(string name) + public UserSecurityPolicy(string name, string subscription, string value = null) { - Name = name; - } + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + if (string.IsNullOrEmpty(subscription)) + { + throw new ArgumentNullException(nameof(subscription)); + } - public UserSecurityPolicy(string name, string value) - { Name = name; + Subscription = subscription; Value = value; } + + public UserSecurityPolicy(UserSecurityPolicy policy) + { + Name = policy.Name; + Subscription = policy.Subscription; + Value = policy.Value; + } /// /// Policy key. @@ -47,9 +60,29 @@ public UserSecurityPolicy(string name, string value) [StringLength(256)] public string Name { get; set; } + /// + /// Name of subscription that added this policy. + /// + [Required] + [StringLength(256)] + public string Subscription { get; set; } + /// /// Support for JSON-serialized properties for specific policies. /// public string Value { get; set; } + + /// + /// Determine if two policies are equal. + /// + public bool Equals(UserSecurityPolicy other) + { + return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && + Subscription.Equals(other.Subscription, StringComparison.OrdinalIgnoreCase) && + ( + (string.IsNullOrEmpty(Value) && string.IsNullOrEmpty(other.Value)) || + (Value.Equals(other.Value, StringComparison.OrdinalIgnoreCase)) + ); + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index e346616ad2..8e1954b77a 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -13,13 +13,28 @@ namespace NuGetGallery.Areas.Admin.Controllers { + /// + /// Controller for the security policy management Admin view. + /// public class SecurityPolicyController : AdminControllerBase { public IEntitiesContext EntitiesContext { get; } - public SecurityPolicyController(IEntitiesContext entitiesContext) + public ISecurityPolicyService PolicyService { get; } + + public SecurityPolicyController(IEntitiesContext entitiesContext, ISecurityPolicyService policyService) { + if (entitiesContext == null) + { + throw new ArgumentNullException(nameof(entitiesContext)); + } + if (policyService == null) + { + throw new ArgumentNullException(nameof(policyService)); + } + EntitiesContext = entitiesContext; + PolicyService = policyService; } [HttpGet] @@ -27,7 +42,7 @@ public virtual ActionResult Index() { var model = new SecurityPolicyViewModel() { - PolicyGroups = UserSecurityPolicyGroup.Instances.Select(pg => pg.Name) + SubscriptionNames = PolicyService.UserSubscriptions.Select(s => s.Name) }; return View(model); @@ -43,15 +58,15 @@ public virtual ActionResult Search(string query) .Where(name => !users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) .ToList(); - var results = new SecurityPolicySearchResult() + var results = new UserSecurityPolicySearchResult() { - // Found users and enrollment status for each policy group. - Users = users.Select(u => new SecurityPolicyEnrollments() + // Found users and subscribed status for each policy subscription. + Users = users.Select(u => new UserSecurityPolicySubscriptions() { Username = u.Username, - Enrollments = UserSecurityPolicyGroup.Instances.ToDictionary( - pg => pg.Name, - pg => u.IsEnrolled(pg)) + Subscriptions = PolicyService.UserSubscriptions.ToDictionary( + s => s.Name, + s => PolicyService.IsSubscribed(u, s)) }), // Usernames that weren't found in the DB. UsersNotFound = usersNotFound @@ -62,10 +77,10 @@ public virtual ActionResult Search(string query) [HttpPost] [ValidateAntiForgeryToken] - public async Task Enroll(SecurityPolicyViewModel viewModel) + public async Task Update(SecurityPolicyViewModel viewModel) { - // Group enrollment requests by user. - var enrollments = viewModel.Enrollments? + // Policy subscription requests by user. + var subscriptions = viewModel.UserSubscriptions? .Select(json => JsonConvert.DeserializeObject(json)) .GroupBy(obj => obj["u"].ToString()) .ToDictionary( @@ -73,29 +88,25 @@ public async Task Enroll(SecurityPolicyViewModel viewModel) g => g.Select(obj => obj["g"].ToString()) ); - // Iterate all users and groups to handle both enrollment and unenrollment. - var usernames = GetUsernamesFromQuery(viewModel.Query); + // Iterate all users and groups to handle both subscribe and unsubscribe. + var usernames = GetUsernamesFromQuery(viewModel.UsersQuery); var users = FindUsers(usernames); foreach (var user in users) { - foreach (var policyGroup in UserSecurityPolicyGroup.Instances) + foreach (var subscription in PolicyService.UserSubscriptions) { - if (enrollments != null && enrollments[user.Username].Contains(policyGroup.Name)) + if (subscriptions != null && subscriptions[user.Username].Contains(subscription.Name)) { - user.AddPolicies(policyGroup); + await PolicyService.SubscribeAsync(user, subscription); } else { - var removedPolicies = user.RemovePolicies(policyGroup); - foreach (var p in removedPolicies) - { - EntitiesContext.UserSecurityPolicies.Remove(p); - } + await PolicyService.UnsubscribeAsync(user, subscription); } } } - await EntitiesContext.SaveChangesAsync(); + TempData["Message"] = $"Updated policies for {users.Count()} users."; return RedirectToAction("Index"); } diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs deleted file mode 100644 index c79b485ac8..0000000000 --- a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicySearchResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Collections.Generic; - -namespace NuGetGallery.Areas.Admin.ViewModels -{ - /// - /// User search results for the security policies admin view. - /// - public class SecurityPolicySearchResult - { - /// - /// Found users, with security policy group enrollments. - /// - public IEnumerable Users { get; set; } - - /// - /// Usernames not found in the database. - /// - public IEnumerable UsersNotFound { get; set; } - } - - /// - /// Security policy group enrollments for a user. - /// - public class SecurityPolicyEnrollments - { - public int UserId { get; set; } - - public string Username { get; set; } - - /// - /// Dictionary of security policy group names (key) and whether the user is enrolled. - /// - public IDictionary Enrollments { get; set; } - } -} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs index e559fcb23d..14a517b57c 100644 --- a/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs +++ b/src/NuGetGallery/Areas/Admin/ViewModels/SecurityPolicyViewModel.cs @@ -13,16 +13,16 @@ public class SecurityPolicyViewModel /// /// Users search query. /// - public string Query { get; set; } + public string UsersQuery { get; set; } /// /// Available security policy groups. /// - public IEnumerable PolicyGroups { get; set; } + public IEnumerable SubscriptionNames { get; set; } /// - /// Requested user enrollments to make. String format is 'username|policygroup'. + /// User subscription requests, in JSON format. /// - public IEnumerable Enrollments { get; set; } + public IEnumerable UserSubscriptions { get; set; } } } \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySearchResult.cs b/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySearchResult.cs new file mode 100644 index 0000000000..b221e5944c --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySearchResult.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace NuGetGallery.Areas.Admin.ViewModels +{ + /// + /// User search results for the security policies admin view. + /// + public class UserSecurityPolicySearchResult + { + /// + /// Found users, with security policy subscriptions they are subscribed to. + /// + public IEnumerable Users { get; set; } + + /// + /// Usernames not found in the database. + /// + public IEnumerable UsersNotFound { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySubscriptions.cs b/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySubscriptions.cs new file mode 100644 index 0000000000..877adfbad5 --- /dev/null +++ b/src/NuGetGallery/Areas/Admin/ViewModels/UserSecurityPolicySubscriptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace NuGetGallery.Areas.Admin.ViewModels +{ + /// + /// Security policy group subscriptions for a user. + /// + public class UserSecurityPolicySubscriptions + { + public int UserId { get; set; } + + public string Username { get; set; } + + /// + /// Dictionary of security policy subscriptions, and whether user is subscribed. + /// + public IDictionary Subscriptions { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml index b1ea6737cd..a274127cee 100644 --- a/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml +++ b/src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml @@ -13,10 +13,10 @@
    - @using (Html.BeginForm("Enroll", "SecurityPolicy", new { area = "Admin" }, FormMethod.Post, new { id = "delete-form" })) + @using (Html.BeginForm("Update", "SecurityPolicy", new { area = "Admin" }, FormMethod.Post, new { id = "delete-form" })) {
    - + @@ -72,7 +72,7 @@ var viewModel = function () { var $self = this; - this.policyGroups = @Html.Raw(Json.Encode(@Model.PolicyGroups)); + this.subscriptions = @Html.Raw(Json.Encode(@Model.SubscriptionNames)); this.searchQuery = ko.observable(''); this.search = function () { @@ -89,8 +89,8 @@ for (var i = 0; i < data.Users.length; i++) { var user = data.Users[i]; user.Selected = {}; - for (var key in user.Enrollments) { - user.Selected[key] = ko.observable(user.Enrollments[key]); + for (var key in user.Subscriptions) { + user.Selected[key] = ko.observable(user.Subscriptions[key]); user.Selected[key].subscribe($self.markDirty); } } @@ -108,27 +108,27 @@ }; this.selectAllState = {}; - for (var i = 0; i < this.policyGroups.length; i++) + for (var i = 0; i < this.subscriptions.length; i++) { - var policyGroup = this.policyGroups[i]; - this.selectAllState[policyGroup] = ko.observable(false); + var subscription = this.subscriptions[i]; + this.selectAllState[subscription] = ko.observable(false); } this.resetSelectAllState = function () { - for (var i = 0; i < $self.policyGroups.length; i++) + for (var i = 0; i < $self.subscriptions.length; i++) { - $self.selectAllState[$self.policyGroups[i]](false); + $self.selectAllState[$self.subscriptions[i]](false); } } this.toggleSelectAll = function (data, e) { - var policyGroup = e.currentTarget.nextSibling.data; - $self.selectAllState[policyGroup](!$self.selectAllState[policyGroup]()); + var subscription = e.currentTarget.nextSibling.data; + $self.selectAllState[subscription](!$self.selectAllState[subscription]()); return true; }; - this.generateValue = function (user, policyGroup) { - return JSON.stringify({ "u": user.Username, "g": policyGroup }) + this.generateValue = function (user, subscription) { + return JSON.stringify({ "u": user.Username, "g": subscription }) }; this.generateUserUrl = function (user) { @@ -145,12 +145,12 @@ $self.changeTracker(true); }; - for (var i = 0; i < this.policyGroups.length; i++) { - this.selectAllState[policyGroup].subscribe(function () { - var state = $self.selectAllState[policyGroup](); + for (var i = 0; i < this.subscriptions.length; i++) { + this.selectAllState[subscription].subscribe(function () { + var state = $self.selectAllState[subscription](); ko.utils.arrayForEach($self.searchResults(), function (result) { - result.Selected[policyGroup](state); + result.Selected[subscription](state); }); }); } @@ -159,7 +159,7 @@ ko.applyBindings(new viewModel(), $('#stage').get(0)); $('#delete-form').submit(function (e) { - if (!confirm('Are you sure you want to continue with enrollment?')) { + if (!confirm('Are you sure you want to continue?')) { e.preventDefault(); } }); diff --git a/src/NuGetGallery/ExtensionMethods.cs b/src/NuGetGallery/ExtensionMethods.cs index b27de34a08..b5500e818d 100644 --- a/src/NuGetGallery/ExtensionMethods.cs +++ b/src/NuGetGallery/ExtensionMethods.cs @@ -461,8 +461,7 @@ public static Credential GetCurrentApiKeyCredential(this User user, IIdentity id var claimsIdentity = identity as ClaimsIdentity; var apiKey = claimsIdentity.GetClaimOrDefault(NuGetClaims.ApiKey); - return string.IsNullOrEmpty(apiKey) ? null - : user.Credentials.FirstOrDefault(c => c.Value == apiKey); + return user.Credentials.FirstOrDefault(c => c.Value == apiKey); } private static User LoadUser(IOwinContext context) diff --git a/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.Designer.cs b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.Designer.cs new file mode 100644 index 0000000000..a6a7befd09 --- /dev/null +++ b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.Designer.cs @@ -0,0 +1,29 @@ +// +namespace NuGetGallery.Migrations +{ + using System.CodeDom.Compiler; + using System.Data.Entity.Migrations; + using System.Data.Entity.Migrations.Infrastructure; + using System.Resources; + + [GeneratedCode("EntityFramework.Migrations", "6.1.3-40302")] + public sealed partial class UserSecurityPolicies_SubscriptionColumn : IMigrationMetadata + { + private readonly ResourceManager Resources = new ResourceManager(typeof(UserSecurityPolicies_SubscriptionColumn)); + + string IMigrationMetadata.Id + { + get { return "201705041614287_UserSecurityPolicies_SubscriptionColumn"; } + } + + string IMigrationMetadata.Source + { + get { return null; } + } + + string IMigrationMetadata.Target + { + get { return Resources.GetString("Target"); } + } + } +} diff --git a/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.cs b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.cs new file mode 100644 index 0000000000..30a3ecf3b8 --- /dev/null +++ b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.cs @@ -0,0 +1,18 @@ +namespace NuGetGallery.Migrations +{ + using System; + using System.Data.Entity.Migrations; + + public partial class UserSecurityPolicies_SubscriptionColumn : DbMigration + { + public override void Up() + { + AddColumn("dbo.UserSecurityPolicies", "Subscription", c => c.String(nullable: false, maxLength: 256)); + } + + public override void Down() + { + DropColumn("dbo.UserSecurityPolicies", "Subscription"); + } + } +} diff --git a/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.resx b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.resx new file mode 100644 index 0000000000..1988b688dc --- /dev/null +++ b/src/NuGetGallery/Migrations/201705041614287_UserSecurityPolicies_SubscriptionColumn.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 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 + + + dbo + + \ No newline at end of file diff --git a/src/NuGetGallery/NuGetGallery.csproj b/src/NuGetGallery/NuGetGallery.csproj index d08969f140..c14305d67c 100644 --- a/src/NuGetGallery/NuGetGallery.csproj +++ b/src/NuGetGallery/NuGetGallery.csproj @@ -679,7 +679,8 @@ - + + @@ -752,8 +753,12 @@ 201705032101231_SecurityPoliciesFix.cs - - + + + 201705041614287_UserSecurityPolicies_SubscriptionColumn.cs + + + @@ -1714,6 +1719,9 @@ 201705032101231_SecurityPoliciesFix.cs + + 201705041614287_UserSecurityPolicies_SubscriptionColumn.cs + diff --git a/src/NuGetGallery/Security/ISecurityPolicyService.cs b/src/NuGetGallery/Security/ISecurityPolicyService.cs index b9e30ce2f9..6bcb1abfb4 100644 --- a/src/NuGetGallery/Security/ISecurityPolicyService.cs +++ b/src/NuGetGallery/Security/ISecurityPolicyService.cs @@ -1,16 +1,38 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; +using System.Threading.Tasks; using System.Web; using NuGetGallery.Filters; namespace NuGetGallery.Security { /// - /// Service that manages evaluation of security policies. + /// Service for managing security policies. /// public interface ISecurityPolicyService { + /// + /// Available user security policy subscriptions. + /// + IEnumerable UserSubscriptions { get; } + + /// + /// Check if a user is subscribed to one or more security policies. + /// + bool IsSubscribed(User user, IUserSecurityPolicySubscription subscription); + + /// + /// Subscribe a user to one or more security policies. + /// + Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription); + + /// + /// Unsubscribe a user from one or more security policies. + /// + Task UnsubscribeAsync(User user, IUserSecurityPolicySubscription subscription); + /// /// Evaluate any security policies that may apply to the current context. /// diff --git a/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs b/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs new file mode 100644 index 0000000000..c7979a2ed4 --- /dev/null +++ b/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace NuGetGallery.Security +{ + /// + /// One or more security policies which a user can subscribe to. + /// + public interface IUserSecurityPolicySubscription + { + /// + /// Subscription name. + /// + string Name { get; } + + /// + /// Required policies. + /// + IEnumerable Policies { get; } + + /// + /// Callback for user subscription. + /// + void OnSubscribe(User user); + + /// + /// Callback for user unsubscription. + /// + void OnUnsubscribe(User user); + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs b/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs index 4ebf5027ab..b9b4f58a33 100644 --- a/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs +++ b/src/NuGetGallery/Security/RequireMinClientVersionForPushPolicy.cs @@ -33,13 +33,13 @@ public RequireMinClientVersionForPushPolicy() /// /// Create a user security policy that requires a minimum client version. /// - public static UserSecurityPolicy CreatePolicy(NuGetVersion minClientVersion) + public static UserSecurityPolicy CreatePolicy(string subscription, NuGetVersion minClientVersion) { var value = JsonConvert.SerializeObject(new State() { MinClientVersion = minClientVersion }); - return new UserSecurityPolicy(PolicyName, value); + return new UserSecurityPolicy(PolicyName, subscription, value); } /// @@ -64,6 +64,9 @@ private NuGetVersion GetClientVersion(UserSecurityPolicyContext context) return NuGetVersion.TryParse(clientVersionString, out clientVersion) ? clientVersion : null; } + /// + /// Evaluate if this security policy is met. + /// public override SecurityPolicyResult Evaluate(UserSecurityPolicyContext context) { if (context == null) diff --git a/src/NuGetGallery/Security/SecurePushSubscription.cs b/src/NuGetGallery/Security/SecurePushSubscription.cs new file mode 100644 index 0000000000..2f98a097b8 --- /dev/null +++ b/src/NuGetGallery/Security/SecurePushSubscription.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Versioning; +using NuGetGallery.Authentication; + +namespace NuGetGallery.Security +{ + /// + /// User security policies for the secure push subscription. + /// + public class SecurePushSubscription : IUserSecurityPolicySubscription + { + public const string SubscriptionName = "SecurePush"; + public const string MinClientVersion = "4.1.0"; + + /// + /// Subscription name. + /// + public string Name + { + get + { + return SubscriptionName; + } + } + + /// + /// Required policies for this subscription. + /// + public IEnumerable Policies + { + get + { + yield return new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName, Name); + yield return RequireMinClientVersionForPushPolicy.CreatePolicy(Name, new NuGetVersion(MinClientVersion)); + } + } + + public void OnSubscribe(User user) + { + SetPushApiKeysToExpire(user); + } + + public void OnUnsubscribe(User user) + { + } + + /// + /// Expire API keys with push capability on secure push enrollment. + /// + private static void SetPushApiKeysToExpire(User user, int expirationInDays = 7) + { + var pushKeys = user.Credentials.Where(c => + CredentialTypes.IsApiKey(c.Type) && + ( + c.Scopes.Count == 0 || + c.Scopes.Any(s => + s.AllowedAction.Equals(NuGetScopes.PackagePush, StringComparison.OrdinalIgnoreCase) || + s.AllowedAction.Equals(NuGetScopes.PackagePushVersion, StringComparison.OrdinalIgnoreCase) + )) + ); + + foreach (var key in pushKeys) + { + var expires = DateTime.UtcNow.AddDays(expirationInDays); + if (!key.Expires.HasValue || key.Expires > expires) + { + key.Expires = expires; + } + } + } + } +} \ No newline at end of file diff --git a/src/NuGetGallery/Security/SecurityPolicyService.cs b/src/NuGetGallery/Security/SecurityPolicyService.cs index 11bcbffced..c2ad36031c 100644 --- a/src/NuGetGallery/Security/SecurityPolicyService.cs +++ b/src/NuGetGallery/Security/SecurityPolicyService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using System.Web; using NuGetGallery.Filters; @@ -14,14 +15,47 @@ namespace NuGetGallery.Security /// public class SecurityPolicyService : ISecurityPolicyService { - private static Lazy> _userPolicyHandlers = - new Lazy>(CreateUserPolicyHandlers); + private static Lazy> _userHandlers = + new Lazy>(CreateUserHandlers); - protected virtual IEnumerable UserPolicyHandlers + private static Lazy> _userSubscriptions = + new Lazy>(CreateUserSubscriptions); + + protected IEntitiesContext EntitiesContext { get; set; } + + protected SecurityPolicyService() + { + } + + public SecurityPolicyService(IEntitiesContext entitiesContext) + { + if (entitiesContext == null) + { + throw new ArgumentNullException(nameof(entitiesContext)); + } + + EntitiesContext = entitiesContext; + } + + /// + /// Available user security policy handlers. + /// + protected virtual IEnumerable UserHandlers + { + get + { + return _userHandlers.Value; + } + } + + /// + /// Available user security policy subscriptions. + /// + public virtual IEnumerable UserSubscriptions { get { - return _userPolicyHandlers.Value; + return _userSubscriptions.Value; } } @@ -36,7 +70,7 @@ public SecurityPolicyResult Evaluate(SecurityPolicyAction action, HttpContextBas } var user = httpContext.GetCurrentUser(); - foreach (var handler in UserPolicyHandlers.Where(h => h.Action == action)) + foreach (var handler in UserHandlers.Where(h => h.Action == action)) { var foundPolicies = user.SecurityPolicies.Where(p => p.Name.Equals(handler.Name, StringComparison.OrdinalIgnoreCase)); if (foundPolicies.Any()) @@ -52,12 +86,108 @@ public SecurityPolicyResult Evaluate(SecurityPolicyAction action, HttpContextBas } /// - /// Create any supported policy handlers. + /// Check if a user is subscribed to one or more security policies. /// - private static IEnumerable CreateUserPolicyHandlers() + public bool IsSubscribed(User user, IUserSecurityPolicySubscription subscription) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (subscription == null) + { + throw new ArgumentNullException(nameof(subscription)); + } + + var subscribed = FindPolicies(user, subscription); + var required = subscription.Policies; + + return required.All(rp => subscribed.Any(sp => sp.Equals(rp))); + } + + /// + /// Subscribe a user to one or more security policies. + /// + public Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscription) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (subscription == null) + { + throw new ArgumentNullException(nameof(subscription)); + } + + if (!IsSubscribed(user, subscription)) + { + foreach (var policy in subscription.Policies) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy)); + } + + subscription.OnSubscribe(user); + + return EntitiesContext.SaveChangesAsync(); + } + + return Task.CompletedTask; + } + + /// + /// Unsubscribe a user from one or more security policies. + /// + public Task UnsubscribeAsync(User user, IUserSecurityPolicySubscription subscription) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (subscription == null) + { + throw new ArgumentNullException(nameof(subscription)); + } + + var matches = FindPolicies(user, subscription).ToList(); + if (matches.Any()) + { + foreach (var policy in matches) + { + user.SecurityPolicies.Remove(policy); + EntitiesContext.UserSecurityPolicies.Remove(policy); + } + + subscription.OnUnsubscribe(user); + + return EntitiesContext.SaveChangesAsync(); + } + + return Task.CompletedTask; + } + + /// + /// Find user security policies which belong to a subscription. + /// + private static IEnumerable FindPolicies(User user, IUserSecurityPolicySubscription subscription) + { + return user.SecurityPolicies.Where(s => s.Subscription.Equals(subscription.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Registration of available user security policy handlers. + /// + private static IEnumerable CreateUserHandlers() { yield return new RequireMinClientVersionForPushPolicy(); yield return new RequirePackageVerifyScopePolicy(); - } + } + + /// + /// Registration of available user security policy subscriptions. + /// + private static IEnumerable CreateUserSubscriptions() + { + yield return new SecurePushSubscription(); + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs b/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs deleted file mode 100644 index 48b653f28a..0000000000 --- a/src/NuGetGallery/Security/UserSecurityPolicyExtensions.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace NuGetGallery.Security -{ - public static class UserSecurityPolicyExtensions - { - /// - /// Determine whether two security policies are equivalent. - /// - public static bool Matches(this UserSecurityPolicy first, UserSecurityPolicy second) - { - return first.Name.Equals(second.Name, StringComparison.OrdinalIgnoreCase) && - ( - (string.IsNullOrEmpty(first.Value) && string.IsNullOrEmpty(second.Value)) || - (first.Value.Equals(second.Value, StringComparison.OrdinalIgnoreCase)) - ); - } - - /// - /// Check whether a user has the security policies required for a policy group. - /// - /// True if enrolled (has all policies), false otherwise. - public static bool IsEnrolled(this User user, UserSecurityPolicyGroup policyGroup) - { - return !user.FindPolicies(policyGroup).Any(p => p == null); - } - - /// - /// Ensure user is enrolled in the security policy group. - /// - /// User to enroll. - /// User security policy group to enroll in. - public static void AddPolicies(this User user, UserSecurityPolicyGroup policyGroup) - { - // Add policies, if not already enrolled in all group policies. - if (!user.IsEnrolled(policyGroup)) - { - foreach (var policy in policyGroup.Policies) - { - user.SecurityPolicies.Add(new UserSecurityPolicy(policy.Name, policy.Value)); - } - policyGroup.OnEnroll?.Invoke(user); - } - } - - /// - /// Ensure user is unenrolled from the security policy group. - /// - /// User to unenroll. - /// User security policy group to unenroll from. - public static IEnumerable RemovePolicies(this User user, UserSecurityPolicyGroup policyGroup) - { - // Remove policies, only if enrolled in all group policies. - var matches = user.FindPolicies(policyGroup).ToList(); - if (!matches.Any(p => p == null)) - { - foreach (var policy in matches) - { - user.SecurityPolicies.Remove(policy); - } - } - return matches; - } - - /// - /// Find user security policies which are part of a security policy group. - /// - private static IEnumerable FindPolicies(this User user, UserSecurityPolicyGroup policyGroup) - { - return policyGroup.Policies.Select(gp => user.SecurityPolicies.FirstOrDefault(up => up.Matches(gp))); - } - } -} \ No newline at end of file diff --git a/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs b/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs deleted file mode 100644 index d1f804ad10..0000000000 --- a/src/NuGetGallery/Security/UserSecurityPolicyGroup.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using NuGet.Versioning; -using NuGetGallery.Authentication; - -namespace NuGetGallery.Security -{ - /// - /// Grouping of one or more user security policies for enrollment. - /// - public class UserSecurityPolicyGroup - { - public const string SecurePush = "SecurePush"; - public const string SecurePushVersion = "4.1.0"; - - /// - /// Policy group name. - /// - public string Name { get; set; } - - /// - /// Required policies. - /// - public IEnumerable Policies { get; set; } - - /// - /// Action to take on user enrollment. - /// - public Action OnEnroll { get; set; } - - /// - /// Get supported user security policy groups. - /// - private static List _instances; - public static List Instances - { - get - { - if (_instances == null) - { - _instances = CreateUserPolicyGroups().ToList(); - } - return _instances; - } - } - - private static IEnumerable CreateUserPolicyGroups() - { - yield return new UserSecurityPolicyGroup() - { - Name = UserSecurityPolicyGroup.SecurePush, - Policies = new[] - { - new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName), - RequireMinClientVersionForPushPolicy.CreatePolicy(new NuGetVersion(SecurePushVersion)) - }, - OnEnroll = OnEnroll_SecurePush - }; - } - - private static void OnEnroll_SecurePush(User user) - { - var pushKeys = user.Credentials.Where(c => - c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) && - ( - c.Scopes.Count == 0 || - c.Scopes.Any(s => - s.AllowedAction.Equals(NuGetScopes.PackagePush, StringComparison.OrdinalIgnoreCase) || - s.AllowedAction.Equals(NuGetScopes.PackagePushVersion, StringComparison.OrdinalIgnoreCase) - )) - ); - - foreach (var key in pushKeys) - { - var maxExpiration = DateTime.UtcNow.AddDays(7); - if (!key.Expires.HasValue || key.Expires > maxExpiration) - { - key.Expires = maxExpiration; - } - } - } - } -} \ No newline at end of file diff --git a/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs b/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs new file mode 100644 index 0000000000..ac8ccb0ecd --- /dev/null +++ b/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Xunit; + +namespace NuGetGallery +{ + public class UserSecurityPolicyFacts + { + [Fact] + public void CtorWithPolicyCopiesProperties() + { + // Arrange + var policy = new UserSecurityPolicy("A", "B", "C"); + + // Act + var copy = new UserSecurityPolicy(policy); + + // Assert + Assert.Equal(policy.Name, copy.Name); + Assert.Equal(policy.Subscription, copy.Subscription); + Assert.Equal(policy.Value, copy.Value); + } + + public static IEnumerable EqualsReturnsTrue_Data() + { + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "B", null) + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("a", "b", "c") + }; + } + + [Theory] + [MemberData(nameof(EqualsReturnsTrue_Data))] + public void EqualsReturnsTrueForPolicyMatches(UserSecurityPolicy first, UserSecurityPolicy second) + { + // Act & Assert + Assert.True(first.Equals(second)); + } + + public static IEnumerable EqualsReturnsFalse_Data() + { + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("B", "B", "") + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "A", "") + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("A", "B", "Z") + }; + } + + [Theory] + [MemberData(nameof(EqualsReturnsFalse_Data))] + public void EqualsReturnsFalseForPolicyNonMatches(UserSecurityPolicy first, UserSecurityPolicy second) + { + // Act & Assert + Assert.False(first.Equals(second)); + } + } +} \ No newline at end of file diff --git a/tests/NuGetGallery.Core.Facts/NuGetGallery.Core.Facts.csproj b/tests/NuGetGallery.Core.Facts/NuGetGallery.Core.Facts.csproj index 50b7f7f796..29966cc317 100644 --- a/tests/NuGetGallery.Core.Facts/NuGetGallery.Core.Facts.csproj +++ b/tests/NuGetGallery.Core.Facts/NuGetGallery.Core.Facts.csproj @@ -134,6 +134,7 @@ + diff --git a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj index 142bed6cc3..c9375a8b66 100644 --- a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj +++ b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj @@ -411,8 +411,7 @@ - - + diff --git a/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs b/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs index 4cbf8338be..db1b8f9414 100644 --- a/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs +++ b/tests/NuGetGallery.Facts/Security/RequireMinClientVersionForPushPolicyFacts.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Web; using Moq; +using NuGet.Versioning; using Xunit; namespace NuGetGallery.Security @@ -52,36 +53,6 @@ public void EvaluateReturnsFailureIfNoClientHeader() Assert.NotNull(result.ErrorMessage); } - [Fact] - public void EvaluateReturnsSuccess_PolicyMissingMinVerAndClientVersionHeader() - { - // Arrange & Act - var result = Evaluate(minClientVersions: "", actualClientVersion: "4.1.0"); - - // Assert - Assert.True(result.Success); - Assert.Null(result.ErrorMessage); - } - - [Fact] - public void EvaluateReturnsFailure_PolicyMissingMinVerAndNoClientVersionHeader() - { - // Arrange & Act - var result = Evaluate(minClientVersions: "", actualClientVersion: ""); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.ErrorMessage); - } - - private static UserSecurityPolicy CreateMinClientVersionForPushPolicy(string minClientVersion) - { - return new UserSecurityPolicy("RequireMinClientVersionForPushPolicy") - { - Value = string.IsNullOrEmpty(minClientVersion) ? null : $"{{\"v\":\"{minClientVersion}\"}}" - }; - } - private SecurityPolicyResult Evaluate(string minClientVersions, string actualClientVersion) { var headers = new NameValueCollection(); @@ -97,7 +68,7 @@ private SecurityPolicyResult Evaluate(string minClientVersions, string actualCli httpContext.Setup(c => c.Request).Returns(httpRequest.Object); var policies = minClientVersions.Split(',').Select( - v => CreateMinClientVersionForPushPolicy(v) + v => RequireMinClientVersionForPushPolicy.CreatePolicy("Subscription", new NuGetVersion(v)) ).ToArray(); var context = new UserSecurityPolicyContext(httpContext.Object, policies); diff --git a/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs b/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs index 86041c2f7d..0dd908bf55 100644 --- a/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs +++ b/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs @@ -68,7 +68,7 @@ private SecurityPolicyResult Evaluate(string scopes) httpContext.Setup(c => c.User).Returns(principal.Object); var context = new UserSecurityPolicyContext(httpContext.Object, - new UserSecurityPolicy[] { new UserSecurityPolicy("RequireApiKeyWithPackageVerifyScopePolicy") }); + new UserSecurityPolicy[] { new UserSecurityPolicy("RequireApiKeyWithPackageVerifyScopePolicy", "Enrollment") }); return new RequirePackageVerifyScopePolicy().Evaluate(context); } diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs b/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs similarity index 50% rename from tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs rename to tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs index 80fa8fc704..c9a2e77943 100644 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyGroupFacts.cs +++ b/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs @@ -4,22 +4,46 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using Moq; using Newtonsoft.Json; using NuGet.Packaging; using Xunit; namespace NuGetGallery.Security { - public class UserSecurityPolicyGroupFacts + public class SecurePushSubscriptionFacts { + [Fact] + public void SubscriptionName() + { + // Act & Assert. + Assert.Equal("SecurePush", new SecurePushSubscription().Name); + } + + [Fact] + public void SubscriptionPolicies() + { + // Arrange. + var subscription = new SecurePushSubscription(); + var policy1 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequireMinClientVersionForPushPolicy.PolicyName)); + var policy2 = subscription.Policies.FirstOrDefault(p => p.Name.Equals(RequirePackageVerifyScopePolicy.PolicyName)); + + // Act & Assert. + Assert.Equal(2, subscription.Policies.Count()); + Assert.NotNull(policy1); + Assert.NotNull(policy2); + Assert.Equal("{\"v\":\"4.1.0\"}", policy1.Value); + } + [Theory] [InlineData("")] [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] - public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) + public void OnSubscribeExpiresPushApiKeysIn1Week(string scopes) { // Arrange & Act. - var user = EnrollUserInSecurePush(CredentialTypes.ApiKey.V2, scopes); + var user = SubscribeUserToSecurePush(CredentialTypes.ApiKey.V2, scopes); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); @@ -30,10 +54,10 @@ public void SecurePush_OnEnrollExpiresPushApiKeys(string scopes) [InlineData("password.v3", "")] [InlineData("apikey.v2", "[{\"a\":\"package:unlist\", \"s\":\"theId\"}]")] [InlineData("apikey.verify.v1", "[{\"a\":\"package:verify\", \"s\":\"theId\"}]")] - public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, string scopes) + public void OnSubscribeDoesNotExpireNonPushCredentials(string type, string scopes) { // Arrange & Act. - var user = EnrollUserInSecurePush(type, scopes); + var user = SubscribeUserToSecurePush(type, scopes); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); @@ -44,21 +68,24 @@ public void SecurePush_OnEnrollDoesNotExpireNonPushCredentials(string type, stri [InlineData("")] [InlineData("[{\"a\":\"package:push\", \"s\":\"theId\"}]")] [InlineData("[{\"a\":\"package:pushversion\", \"s\":\"theId\"}]")] - public void SecurePush_OnEnrollDoesNotChangeExpiringPushCredentials(string scopes) + public void OnSubscribeDoesNotChangeExpiringPushCredentials(string scopes) { // Arrange & Act. - var user = EnrollUserInSecurePush(CredentialTypes.ApiKey.V2, scopes, expiresInDays: 2); + var user = SubscribeUserToSecurePush(CredentialTypes.ApiKey.V2, scopes, expiresInDays: 2); // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); Assert.True(DateTime.UtcNow.AddDays(2) >= user.Credentials.First().Expires); } - private User EnrollUserInSecurePush(string type, string scopes, int expiresInDays = 10) + private User SubscribeUserToSecurePush(string type, string scopes, int expiresInDays = 10) { // Arrange. - var group = UserSecurityPolicyGroup.Instances.First( - g => g.Name.Equals(UserSecurityPolicyGroup.SecurePush, StringComparison.OrdinalIgnoreCase)); + var entitiesContext = new Mock(); + entitiesContext.Setup(c => c.SaveChangesAsync()).Returns(Task.FromResult(2)).Verifiable(); + + var service = new SecurityPolicyService(entitiesContext.Object); + var subscription = service.UserSubscriptions.First(s => s.Name.Equals(SecurePushSubscription.SubscriptionName)); var credential = new Credential(type, string.Empty, TimeSpan.FromDays(expiresInDays)); if (!string.IsNullOrWhiteSpace(scopes)) @@ -69,7 +96,8 @@ private User EnrollUserInSecurePush(string type, string scopes, int expiresInDay user.Credentials.Add(credential); // Act. - user.AddPolicies(group); + service.SubscribeAsync(user, subscription); + entitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); return user; } diff --git a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs index 55ae8ffda2..054908c221 100644 --- a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs @@ -1,19 +1,69 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; +using System.Data.Entity; using System.Linq; +using System.Reflection; +using System.Threading.Tasks; using System.Web; using Moq; using NuGetGallery.Filters; using NuGetGallery.Framework; using Xunit; -using System.Reflection; namespace NuGetGallery.Security { public class SecurityPolicyServiceFacts { + [Fact] + public void CtorThrowsIfEntitiesContextNull() + { + Assert.Throws(() => new SecurityPolicyService(null)); + } + + [Fact] + public void UserSubscriptions() + { + // Arrange. + var entitiesContext = new Mock(); + var service = new SecurityPolicyService(entitiesContext.Object); + + // Act. + var subscriptions = service.UserSubscriptions; + + // Assert. + Assert.Equal(1, subscriptions.Count()); + Assert.Equal("SecurePush", subscriptions.First().Name); + } + + [Fact] + public void UserHandlers() + { + // Arrange. + var entitiesContext = new Mock(); + var service = new SecurityPolicyService(entitiesContext.Object); + + // Act. + var handlers = ((IEnumerable)service.GetType() + .GetProperty("UserHandlers", BindingFlags.GetProperty | BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(service)).ToList(); + + // Assert + Assert.NotNull(handlers); + Assert.Equal(2, handlers.Count); + Assert.Equal(typeof(RequireMinClientVersionForPushPolicy), handlers[0].GetType()); + Assert.Equal(typeof(RequirePackageVerifyScopePolicy), handlers[1].GetType()); + } + + [Fact] + public void EvaluateThrowsIfHttpContextNull() + { + Assert.Throws(() => new TestSecurityPolicyService() + .Evaluate(SecurityPolicyAction.PackagePush, null)); + } + [Fact] public void EvaluateReturnsSuccessWithoutEvaluationIfNoPoliciesFound() { @@ -28,8 +78,8 @@ public void EvaluateReturnsSuccessWithoutEvaluationIfNoPoliciesFound() Assert.True(result.Success); Assert.Null(result.ErrorMessage); - service.MockPushPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Never); - service.MockPushPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Never); + service.MockPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Never); + service.MockPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Never); } [Fact] @@ -38,8 +88,7 @@ public void EvaluateReturnsSuccessWithEvaluationIfPoliciesFoundAndMet() // Arrange var service = new TestSecurityPolicyService(); var user = new User("testUser"); - user.SecurityPolicies.Add(new UserSecurityPolicy("MockPushPolicy1")); - user.SecurityPolicies.Add(new UserSecurityPolicy("MockPushPolicy2")); + user.SecurityPolicies = TestSecurityPolicyService.GetMockPolicies().ToList(); // Act var result = service.Evaluate(SecurityPolicyAction.PackagePush, CreateHttpContext(user)); @@ -48,8 +97,8 @@ public void EvaluateReturnsSuccessWithEvaluationIfPoliciesFoundAndMet() Assert.True(result.Success); Assert.Null(result.ErrorMessage); - service.MockPushPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Once); - service.MockPushPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Once); + service.MockPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Once); + service.MockPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Once); } [Fact] @@ -58,34 +107,249 @@ public void EvaluateReturnsAfterFirstFailure() // Arrange var service = new TestSecurityPolicyService(success1: false, success2: true); var user = new User("testUser"); - user.SecurityPolicies.Add(new UserSecurityPolicy("MockPushPolicy1")); - user.SecurityPolicies.Add(new UserSecurityPolicy("MockPushPolicy2")); + user.SecurityPolicies = TestSecurityPolicyService.GetMockPolicies().ToList(); // Act var result = service.Evaluate(SecurityPolicyAction.PackagePush, CreateHttpContext(user)); // Assert Assert.False(result.Success); - Assert.Equal("MockPushPolicy1", result.ErrorMessage); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy1), result.ErrorMessage); - service.MockPushPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Once); - service.MockPushPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Never); + service.MockPolicy1.Verify(p => p.Evaluate(It.IsAny()), Times.Once); + service.MockPolicy2.Verify(p => p.Evaluate(It.IsAny()), Times.Never); } [Fact] - public void LoadUserPolicyHandlersPopulatesAllHandlers() + public void IsSubscribedThrowsIfUserNull() { - // Arrange & Act - var service = new SecurityPolicyService(); - var handlers = ((IEnumerable)service.GetType() - .GetProperty("UserPolicyHandlers", BindingFlags.GetProperty | BindingFlags.NonPublic | BindingFlags.Instance) - .GetValue(service)).ToList(); + Assert.Throws(() => + new TestSecurityPolicyService().IsSubscribed(null, new SecurePushSubscription())); + } - // Assert - Assert.NotNull(handlers); - Assert.Equal(2, handlers.Count); - Assert.Equal(typeof(RequireMinClientVersionForPushPolicy), handlers[0].GetType()); - Assert.Equal(typeof(RequirePackageVerifyScopePolicy), handlers[1].GetType()); + [Fact] + public void IsSubscribedThrowsIfSubscriptionNull() + { + Assert.Throws(() => + new TestSecurityPolicyService().IsSubscribed(new User(), null)); + } + + [Fact] + public void IsSubscribedReturnsTrueWhenUserHasSamePolicies() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + user.SecurityPolicies = TestSecurityPolicyService.GetMockPolicies().ToList(); + + // Act & Assert. + Assert.True(service.IsSubscribed(user, service.UserSubscriptions.First())); + } + + [Fact] + public void IsSubscribedReturnsTrueWhenUserHasMorePolicies() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + user.SecurityPolicies.Add(new UserSecurityPolicy("OtherPolicy", "OtherSubscription")); + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(policy); + } + + // Act & Assert. + Assert.True(service.IsSubscribed(user, service.UserSubscriptions.First())); + } + + [Fact] + public void IsSubscribedReturnsTrueWhenUserDoesNotHaveAllPolicies() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + user.SecurityPolicies.Add(TestSecurityPolicyService.GetMockPolicies().First()); + + // Act & Assert. + Assert.False(service.IsSubscribed(user, service.UserSubscriptions.First())); + } + + [Fact] + public void SubscribeThrowsIfUserNull() + { + Assert.ThrowsAsync(() => + new TestSecurityPolicyService().SubscribeAsync(null, new SecurePushSubscription())); + } + + [Fact] + public void SubscribeThrowsIfSubscriptionNull() + { + Assert.ThrowsAsync(() => + new TestSecurityPolicyService().SubscribeAsync(new User(), null)); + } + + [Fact] + public void SubscribeAddsUserPoliciesWhenNone() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + + // Act. + service.SubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + var policies = user.SecurityPolicies.ToList(); + Assert.Equal(2, policies.Count); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy1), policies[0].Name); + Assert.Equal(TestSecurityPolicyService.MockSubscriptionName, policies[0].Subscription); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy2), policies[1].Name); + Assert.Equal(TestSecurityPolicyService.MockSubscriptionName, policies[1].Subscription); + + service.MockSubscription.Verify(s => s.OnSubscribe(It.IsAny()), Times.Once); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); + } + + [Fact] + public void SubscribeAddsUserPoliciesWhenSameFromDifferentSubscription() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + var subscriptionName2 = "MockSubscription2"; + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy.Name, subscriptionName2)); + } + + // Act. + service.SubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + var policies = user.SecurityPolicies.ToList(); + Assert.Equal(4, policies.Count); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy1), policies[0].Name); + Assert.Equal(subscriptionName2, policies[0].Subscription); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy2), policies[1].Name); + Assert.Equal(subscriptionName2, policies[1].Subscription); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy1), policies[2].Name); + Assert.Equal(TestSecurityPolicyService.MockSubscriptionName, policies[2].Subscription); + Assert.Equal(nameof(TestSecurityPolicyService.MockPolicy2), policies[3].Name); + Assert.Equal(TestSecurityPolicyService.MockSubscriptionName, policies[3].Subscription); + + service.MockSubscription.Verify(s => s.OnSubscribe(It.IsAny()), Times.Once); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); + } + + [Fact] + public void SubscribeSkipsUserPoliciesWhenAlreadySubscribed() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy)); + } + Assert.Equal(2, user.SecurityPolicies.Count); + + // Act. + service.SubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + Assert.Equal(2, user.SecurityPolicies.Count); + + service.MockSubscription.Verify(s => s.OnSubscribe(It.IsAny()), Times.Never); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Never); + } + + [Fact] + public void UnsubscribeThrowsIfUserNull() + { + Assert.ThrowsAsync(() => + new TestSecurityPolicyService().UnsubscribeAsync(null, new SecurePushSubscription())); + } + + [Fact] + public void UnsubscribeThrowsIfSubscriptionNull() + { + Assert.ThrowsAsync(() => + new TestSecurityPolicyService().UnsubscribeAsync(new User(), null)); + } + + [Fact] + public void UnsubscribeRemovesAllSubscriptionPolicies() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy)); + } + Assert.Equal(2, user.SecurityPolicies.Count); + + // Act. + service.UnsubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + Assert.Equal(0, user.SecurityPolicies.Count); + + service.MockSubscription.Verify(s => s.OnUnsubscribe(It.IsAny()), Times.Once); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); + service.MockUserSecurityPolicies.Verify(p => p.Remove(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void UnsubscribeDoesNotRemoveOtherSubscriptionPolicies() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + var subscriptionName2 = "MockSubscription2"; + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy)); + user.SecurityPolicies.Add(new UserSecurityPolicy(policy.Name, subscriptionName2)); + } + Assert.Equal(4, user.SecurityPolicies.Count); + + // Act. + service.UnsubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + var policies = user.SecurityPolicies.ToList(); + Assert.Equal(2, policies.Count); + Assert.Equal(subscriptionName2, policies[0].Subscription); + Assert.Equal(subscriptionName2, policies[1].Subscription); + + service.MockSubscription.Verify(s => s.OnUnsubscribe(It.IsAny()), Times.Once); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Once); + service.MockUserSecurityPolicies.Verify(p => p.Remove(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public void UnsubscribeRemovesNoneIfNotSubscribed() + { + // Arrange. + var service = new TestSecurityPolicyService(); + var user = new User("testUser"); + var subscriptionName2 = "MockSubscription2"; + foreach (var policy in TestSecurityPolicyService.GetMockPolicies()) + { + user.SecurityPolicies.Add(new UserSecurityPolicy(policy.Name, subscriptionName2)); + } + Assert.Equal(2, user.SecurityPolicies.Count); + + // Act. + service.UnsubscribeAsync(user, service.UserSubscriptions.First()); + + // Act & Assert. + Assert.Equal(2, user.SecurityPolicies.Count); + + service.MockSubscription.Verify(s => s.OnUnsubscribe(It.IsAny()), Times.Never); + service.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Never); + service.MockUserSecurityPolicies.Verify(p => p.Remove(It.IsAny()), Times.Never); } private HttpContextBase CreateHttpContext(User user) @@ -100,27 +364,63 @@ private HttpContextBase CreateHttpContext(User user) return httpContext.Object; } - class TestSecurityPolicyService : SecurityPolicyService + public class TestSecurityPolicyService : SecurityPolicyService { + public const string MockSubscriptionName = "MockSubscription"; + + public Mock MockEntitiesContext { get; } + + public Mock> MockUserSecurityPolicies { get; set; } + + public Mock MockSubscription { get; } + + public Mock MockPolicy1 { get; } + + public Mock MockPolicy2 { get; } + public TestSecurityPolicyService(bool success1 = true, bool success2 = true) { - MockPushPolicy1 = MockHandler("MockPushPolicy1", success1); - MockPushPolicy2 = MockHandler("MockPushPolicy2", success2); - } + MockUserSecurityPolicies = new Mock>(); + MockUserSecurityPolicies.Setup(p => p.Remove(It.IsAny())).Verifiable(); + + MockEntitiesContext = new Mock(); + MockEntitiesContext.Setup(c => c.SaveChangesAsync()).Returns(Task.FromResult(2)).Verifiable(); + MockEntitiesContext.Setup(c => c.UserSecurityPolicies).Returns(MockUserSecurityPolicies.Object); + EntitiesContext = MockEntitiesContext.Object; - public Mock MockPushPolicy1 { get; set; } + MockPolicy1 = MockHandler(nameof(MockPolicy1), success1); + MockPolicy2 = MockHandler(nameof(MockPolicy2), success2); - public Mock MockPushPolicy2 { get; set; } + MockSubscription = new Mock(); + MockSubscription.Setup(s => s.Name).Returns(MockSubscriptionName); + MockSubscription.Setup(s => s.OnSubscribe(It.IsAny())).Verifiable(); + MockSubscription.Setup(s => s.OnUnsubscribe(It.IsAny())).Verifiable(); + MockSubscription.Setup(s => s.Policies).Returns(GetMockPolicies()); + } + + public override IEnumerable UserSubscriptions + { + get + { + yield return MockSubscription.Object; + } + } - protected override IEnumerable UserPolicyHandlers + protected override IEnumerable UserHandlers { get { - yield return MockPushPolicy1.Object; - yield return MockPushPolicy2.Object; + yield return MockPolicy1.Object; + yield return MockPolicy2.Object; } } + public static IEnumerable GetMockPolicies() + { + yield return new UserSecurityPolicy(nameof(MockPolicy1), MockSubscriptionName); + yield return new UserSecurityPolicy(nameof(MockPolicy2), MockSubscriptionName); + } + private Mock MockHandler(string name, bool success) { var result = success ? SecurityPolicyResult.SuccessResult : SecurityPolicyResult.CreateErrorResult(name); diff --git a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs b/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs deleted file mode 100644 index 57c09230e5..0000000000 --- a/tests/NuGetGallery.Facts/Security/UserSecurityPolicyExtensionsFacts.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Collections.Generic; -using System.Linq; -using Xunit; -using Newtonsoft.Json; -using System.Web.Mvc; - -namespace NuGetGallery.Security -{ - public class UserSecurityPolicyExtensionsFacts - { - [Theory] - [InlineData("", "")] - [InlineData(null, "")] - [InlineData(null, null)] - public void MatchesReturnsTrue(string value1, string value2) - { - // Arrange. - var policy1 = new UserSecurityPolicy("A") { Value = value1 }; - var policy2 = new UserSecurityPolicy("A") { Value = value2 }; - - // Act & Assert. - Assert.True(policy1.Matches(policy2)); - } - - [Fact] - public void MatchesReturnsFalseIfNameDiffers() - { - // Arrange. - var policy1 = new UserSecurityPolicy("A"); - var policy2 = new UserSecurityPolicy("B"); - - // Act & Assert. - Assert.False(policy1.Matches(policy2)); - } - - [Fact] - public void MatchesReturnsFalseIfValueDiffers() - { - // Arrange. - var policy1 = new UserSecurityPolicy("A") { Value = "B" }; - var policy2 = new UserSecurityPolicy("A") { Value = "C" }; - - // Act & Assert. - Assert.False(policy1.Matches(policy2)); - } - - [Theory] - [InlineData("[[\"A\",\"\"]]", "[[\"A\",null]]")] - [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]", "[[\"E\",\"\"],[\"A\",\"B\"],[\"C\",\"D\"]]")] - public void IsEnrolledReturnsTrue(string groupPolicies, string userPolicies) - { - // Arrange. - var group = new UserSecurityPolicyGroup() - { - Policies = LoadPolicies(groupPolicies) - }; - var user = new User(); - LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); - - // Act & Assert. - Assert.True(user.IsEnrolled(group)); - } - - [Theory] - [InlineData("[[\"A\",\"B\"],[\"E\",null]]", "[]")] - [InlineData("[[\"A\",\"B\"],[\"E\",null]]", "[[\"A\",\"B\"],[\"C\",\"D\"]]")] - public void IsEnrolledReturnsFalse(string groupPolicies, string userPolicies) - { - // Arrange. - var group = new UserSecurityPolicyGroup() - { - Policies = LoadPolicies(groupPolicies) - }; - var user = new User(); - LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); - - // Act & Assert. - Assert.False(user.IsEnrolled(group)); - } - - [Theory] - [InlineData("[[\"A\",\"\"]]")] - [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]")] - public void AddPoliciesAddsPolicies(string groupPolicies) - { - // Arrange. - var group = new UserSecurityPolicyGroup() - { - Policies = LoadPolicies(groupPolicies) - }; - var user = new User(); - - // Act. - user.AddPolicies(group); - - // Assert. - Assert.Equal(group.Policies.Count(), user.SecurityPolicies.Count()); - } - - [Theory] - [InlineData("[[\"A\",\"\"]]", "[[\"A\",null]]", 0)] - [InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]", "[[\"E\",\"\"],[\"A\",\"B\"],[\"C\",\"D\"]]", 1)] - public void RemovePoliciesRemovesPolicies(string groupPolicies, string userPolicies, int expectedCount) - { - // Arrange. - var group = new UserSecurityPolicyGroup() - { - Policies = LoadPolicies(groupPolicies) - }; - var user = new User(); - LoadPolicies(userPolicies).ToList().ForEach(p => user.SecurityPolicies.Add(p)); - - // Act. - user.RemovePolicies(group); - - // Assert. - Assert.Equal(expectedCount, user.SecurityPolicies.Count()); - } - - private IEnumerable LoadPolicies(string policiesString) - { - var policies = (string[][])JsonConvert.DeserializeObject(policiesString); - if (policies != null) - { - foreach (var p in policies) - { - yield return new UserSecurityPolicy(p[0]) { Value = p[1] }; - } - } - } - } -} From 7222a3ce1c1a7bda7a2d87d776241828025b0744 Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Thu, 4 May 2017 15:06:32 -0700 Subject: [PATCH 8/9] cleanup --- .../Security/RequirePackageVerifyScopePolicyFacts.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs b/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs index 0dd908bf55..1164f6fe34 100644 --- a/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs +++ b/tests/NuGetGallery.Facts/Security/RequirePackageVerifyScopePolicyFacts.cs @@ -68,7 +68,7 @@ private SecurityPolicyResult Evaluate(string scopes) httpContext.Setup(c => c.User).Returns(principal.Object); var context = new UserSecurityPolicyContext(httpContext.Object, - new UserSecurityPolicy[] { new UserSecurityPolicy("RequireApiKeyWithPackageVerifyScopePolicy", "Enrollment") }); + new UserSecurityPolicy[] { new UserSecurityPolicy("RequireApiKeyWithPackageVerifyScopePolicy", "Subscription") }); return new RequirePackageVerifyScopePolicy().Evaluate(context); } From 5cebd42b0d263fb763ed57ed3519f55b4830f01d Mon Sep 17 00:00:00 2001 From: Christy Henriksson Date: Fri, 5 May 2017 15:14:16 -0700 Subject: [PATCH 9/9] PR feedback --- .../Entities/UserSecurityPolicy.cs | 33 ++- .../Controllers/SecurityPolicyController.cs | 36 ++-- .../IUserSecurityPolicySubscription.cs | 2 +- .../Security/SecurePushSubscription.cs | 17 +- .../Security/SecurityPolicyService.cs | 11 +- .../Entities/UserSecurityPolicyFacts.cs | 48 +++-- .../SecurityPolicyControllerFacts.cs | 203 ++++++++++++++++++ .../NuGetGallery.Facts.csproj | 2 + .../Security/SecurePushSubscriptionFacts.cs | 10 +- .../Security/SecurityPolicyServiceFacts.cs | 70 +----- .../Security/TestSecurityPolicyService.cs | 78 +++++++ .../TestUtils/MockExtensions.cs | 17 +- 12 files changed, 375 insertions(+), 152 deletions(-) create mode 100644 tests/NuGetGallery.Facts/Areas/Admin/Controllers/SecurityPolicyControllerFacts.cs create mode 100644 tests/NuGetGallery.Facts/Security/TestSecurityPolicyService.cs diff --git a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs index d935b43d51..2af8266c70 100644 --- a/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs +++ b/src/NuGetGallery.Core/Entities/UserSecurityPolicy.cs @@ -15,27 +15,16 @@ public UserSecurityPolicy() { } - public UserSecurityPolicy(string name, string subscription, string value = null) + public UserSecurityPolicy(UserSecurityPolicy policy) + : this(policy.Name, policy.Subscription, policy.Value) { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - if (string.IsNullOrEmpty(subscription)) - { - throw new ArgumentNullException(nameof(subscription)); - } - - Name = name; - Subscription = subscription; - Value = value; } - - public UserSecurityPolicy(UserSecurityPolicy policy) + + public UserSecurityPolicy(string name, string subscription, string value = null) { - Name = policy.Name; - Subscription = policy.Subscription; - Value = policy.Value; + Name = name ?? throw new ArgumentNullException(nameof(name)); + Subscription = subscription ?? throw new ArgumentNullException(nameof(subscription)); + Value = value; } /// @@ -84,5 +73,13 @@ public bool Equals(UserSecurityPolicy other) (Value.Equals(other.Value, StringComparison.OrdinalIgnoreCase)) ); } + + private static readonly Func _hash = (i, hash) => ((hash << 5) + hash) ^ (i?.GetHashCode() ?? 0); + private const long _seed = 0x1505L; + + public override int GetHashCode() + { + return _hash(Value, _hash(Subscription, _hash(Name, _seed))).GetHashCode(); + } } } \ No newline at end of file diff --git a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs index 8e1954b77a..81141f313a 100644 --- a/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs +++ b/src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs @@ -18,23 +18,18 @@ namespace NuGetGallery.Areas.Admin.Controllers /// public class SecurityPolicyController : AdminControllerBase { - public IEntitiesContext EntitiesContext { get; } + protected IEntitiesContext EntitiesContext { get; set; } - public ISecurityPolicyService PolicyService { get; } + protected ISecurityPolicyService PolicyService { get; set; } - public SecurityPolicyController(IEntitiesContext entitiesContext, ISecurityPolicyService policyService) + protected SecurityPolicyController() { - if (entitiesContext == null) - { - throw new ArgumentNullException(nameof(entitiesContext)); - } - if (policyService == null) - { - throw new ArgumentNullException(nameof(policyService)); - } + } - EntitiesContext = entitiesContext; - PolicyService = policyService; + public SecurityPolicyController(IEntitiesContext entitiesContext, ISecurityPolicyService policyService) + { + EntitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); + PolicyService = policyService ?? throw new ArgumentNullException(nameof(policyService)); } [HttpGet] @@ -42,21 +37,19 @@ public virtual ActionResult Index() { var model = new SecurityPolicyViewModel() { - SubscriptionNames = PolicyService.UserSubscriptions.Select(s => s.Name) + SubscriptionNames = PolicyService.UserSubscriptions.Select(s => s.SubscriptionName) }; return View(model); } [HttpGet] - public virtual ActionResult Search(string query) + public virtual JsonResult Search(string query) { // Parse query and look for users in the DB. - var usernames = GetUsernamesFromQuery(query); + var usernames = GetUsernamesFromQuery(query ?? ""); var users = FindUsers(usernames); - var usersNotFound = usernames - .Where(name => !users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) - .ToList(); + var usersNotFound = usernames.Except(users.Select(u => u.Username)); var results = new UserSecurityPolicySearchResult() { @@ -65,7 +58,7 @@ public virtual ActionResult Search(string query) { Username = u.Username, Subscriptions = PolicyService.UserSubscriptions.ToDictionary( - s => s.Name, + s => s.SubscriptionName, s => PolicyService.IsSubscribed(u, s)) }), // Usernames that weren't found in the DB. @@ -95,7 +88,8 @@ public async Task Update(SecurityPolicyViewModel viewModel) { foreach (var subscription in PolicyService.UserSubscriptions) { - if (subscriptions != null && subscriptions[user.Username].Contains(subscription.Name)) + var userKeyExists = subscriptions?.ContainsKey(user.Username) ?? false; + if (userKeyExists && subscriptions[user.Username].Contains(subscription.SubscriptionName)) { await PolicyService.SubscribeAsync(user, subscription); } diff --git a/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs b/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs index c7979a2ed4..863d385de2 100644 --- a/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs +++ b/src/NuGetGallery/Security/IUserSecurityPolicySubscription.cs @@ -14,7 +14,7 @@ public interface IUserSecurityPolicySubscription /// /// Subscription name. /// - string Name { get; } + string SubscriptionName { get; } /// /// Required policies. diff --git a/src/NuGetGallery/Security/SecurePushSubscription.cs b/src/NuGetGallery/Security/SecurePushSubscription.cs index 2f98a097b8..b673c5248d 100644 --- a/src/NuGetGallery/Security/SecurePushSubscription.cs +++ b/src/NuGetGallery/Security/SecurePushSubscription.cs @@ -14,17 +14,18 @@ namespace NuGetGallery.Security /// public class SecurePushSubscription : IUserSecurityPolicySubscription { - public const string SubscriptionName = "SecurePush"; - public const string MinClientVersion = "4.1.0"; + public const string Name = "SecurePush"; + private const string MinClientVersion = "4.1.0"; + private const int PushKeysExpirationInDays = 30; /// /// Subscription name. /// - public string Name + public string SubscriptionName { get { - return SubscriptionName; + return Name; } } @@ -35,8 +36,8 @@ public IEnumerable Policies { get { - yield return new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName, Name); - yield return RequireMinClientVersionForPushPolicy.CreatePolicy(Name, new NuGetVersion(MinClientVersion)); + yield return new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName, SubscriptionName); + yield return RequireMinClientVersionForPushPolicy.CreatePolicy(SubscriptionName, new NuGetVersion(MinClientVersion)); } } @@ -52,7 +53,7 @@ public void OnUnsubscribe(User user) /// /// Expire API keys with push capability on secure push enrollment. /// - private static void SetPushApiKeysToExpire(User user, int expirationInDays = 7) + private static void SetPushApiKeysToExpire(User user) { var pushKeys = user.Credentials.Where(c => CredentialTypes.IsApiKey(c.Type) && @@ -66,7 +67,7 @@ private static void SetPushApiKeysToExpire(User user, int expirationInDays = 7) foreach (var key in pushKeys) { - var expires = DateTime.UtcNow.AddDays(expirationInDays); + var expires = DateTime.UtcNow.AddDays(PushKeysExpirationInDays); if (!key.Expires.HasValue || key.Expires > expires) { key.Expires = expires; diff --git a/src/NuGetGallery/Security/SecurityPolicyService.cs b/src/NuGetGallery/Security/SecurityPolicyService.cs index c2ad36031c..770d4203bc 100644 --- a/src/NuGetGallery/Security/SecurityPolicyService.cs +++ b/src/NuGetGallery/Security/SecurityPolicyService.cs @@ -29,12 +29,7 @@ protected SecurityPolicyService() public SecurityPolicyService(IEntitiesContext entitiesContext) { - if (entitiesContext == null) - { - throw new ArgumentNullException(nameof(entitiesContext)); - } - - EntitiesContext = entitiesContext; + EntitiesContext = entitiesContext ?? throw new ArgumentNullException(nameof(entitiesContext)); } /// @@ -118,7 +113,7 @@ public Task SubscribeAsync(User user, IUserSecurityPolicySubscription subscripti { throw new ArgumentNullException(nameof(subscription)); } - + if (!IsSubscribed(user, subscription)) { foreach (var policy in subscription.Policies) @@ -170,7 +165,7 @@ public Task UnsubscribeAsync(User user, IUserSecurityPolicySubscription subscrip /// private static IEnumerable FindPolicies(User user, IUserSecurityPolicySubscription subscription) { - return user.SecurityPolicies.Where(s => s.Subscription.Equals(subscription.Name, StringComparison.OrdinalIgnoreCase)); + return user.SecurityPolicies.Where(s => s.Subscription.Equals(subscription.SubscriptionName, StringComparison.OrdinalIgnoreCase)); } /// diff --git a/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs b/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs index ac8ccb0ecd..d8de80257c 100644 --- a/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs +++ b/tests/NuGetGallery.Core.Facts/Entities/UserSecurityPolicyFacts.cs @@ -22,17 +22,20 @@ public void CtorWithPolicyCopiesProperties() Assert.Equal(policy.Subscription, copy.Subscription); Assert.Equal(policy.Value, copy.Value); } - - public static IEnumerable EqualsReturnsTrue_Data() + + public static IEnumerable EqualsReturnsTrue_Data { - yield return new[] - { - new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "B", null) - }; - yield return new[] + get { - new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("a", "b", "c") - }; + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "B", null) + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("a", "b", "c") + }; + } } [Theory] @@ -43,20 +46,23 @@ public void EqualsReturnsTrueForPolicyMatches(UserSecurityPolicy first, UserSecu Assert.True(first.Equals(second)); } - public static IEnumerable EqualsReturnsFalse_Data() + public static IEnumerable EqualsReturnsFalse_Data { - yield return new[] - { - new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("B", "B", "") - }; - yield return new[] - { - new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "A", "") - }; - yield return new[] + get { - new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("A", "B", "Z") - }; + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("B", "B", "") + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", ""), new UserSecurityPolicy("A", "A", "") + }; + yield return new[] + { + new UserSecurityPolicy("A", "B", "C"), new UserSecurityPolicy("A", "B", "Z") + }; + } } [Theory] diff --git a/tests/NuGetGallery.Facts/Areas/Admin/Controllers/SecurityPolicyControllerFacts.cs b/tests/NuGetGallery.Facts/Areas/Admin/Controllers/SecurityPolicyControllerFacts.cs new file mode 100644 index 0000000000..b1012b74d5 --- /dev/null +++ b/tests/NuGetGallery.Facts/Areas/Admin/Controllers/SecurityPolicyControllerFacts.cs @@ -0,0 +1,203 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web.Mvc; +using Moq; +using NuGetGallery.Security; +using NuGetGallery.Areas.Admin.ViewModels; +using Xunit; + +namespace NuGetGallery.Areas.Admin.Controllers +{ + public class SecurityPolicyControllerFacts + { + [Fact] + public void CtorThrowsIfEntitiesContextNull() + { + // Arrange. + var policyService = new TestSecurityPolicyService(); + + // Act & Assert. + Assert.Throws(() => new SecurityPolicyController(null, policyService)); + } + + [Fact] + public void CtorThrowsIfPolicyServiceNull() + { + // Arrange. + var entitiesContext = new Mock(); + + // Act & Assert. + Assert.Throws(() => new SecurityPolicyController(entitiesContext.Object, null)); + } + + [Fact] + public void IndexActionReturnsSubscriptionNames() + { + // Arrange. + var policyService = new TestSecurityPolicyService(); + var entitiesContext = policyService.MockEntitiesContext.Object; + var controller = new SecurityPolicyController(entitiesContext, policyService); + + // Act. + var result = controller.Index(); + + // Assert + var model = ResultAssert.IsView(result); + Assert.NotNull(model); + Assert.NotNull(model.SubscriptionNames); + Assert.Equal(1, model.SubscriptionNames.Count()); + } + + [Theory] + [InlineData(null, 0, 0)] + [InlineData("", 0, 0)] + [InlineData("A, B, C", 3, 0)] + [InlineData("D, E, F", 0, 3)] + [InlineData("F,B, C,G,", 2, 2)] + [InlineData("A\n B\n C", 3, 0)] + [InlineData("D\n E\n F", 0, 3)] + [InlineData("F\nB\n C\nG\n", 2, 2)] + [InlineData("A\r B\r C", 3, 0)] + [InlineData("D\r E\r F", 0, 3)] + [InlineData("F\rB\r C\rG\r", 2, 2)] + [InlineData("A\n\r B\n\r C", 3, 0)] + [InlineData("D\n\r E\n\r F", 0, 3)] + [InlineData("F\n\rB\n\r C\n\rG\n\r", 2, 2)] + public void SearchFindsMatchingUsers(string query, int foundCount, int notFoundCount) + { + // Arrange. + var policyService = new TestSecurityPolicyService(); + var entitiesMock = policyService.MockEntitiesContext; + entitiesMock.Setup(c => c.Users).Returns(TestUsers.MockDbSet().Object); + var controller = new SecurityPolicyController(entitiesMock.Object, policyService); + + // Act. + JsonResult result = controller.Search(query); + + // Assert + dynamic data = result.Data; + var users = data.Users as IEnumerable; + var usersNotFound = data.UsersNotFound as IEnumerable; + + Assert.NotNull(users); + Assert.Equal(foundCount, users.Count()); + Assert.Equal(notFoundCount, usersNotFound.Count()); + } + + [Fact] + public async void SearchReturnsUserSubscriptions() + { + // Arrange. + var policyService = new TestSecurityPolicyService(); + var dbUsers = TestUsers.ToArray(); + await policyService.SubscribeAsync(dbUsers[1], policyService.MockSubscription.Object); + + var entitiesMock = policyService.MockEntitiesContext; + entitiesMock.Setup(c => c.Users).Returns(dbUsers.MockDbSet().Object); + var controller = new SecurityPolicyController(entitiesMock.Object, policyService); + + // Act. + JsonResult result = controller.Search("A,B,C"); + + // Assert. + dynamic data = result.Data; + var users = (data.Users as IEnumerable)?.ToList(); + var subscriptionName = policyService.MockSubscription.Object.SubscriptionName; + + Assert.NotNull(users); + Assert.Equal(3, users.Count()); + + Assert.Equal(1, users[0].Subscriptions.Count); + Assert.True(users[0].Subscriptions.ContainsKey(subscriptionName)); + Assert.False(users[0].Subscriptions[subscriptionName]); + + Assert.Equal(1, users[1].Subscriptions.Count); + Assert.True(users[1].Subscriptions.ContainsKey(subscriptionName)); + Assert.True(users[1].Subscriptions[subscriptionName]); + + Assert.Equal(1, users[2].Subscriptions.Count); + Assert.True(users[2].Subscriptions.ContainsKey(subscriptionName)); + Assert.False(users[2].Subscriptions[subscriptionName]); + } + + [Fact] + public async void UpdateSubscribesUsers() + { + // Arrange. + var users = TestUsers.ToList(); + var policyService = new TestSecurityPolicyService(); + var entitiesMock = policyService.MockEntitiesContext; + entitiesMock.Setup(c => c.Users).Returns(users.MockDbSet().Object); + var controller = new SecurityPolicyController(entitiesMock.Object, policyService); + var subscription = policyService.MockSubscription.Object; + + Assert.False(users.Any(u => policyService.IsSubscribed(u, subscription))); + + // Act. + var viewModel = new SecurityPolicyViewModel() + { + UsersQuery = "A,B,C", + UserSubscriptions = new[] + { + $"{{\"u\":\"A\",\"g\":\"{subscription.SubscriptionName}\"}}", + $"{{\"u\":\"C\",\"g\":\"{subscription.SubscriptionName}\"}}" + } + }; + var result = await controller.Update(viewModel); + + // Assert. + Assert.True(policyService.IsSubscribed(users[0], subscription)); + Assert.False(policyService.IsSubscribed(users[1], subscription)); + Assert.True(policyService.IsSubscribed(users[2], subscription)); + + policyService.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Exactly(2)); + } + + [Fact] + public async void UpdateUnsubscribesUsers() + { + // Arrange. + var users = TestUsers.ToList(); + var policyService = new TestSecurityPolicyService(); + var entitiesMock = policyService.MockEntitiesContext; + entitiesMock.Setup(c => c.Users).Returns(users.MockDbSet().Object); + var controller = new SecurityPolicyController(entitiesMock.Object, policyService); + var subscription = policyService.MockSubscription.Object; + + users.ForEach(async u => await policyService.SubscribeAsync(u, subscription)); + policyService.MockEntitiesContext.ResetCalls(); + + // Act. + var viewModel = new SecurityPolicyViewModel() + { + UsersQuery = "A,B,C", + UserSubscriptions = new[] + { + $"{{\"u\":\"B\",\"g\":\"{subscription.SubscriptionName}\"}}" + } + }; + var result = await controller.Update(viewModel); + + // Assert. + Assert.False(policyService.IsSubscribed(users[0], subscription)); + Assert.True(policyService.IsSubscribed(users[1], subscription)); + Assert.False(policyService.IsSubscribed(users[2], subscription)); + + policyService.MockEntitiesContext.Verify(c => c.SaveChangesAsync(), Times.Exactly(2)); + } + + private IEnumerable TestUsers + { + get + { + yield return new User("A"); + yield return new User("B"); + yield return new User("C"); + } + } + } +} diff --git a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj index c9375a8b66..a2df06ea79 100644 --- a/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj +++ b/tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj @@ -375,6 +375,7 @@ + @@ -412,6 +413,7 @@ + diff --git a/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs b/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs index c9a2e77943..e76f35012a 100644 --- a/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs +++ b/tests/NuGetGallery.Facts/Security/SecurePushSubscriptionFacts.cs @@ -18,7 +18,7 @@ public class SecurePushSubscriptionFacts public void SubscriptionName() { // Act & Assert. - Assert.Equal("SecurePush", new SecurePushSubscription().Name); + Assert.Equal("SecurePush", new SecurePushSubscription().SubscriptionName); } [Fact] @@ -47,7 +47,7 @@ public void OnSubscribeExpiresPushApiKeysIn1Week(string scopes) // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.True(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + Assert.True(DateTime.UtcNow.AddDays(30) >= user.Credentials.First().Expires); } [Theory] @@ -61,7 +61,7 @@ public void OnSubscribeDoesNotExpireNonPushCredentials(string type, string scope // Assert. Assert.Equal(2, user.SecurityPolicies.Count()); - Assert.False(DateTime.UtcNow.AddDays(7) >= user.Credentials.First().Expires); + Assert.False(DateTime.UtcNow.AddDays(30) >= user.Credentials.First().Expires); } [Theory] @@ -78,14 +78,14 @@ public void OnSubscribeDoesNotChangeExpiringPushCredentials(string scopes) Assert.True(DateTime.UtcNow.AddDays(2) >= user.Credentials.First().Expires); } - private User SubscribeUserToSecurePush(string type, string scopes, int expiresInDays = 10) + private User SubscribeUserToSecurePush(string type, string scopes, int expiresInDays = 100) { // Arrange. var entitiesContext = new Mock(); entitiesContext.Setup(c => c.SaveChangesAsync()).Returns(Task.FromResult(2)).Verifiable(); var service = new SecurityPolicyService(entitiesContext.Object); - var subscription = service.UserSubscriptions.First(s => s.Name.Equals(SecurePushSubscription.SubscriptionName)); + var subscription = service.UserSubscriptions.First(s => s.SubscriptionName.Equals(SecurePushSubscription.Name)); var credential = new Credential(type, string.Empty, TimeSpan.FromDays(expiresInDays)); if (!string.IsNullOrWhiteSpace(scopes)) diff --git a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs index 054908c221..d2be06f6e3 100644 --- a/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs +++ b/tests/NuGetGallery.Facts/Security/SecurityPolicyServiceFacts.cs @@ -3,10 +3,8 @@ using System; using System.Collections.Generic; -using System.Data.Entity; using System.Linq; using System.Reflection; -using System.Threading.Tasks; using System.Web; using Moq; using NuGetGallery.Filters; @@ -35,7 +33,7 @@ public void UserSubscriptions() // Assert. Assert.Equal(1, subscriptions.Count()); - Assert.Equal("SecurePush", subscriptions.First().Name); + Assert.Equal("SecurePush", subscriptions.First().SubscriptionName); } [Fact] @@ -363,71 +361,5 @@ private HttpContextBase CreateHttpContext(User user) return httpContext.Object; } - - public class TestSecurityPolicyService : SecurityPolicyService - { - public const string MockSubscriptionName = "MockSubscription"; - - public Mock MockEntitiesContext { get; } - - public Mock> MockUserSecurityPolicies { get; set; } - - public Mock MockSubscription { get; } - - public Mock MockPolicy1 { get; } - - public Mock MockPolicy2 { get; } - - public TestSecurityPolicyService(bool success1 = true, bool success2 = true) - { - MockUserSecurityPolicies = new Mock>(); - MockUserSecurityPolicies.Setup(p => p.Remove(It.IsAny())).Verifiable(); - - MockEntitiesContext = new Mock(); - MockEntitiesContext.Setup(c => c.SaveChangesAsync()).Returns(Task.FromResult(2)).Verifiable(); - MockEntitiesContext.Setup(c => c.UserSecurityPolicies).Returns(MockUserSecurityPolicies.Object); - EntitiesContext = MockEntitiesContext.Object; - - MockPolicy1 = MockHandler(nameof(MockPolicy1), success1); - MockPolicy2 = MockHandler(nameof(MockPolicy2), success2); - - MockSubscription = new Mock(); - MockSubscription.Setup(s => s.Name).Returns(MockSubscriptionName); - MockSubscription.Setup(s => s.OnSubscribe(It.IsAny())).Verifiable(); - MockSubscription.Setup(s => s.OnUnsubscribe(It.IsAny())).Verifiable(); - MockSubscription.Setup(s => s.Policies).Returns(GetMockPolicies()); - } - - public override IEnumerable UserSubscriptions - { - get - { - yield return MockSubscription.Object; - } - } - - protected override IEnumerable UserHandlers - { - get - { - yield return MockPolicy1.Object; - yield return MockPolicy2.Object; - } - } - - public static IEnumerable GetMockPolicies() - { - yield return new UserSecurityPolicy(nameof(MockPolicy1), MockSubscriptionName); - yield return new UserSecurityPolicy(nameof(MockPolicy2), MockSubscriptionName); - } - - private Mock MockHandler(string name, bool success) - { - var result = success ? SecurityPolicyResult.SuccessResult : SecurityPolicyResult.CreateErrorResult(name); - var mock = new Mock(name, SecurityPolicyAction.PackagePush); - mock.Setup(m => m.Evaluate(It.IsAny())).Returns(result).Verifiable(); - return mock; - } - } } } diff --git a/tests/NuGetGallery.Facts/Security/TestSecurityPolicyService.cs b/tests/NuGetGallery.Facts/Security/TestSecurityPolicyService.cs new file mode 100644 index 0000000000..47df0af8ed --- /dev/null +++ b/tests/NuGetGallery.Facts/Security/TestSecurityPolicyService.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Data.Entity; +using System.Threading.Tasks; +using Moq; +using NuGetGallery.Filters; + +namespace NuGetGallery.Security +{ + public class TestSecurityPolicyService : SecurityPolicyService + { + public const string MockSubscriptionName = "MockSubscription"; + + public Mock MockEntitiesContext { get; } + + public Mock> MockUserSecurityPolicies { get; } + + public Mock MockSubscription { get; } + + public Mock MockPolicy1 { get; } + + public Mock MockPolicy2 { get; } + + public TestSecurityPolicyService(Mock mockEntitiesContext = null, + bool success1 = true, bool success2 = true) + { + MockUserSecurityPolicies = new Mock>(); + MockUserSecurityPolicies.Setup(p => p.Remove(It.IsAny())).Verifiable(); + + MockEntitiesContext = new Mock(); + MockEntitiesContext.Setup(c => c.SaveChangesAsync()).Returns(Task.FromResult(2)).Verifiable(); + MockEntitiesContext.Setup(c => c.UserSecurityPolicies).Returns(MockUserSecurityPolicies.Object); + EntitiesContext = MockEntitiesContext.Object; + + MockPolicy1 = MockHandler(nameof(MockPolicy1), success1); + MockPolicy2 = MockHandler(nameof(MockPolicy2), success2); + + MockSubscription = new Mock(); + MockSubscription.Setup(s => s.SubscriptionName).Returns(MockSubscriptionName); + MockSubscription.Setup(s => s.OnSubscribe(It.IsAny())).Verifiable(); + MockSubscription.Setup(s => s.OnUnsubscribe(It.IsAny())).Verifiable(); + MockSubscription.Setup(s => s.Policies).Returns(GetMockPolicies()); + } + + public override IEnumerable UserSubscriptions + { + get + { + yield return MockSubscription.Object; + } + } + + protected override IEnumerable UserHandlers + { + get + { + yield return MockPolicy1.Object; + yield return MockPolicy2.Object; + } + } + + public static IEnumerable GetMockPolicies() + { + yield return new UserSecurityPolicy(nameof(MockPolicy1), MockSubscriptionName); + yield return new UserSecurityPolicy(nameof(MockPolicy2), MockSubscriptionName); + } + + private Mock MockHandler(string name, bool success) + { + var result = success ? SecurityPolicyResult.SuccessResult : SecurityPolicyResult.CreateErrorResult(name); + var mock = new Mock(name, SecurityPolicyAction.PackagePush); + mock.Setup(m => m.Evaluate(It.IsAny())).Returns(result).Verifiable(); + return mock; + } + } +} diff --git a/tests/NuGetGallery.Facts/TestUtils/MockExtensions.cs b/tests/NuGetGallery.Facts/TestUtils/MockExtensions.cs index 041684b023..693b76bdaa 100644 --- a/tests/NuGetGallery.Facts/TestUtils/MockExtensions.cs +++ b/tests/NuGetGallery.Facts/TestUtils/MockExtensions.cs @@ -2,9 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Threading.Tasks; +using System.Data.Entity; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Moq; using Moq.Language; using Moq.Language.Flow; @@ -15,6 +16,20 @@ namespace NuGetGallery { public static class MockExtensions { + public static Mock> MockDbSet(this IEnumerable data) + where T : class, IEntity + { + var query = data.AsQueryable(); + + var dbSet = new Mock>(); + dbSet.As>().Setup(s => s.Provider).Returns(query.Provider); + dbSet.As>().Setup(s => s.Expression).Returns(query.Expression); + dbSet.As>().Setup(s => s.ElementType).Returns(query.ElementType); + dbSet.As>().Setup(s => s.GetEnumerator()).Returns(() => data.GetEnumerator()); + + return dbSet; + } + // Helper to get around Mock Returns((Type)null) weirdness. public static IReturnsResult ReturnsNull(this IReturns self) where TMock : class