-
Notifications
You must be signed in to change notification settings - Fork 643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Temp keys policy onboarding #3854
Conversation
private static string[] GetUsernamesFromQuery(string query) | ||
{ | ||
return query.Split(new[] { ',', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) | ||
.Select(username => username.Trim()).ToArray(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should probably filter out empty results after trimming.
private IEnumerable<User> FindUsers(string[] usernames) | ||
{ | ||
return EntitiesContext.Users | ||
.Where(u => usernames.Any(name => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This properly becomes Username IN ('a', 'b', ...)
in SQL? Or is this being evaluated client side?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Evaluated SQL-side, with similar query...
SELECT * FROM Users WHERE EXISTS(
SELECT 1 AS [C1] FROM (
SELECT N'name1' AS [C1] FROM ( SELECT 1 AS X ) AS [SingleRowTable1]
UNION ALL
SELECT N'name2' AS [C1] FROM ( SELECT 1 AS X ) AS [SingleRowTable2]
) AS [UnionAll2] WHERE Users.[Username] = [UnionAll2].[C1])
ViewBag.Title = "Security Policies"; | ||
} | ||
|
||
<section> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we get a screenshot?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@joelverhagen sent
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can upload the image to GitHub so everybody can see.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Attached to conversation
return true; | ||
}; | ||
|
||
this.generateValue = function (user, policyGroup) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really have to invent our own serialization here? Can't we just JSON encode?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated
Policies = new [] | ||
{ | ||
new UserSecurityPolicy(RequirePackageVerifyScopePolicy.PolicyName), | ||
new UserSecurityPolicy(RequireMinClientVersionForPushPolicy.PolicyName) { Value = "{\"v\":\"4.1.0\"}" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use JSON API to write this JSON string.
|
||
foreach (var key in pushKeys) | ||
{ | ||
key.Expires = DateTime.Now.AddDays(7); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will fall into the typical expiration notification flow -- so user's might now know reason why this is expiring, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, DateTime.UtcNow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could actually give some keys more time right? Shouldn't we only do this if the Expires
is greater than 7 days in the future?
|
||
private static IEnumerable<UserSecurityPolicyGroup> CreateUserPolicyGroups() | ||
{ | ||
yield return new UserSecurityPolicyGroup() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is all constant, can't we just subclass UserSecurityPolicyGroup
? This allows us to avoid the Action<User>
and just make it an overridden method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. This is really confusing as is...just make this an abstract class with SecurePushUserSecurityPolicyGroup
a subclass with all of this logic.
/// <summary> | ||
/// Grouping of one or more user security policies for enrollment. | ||
/// </summary> | ||
public class UserSecurityPolicyGroup |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems to me like a "policy group" is a bit redundant. Isn't this just another policy, a la "aggregate policy"? Maybe I'm off here but couldn't a policy either have it's own logic, be an aggregate of other policies, or both? I don't see why we need another type for a grouping when the UserSecurityPolicy
type could describe it as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@joelverhagen @skofman1 per our discussion, will add a new column to UserSecurityPolicies table to track what was enrolled in - which could be a single policy or aggregate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chenriksson make sure that column handles the state of being enrolled in multiple aggregates.
e.g.
I'm enrolled in policy A through groups X and Y. I leave group X. I should still be enrolled in policy A through group Y.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⌚️
778bc17
to
b0259c4
Compare
|
||
public SecurityPolicyController(IEntitiesContext entitiesContext) | ||
{ | ||
EntitiesContext = entitiesContext; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
null check
/// Get supported user security policy groups. | ||
/// </summary> | ||
private static List<UserSecurityPolicyGroup> _instances; | ||
public static List<UserSecurityPolicyGroup> Instances |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider using Lazy
private static void OnEnroll_SecurePush(User user) | ||
{ | ||
var pushKeys = user.Credentials.Where(c => | ||
c.Type.StartsWith(CredentialTypes.ApiKey.Prefix) && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
c.Type.StartsWith(CredentialTypes.ApiKey.Prefi [](start = 16, length = 46)
I think there is a method IsApiKey that has this logic
/// <summary> | ||
/// Security policy group enrollments for a user. | ||
/// </summary> | ||
public class SecurityPolicyEnrollments |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
r [](start = 21, length = 1)
one class per file please
} | ||
} | ||
|
||
await EntitiesContext.SaveChangesAsync(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we send a user an e-mail when he is enrolled? Perhaps Anand has ideas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really want to see UserSecurityPolicyGroup
split into subclasses. Otherwise looks good.
/// <summary> | ||
/// Security policy group enrollments for a user. | ||
/// </summary> | ||
public class SecurityPolicyEnrollments |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be more clear if this was named UserSecurityPolicyEnrollments
.
ViewBag.Title = "Security Policies"; | ||
} | ||
|
||
<section> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you can upload the image to GitHub so everybody can see.
public IEnumerable<string> PolicyGroups { get; set; } | ||
|
||
/// <summary> | ||
/// Requested user enrollments to make. String format is 'username|policygroup'. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When you changed the serialization did you update this comment?
/// <summary> | ||
/// Grouping of one or more user security policies for enrollment. | ||
/// </summary> | ||
public class UserSecurityPolicyGroup |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chenriksson make sure that column handles the state of being enrolled in multiple aggregates.
e.g.
I'm enrolled in policy A through groups X and Y. I leave group X. I should still be enrolled in policy A through group Y.
|
||
private static IEnumerable<UserSecurityPolicyGroup> CreateUserPolicyGroups() | ||
{ | ||
yield return new UserSecurityPolicyGroup() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. This is really confusing as is...just make this an abstract class with SecurePushUserSecurityPolicyGroup
a subclass with all of this logic.
{ | ||
[Theory] | ||
[InlineData("", "")] | ||
[InlineData(null, "")] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should also check [InlineData("", null)]
just in case
/// <summary> | ||
/// Determine whether two security policies are equivalent. | ||
/// </summary> | ||
public static bool Matches(this UserSecurityPolicy first, UserSecurityPolicy second) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this not an Equals
method?
e.g. UserSecurityPolicy
should implement IEquatable<UserSecurityPolicy>
// Arrange. | ||
var group = new UserSecurityPolicyGroup() | ||
{ | ||
Policies = LoadPolicies(groupPolicies) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you use MemberData
instead of InlineData
you can get rid of the serialization/deserialization.
[Theory] | ||
[InlineData("[[\"A\",\"\"]]")] | ||
[InlineData("[[\"A\",\"B\"],[\"E\",\"\"]]")] | ||
public void AddPoliciesAddsPolicies(string groupPolicies) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want test cases around
1 - adding policies that are already added
2 - removing policies that are not enrolled
|
||
await EntitiesContext.SaveChangesAsync(); | ||
|
||
return RedirectToAction("Index"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Usually these kind of operations will set a message on the page, e.g. User x was enrolled in y.
Do we want to do that here? It could be nontrivial to figure out what was changed otherwise.
b0259c4
to
63bb404
Compare
63bb404
to
3f840d3
Compare
/// <summary> | ||
/// Subscription name. | ||
/// </summary> | ||
public string Name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Name [](start = 22, length = 4)
Nit: => SubscriptionName
using System.ComponentModel.DataAnnotations; | ||
|
||
namespace NuGetGallery | ||
{ | ||
/// <summary> | ||
/// User-subscribed security policy. | ||
/// </summary> | ||
public class UserSecurityPolicy : IEntity | ||
public class UserSecurityPolicy : IEntity, IEquatable<UserSecurityPolicy> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IEquatable [](start = 47, length = 10)
IEquatable should override GetHashCode
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
Assert.True(first.Equals(second)); | ||
} | ||
|
||
public static IEnumerable<UserSecurityPolicy[]> EqualsReturnsFalse_Data() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EqualsReturnsFalse_Data [](start = 56, length = 23)
nit make this a property
{ | ||
public override void Up() | ||
{ | ||
AddColumn("dbo.UserSecurityPolicies", "Subscription", c => c.String(nullable: false, maxLength: 256)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nullable [](start = 80, length = 8)
What will existing records do with this?
Or are we assuming there is no data?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No data in the table yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. Thanks.
{ | ||
public IEntitiesContext EntitiesContext { get; } | ||
|
||
public ISecurityPolicyService PolicyService { get; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PolicyService [](start = 38, length = 13)
nit why public?
} | ||
|
||
[HttpGet] | ||
public virtual ActionResult Search(string query) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Search [](start = 36, length = 6)
thoughts on unit testing this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally we could mock the context and policy service
In reply to: 115030333 [](ancestors = 115030333)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@joelverhagen Tests added!
var usernames = GetUsernamesFromQuery(query); | ||
var users = FindUsers(usernames); | ||
var usersNotFound = usernames | ||
.Where(name => !users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where [](start = 17, length = 5)
linq except might be a bit clearer for this
throw new ArgumentNullException(nameof(subscription)); | ||
} | ||
|
||
if (!IsSubscribed(user, subscription)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IsSubscribed [](start = 17, length = 12)
This isn't thread safe. Two simultaneous enrolls to the same subscription would result in duplicate policies right? Or is there a optimistic concurrency on the user?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm afraid that's the case for a lot of our CUD actions... for scoped API keys I added "locks" in the page itself to prevent double clicks from creating duplicate calls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The confirmation prompt should help guard against duplicate clicks. I don't think a lock in the gallery (per instance) or optimistic concurrency in the DB (per row) is the solution here.
Now that we have the Subscription column, we could add a unique index (Name, Subscription)... assuming we wouldn't have a subscription contain multiple of the same policies differing in value only. We could then silently ignore duplicate inserts (SqlException 2601). Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Name, Subscription)
unique index seems right. That being said, it seems like the "is enrolled?" and unenroll code paths handle duplicates just fine.
/// <summary> | ||
/// Expire API keys with push capability on secure push enrollment. | ||
/// </summary> | ||
private static void SetPushApiKeysToExpire(User user, int expirationInDays = 7) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
= 7 [](start = 83, length = 3)
why optional parameter? there is only one acceptable value here today right? seems like a private const more correct here
@@ -14,14 +15,47 @@ namespace NuGetGallery.Security | |||
/// </summary> | |||
public class SecurityPolicyService : ISecurityPolicyService |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SecurityPolicyService [](start = 17, length = 21)
I don't see any logging around these actions. Are we thinking that auditing is enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was planning to do auditing and telemetry together in next PR.
🕐 |
|
||
public SecurityPolicyController(IEntitiesContext entitiesContext, ISecurityPolicyService policyService) | ||
{ | ||
if (entitiesContext == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: c# 7 introduces throw expressions: https://blogs.msdn.microsoft.com/dotnet/2016/08/24/whats-new-in-csharp-7-0/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I tried in an earlier commit and had a CI issue... but will try it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sure you have the changes to build.ps1
from this commit. If it still fails, run it on the build machines queue and not the hosted agent queue.
@@ -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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What will be the behavior if we authenticated with a credential that is not an API key?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The apiKey var would be empty/null, and user.Credentials.FirstOrDefault would return null. Same behavior, so the IsNullOrEmpty check is unnecessary.
This is actually a regression from PR #3855, since some test infra uses empty API keys.
No description provided.