Skip to content

Commit 68e84b6

Browse files
Support for policy propagation (#4061) (#4117)
- Added inline confirmation when adding new package owner - Added package URL link to package owner request emails - Added new notification to co-owners when package owner request is confirmed - Added secure push policy messaging to communication above (confirmation, request, and notification) - Added secure push policy messaging to package view for owners and admins - Fixed bug on security policy admin view where toggle all broken if multiple subscriptions - Updated security policy admin view to not reload page on update postback
1 parent ac3199b commit 68e84b6

34 files changed

+1844
-350
lines changed

src/NuGetGallery/App_Start/DefaultDependenciesModule.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ protected override void Load(ContainerBuilder builder)
210210
builder.RegisterType<SecurePushSubscription>()
211211
.SingleInstance();
212212

213+
builder.RegisterType<RequireSecurePushForCoOwnersPolicy>()
214+
.SingleInstance();
215+
213216
var mailSenderThunk = new Lazy<IMailSender>(
214217
() =>
215218
{

src/NuGetGallery/Areas/Admin/Controllers/SecurityPolicyController.cs

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ public virtual JsonResult Search(string query)
4848
{
4949
// Parse query and look for users in the DB.
5050
var usernames = GetUsernamesFromQuery(query ?? "");
51-
var users = FindUsers(usernames);
51+
var users = EntitiesContext.Users
52+
.Where(u => usernames.Any(name => u.Username == name))
53+
.ToList();
5254
var usersNotFound = usernames.Except(users.Select(u => u.Username));
5355

5456
var results = new UserSecurityPolicySearchResult()
@@ -69,40 +71,49 @@ public virtual JsonResult Search(string query)
6971
}
7072

7173
[HttpPost]
72-
[ValidateAntiForgeryToken]
73-
public async Task<ActionResult> Update(SecurityPolicyViewModel viewModel)
74+
public async Task<JsonResult> Update(List<string> subscriptionsJson)
7475
{
75-
// Policy subscription requests by user.
76-
var subscriptions = viewModel.UserSubscriptions?
77-
.Select(json => JsonConvert.DeserializeObject<JObject>(json))
76+
var subscribeRequests = subscriptionsJson?.Select(JsonConvert.DeserializeObject<JObject>)
77+
.Where(obj => obj["v"].ToObject<bool>())
78+
.GroupBy(obj => obj["u"].ToString())
79+
.ToDictionary(
80+
g => g.Key, // username
81+
g => g.Select(obj => obj["g"].ToString()) // subscriptions
82+
);
83+
84+
var unsubscribeRequests = subscriptionsJson?.Select(JsonConvert.DeserializeObject<JObject>)
85+
.Where(obj => !obj["v"].ToObject<bool>())
7886
.GroupBy(obj => obj["u"].ToString())
7987
.ToDictionary(
80-
g => g.Key,
81-
g => g.Select(obj => obj["g"].ToString())
88+
g => g.Key, // username
89+
g => g.Select(obj => obj["g"].ToString()) // subscriptions
8290
);
8391

84-
// Iterate all users and groups to handle both subscribe and unsubscribe.
85-
var usernames = GetUsernamesFromQuery(viewModel.UsersQuery);
86-
var users = FindUsers(usernames);
87-
foreach (var user in users)
92+
foreach (var r in subscribeRequests)
8893
{
89-
foreach (var subscription in PolicyService.UserSubscriptions)
94+
var user = EntitiesContext.Users.FirstOrDefault(u => u.Username == r.Key);
95+
if (user != null)
9096
{
91-
var userKeyExists = subscriptions?.ContainsKey(user.Username) ?? false;
92-
if (userKeyExists && subscriptions[user.Username].Contains(subscription.SubscriptionName))
97+
foreach (var subscription in r.Value)
9398
{
9499
await PolicyService.SubscribeAsync(user, subscription);
95100
}
96-
else
101+
}
102+
}
103+
104+
foreach (var r in unsubscribeRequests)
105+
{
106+
var user = EntitiesContext.Users.FirstOrDefault(u => u.Username == r.Key);
107+
if (user != null)
108+
{
109+
foreach (var subscription in r.Value)
97110
{
98111
await PolicyService.UnsubscribeAsync(user, subscription);
99112
}
100113
}
101114
}
102115

103-
TempData["Message"] = $"Updated policies for {users.Count()} users.";
104-
105-
return RedirectToAction("Index");
116+
return Json(new { success = true });
106117
}
107118

108119
private static string[] GetUsernamesFromQuery(string query)
@@ -111,12 +122,5 @@ private static string[] GetUsernamesFromQuery(string query)
111122
.Select(username => username.Trim())
112123
.Where(username => !string.IsNullOrEmpty(username)).ToArray();
113124
}
114-
115-
private IEnumerable<User> FindUsers(string[] usernames)
116-
{
117-
return EntitiesContext.Users
118-
.Where(u => usernames.Any(name => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
119-
.ToList();
120-
}
121125
}
122126
}

src/NuGetGallery/Areas/Admin/Views/SecurityPolicy/Index.cshtml

Lines changed: 105 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -6,62 +6,62 @@
66

77
<section>
88
<article id="stage">
9+
10+
<div class="message" style="display: none" data-bind="text: message, visible: message"></div>
11+
912
<h2>User Security Policies</h2>
1013

1114
<form>
1215
<textarea placeholder="Search for usernames (comma-separated)" autocomplete="off" autofocus style="width: 100%;" rows="5" data-bind="value: searchQuery"></textarea><br />
13-
<input type="button" value="Search" data-bind="click: search" />
16+
<input type="button" value="Search" title="Search" data-bind="click: search" />
1417
</form><br />
1518

16-
@using (Html.BeginForm("Update", "SecurityPolicy", new { area = "Admin" }, FormMethod.Post, new { id = "delete-form" }))
17-
{
1819
<div data-bind="visible: searchResults().length > 0">
19-
<input type="hidden" name="UsersQuery" data-bind="value: searchQuery" />
20-
20+
2121
<div class="message warning" style="display: none;" data-bind="visible: searchNotFoundResults().length > 0">
2222
<strong>The following users were not found:</strong><br />
2323
<span data-bind="text: searchNotFoundResults().join(',')"></span>
2424
</div>
25+
26+
@using (Html.BeginForm())
27+
{
28+
<div>
29+
<table id="searchResults" class="sexy-table">
30+
<thead>
31+
<tr>
32+
<th>Username</th>
33+
@foreach (var subscription in Model.SubscriptionNames)
34+
{
35+
<th><input type="checkbox" data-bind="click: toggleSelectAll, checked: selectAllState.@subscription" />@subscription</th>
36+
}
37+
</tr>
38+
</thead>
39+
<tbody id="policies" data-bind="foreach: searchResults">
40+
<tr>
41+
<td><a href="#" data-bind="text: Username, attr: { href: $parent.generateUserUrl($data) }"></a></td>
42+
@foreach (var subscription in Model.SubscriptionNames)
43+
{
44+
<td><input type="checkbox" data-bind="checked: $data.Selected.@subscription, value: $parent.generateValue($data, '@subscription')" /></td>
45+
}
46+
</tr>
47+
</tbody>
48+
</table>
49+
</div>
2550

26-
<div>
27-
<table id="searchResults" class="sexy-table">
28-
<thead>
29-
<tr>
30-
<th>Username</th>
31-
@foreach (var subscription in Model.SubscriptionNames)
32-
{
33-
<th><input type="checkbox" data-bind="click: toggleSelectAll, checked: selectAllState.@subscription" />@subscription</th>
34-
}
35-
</tr>
36-
</thead>
37-
<tbody data-bind="foreach: searchResults">
38-
<tr>
39-
<td><a href="#" data-bind="text: Username, attr: { href: $parent.generateUserUrl($data) }"></a></td>
40-
@foreach (var subscription in Model.SubscriptionNames)
41-
{
42-
<td><input type="checkbox" name="UserSubscriptions[]" data-bind="checked: $data.Selected.@subscription, value: $parent.generateValue($data, '@subscription')" /></td>
43-
}
44-
</tr>
45-
</tbody>
46-
</table>
47-
</div>
48-
49-
<div class="danger-zone" style="display: none;" data-bind="visible: changeTracker">
51+
<div class="danger-zone" style="display: none;" data-bind="visible: changeTracker">
5052

51-
<fieldset id="unlist-form" class="form">
52-
@Html.AntiForgeryToken()
53-
54-
<p>
55-
Onboarding users to security policy subscriptions could result in changes which <strong>CANNOT</strong> be undone.
56-
</p>
57-
58-
<input type="submit" value="I understand, update security policies." title="I understand, update security policies." />
59-
<a class="cancel" href="@Url.Action("Index", "Home")" title="Cancel changes">Cancel</a>
60-
</fieldset>
61-
</div>
53+
<fieldset id="update-form" class="form">
54+
<p>
55+
Onboarding users to security policy subscriptions could result in changes which <strong>CANNOT</strong> be undone.
56+
</p>
57+
58+
<input type="submit" value="I understand, update policies." title="I understand, update policies." data-bind="click: update" />
59+
<a class="cancel" href="@Url.Action("Index", "Home")" title="Cancel changes">Cancel</a>
60+
</fieldset>
61+
</div>
62+
}
6263

6364
</div>
64-
}
6565
</article>
6666
</section>
6767

@@ -72,63 +72,88 @@
7272
var viewModel = function () {
7373
var $self = this;
7474
75-
this.subscriptions = @Html.Raw(Json.Encode(@Model.SubscriptionNames));
75+
this.message = ko.observable('');
76+
77+
this.currentSubscription;
78+
this.subscriptionNames = @Html.Raw(Json.Encode(@Model.SubscriptionNames));
7679
this.searchQuery = ko.observable('');
7780
81+
this.update = function () {
82+
var subscriptions = [];
83+
$('#policies input:checkbox').each(function (i, checkbox) {
84+
subscriptions.push(checkbox.value);
85+
});
86+
87+
$.ajax({
88+
url: '@Url.Action("Update", "SecurityPolicy", new { area = "Admin" })',
89+
cache: false,
90+
dataType: 'json',
91+
type: 'POST',
92+
data: JSON.stringify(subscriptions),
93+
contentType: 'application/json; charset=utf-8',
94+
success: function (data) {
95+
$self.changeTracker(false);
96+
$self.message("Security policies updated!");
97+
}
98+
})
99+
.error(function(jqXhr, textStatus, errorThrown) {
100+
alert("Error: " + errorThrown);
101+
});
102+
},
103+
78104
this.search = function () {
105+
$self.message("");
79106
$.ajax({
80107
url: '@Url.Action("Search", "SecurityPolicy", new {area = "Admin"})?query=' + encodeURIComponent($self.searchQuery()),
81-
cache: false,
82-
dataType: 'json',
83-
success: function (data) {
84-
$self.changeTracker(false);
85-
$self.resetSelectAllState();
86-
$self.searchResults.removeAll();
87-
$self.searchNotFoundResults.removeAll();
88-
89-
for (var i = 0; i < data.Users.length; i++) {
90-
var user = data.Users[i];
91-
user.Selected = {};
92-
for (var key in user.Subscriptions) {
93-
user.Selected[key] = ko.observable(user.Subscriptions[key]);
94-
user.Selected[key].subscribe($self.markDirty);
95-
}
108+
cache: false,
109+
dataType: 'json',
110+
success: function (data) {
111+
$self.changeTracker(false);
112+
$self.resetSelectAllState();
113+
$self.searchResults.removeAll();
114+
$self.searchNotFoundResults.removeAll();
115+
116+
for (var i = 0; i < data.Users.length; i++) {
117+
var user = data.Users[i];
118+
user.Selected = {};
119+
for (var key in user.Subscriptions) {
120+
user.Selected[key] = ko.observable(user.Subscriptions[key]);
121+
user.Selected[key].subscribe($self.markDirty);
96122
}
97-
98-
$self.searchResults(data.Users);
99-
$self.searchNotFoundResults(data.UsersNotFound);
100-
},
101-
error: function (data) {
102-
alert("Error: " + errorThrown);
103123
}
104-
})
105-
.error(function(jqXhr, textStatus, errorThrown) {
106-
alert("Error: " + errorThrown);
107-
});
124+
125+
$self.searchResults(data.Users);
126+
$self.searchNotFoundResults(data.UsersNotFound);
127+
}
128+
})
129+
.error(function(jqXhr, textStatus, errorThrown) {
130+
alert("Error: " + errorThrown);
131+
});
108132
};
109-
133+
110134
this.selectAllState = {};
111-
for (var i = 0; i < this.subscriptions.length; i++)
135+
for (var i = 0; i < this.subscriptionNames.length; i++)
112136
{
113-
var subscription = this.subscriptions[i];
137+
var subscription = this.subscriptionNames[i];
114138
this.selectAllState[subscription] = ko.observable(false);
115139
}
116140
117141
this.resetSelectAllState = function () {
118-
for (var i = 0; i < $self.subscriptions.length; i++)
142+
for (var i = 0; i < $self.subscriptionNames.length; i++)
119143
{
120-
$self.selectAllState[$self.subscriptions[i]](false);
144+
var subscription = $self.subscriptionNames[i];
145+
$self.selectAllState[subscription](false);
121146
}
122147
}
123-
148+
124149
this.toggleSelectAll = function (data, e) {
125-
var subscription = e.currentTarget.nextSibling.data;
126-
$self.selectAllState[subscription](!$self.selectAllState[subscription]());
150+
$self.currentSubscription = e.currentTarget.nextSibling.data;
151+
$self.selectAllState[$self.currentSubscription](!$self.selectAllState[$self.currentSubscription]());
127152
return true;
128153
};
129154
130155
this.generateValue = function (user, subscription) {
131-
return JSON.stringify({ "u": user.Username, "g": subscription })
156+
return JSON.stringify({ "u": user.Username, "g": subscription, "v": user.Selected[subscription]() })
132157
};
133158
134159
this.generateUserUrl = function (user) {
@@ -145,24 +170,19 @@
145170
$self.changeTracker(true);
146171
};
147172
148-
for (var i = 0; i < this.subscriptions.length; i++) {
173+
for (var i = 0; i < this.subscriptionNames.length; i++) {
174+
var subscription = this.subscriptionNames[i];
149175
this.selectAllState[subscription].subscribe(function () {
150-
var state = $self.selectAllState[subscription]();
176+
var state = $self.selectAllState[$self.currentSubscription]();
151177
152178
ko.utils.arrayForEach($self.searchResults(), function (result) {
153-
result.Selected[subscription](state);
179+
result.Selected[$self.currentSubscription](state);
154180
});
155181
});
156182
}
157183
};
158184
159185
ko.applyBindings(new viewModel(), $('#stage').get(0));
160-
161-
$('#delete-form').submit(function (e) {
162-
if (!confirm('Are you sure you want to continue?')) {
163-
e.preventDefault();
164-
}
165-
});
166186
});
167187
</script>
168188
}

0 commit comments

Comments
 (0)