Skip to content

Commit d711039

Browse files
authored
Merge pull request #77 from rubberduck-vba/webhook
Admin features, roles
2 parents 17aeb11 + 0185557 commit d711039

File tree

57 files changed

+2841
-403
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2841
-403
lines changed

rubberduckvba.Server/Api/Admin/AdminController.cs

Lines changed: 152 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22
using Microsoft.AspNetCore.Cors;
33
using Microsoft.AspNetCore.Mvc;
44
using Microsoft.Extensions.Options;
5+
using rubberduckvba.Server.Model.Entity;
56
using rubberduckvba.Server.Services;
7+
using System.Security.Principal;
68

79
namespace rubberduckvba.Server.Api.Admin;
810

911
[ApiController]
10-
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase
12+
[EnableCors(CorsPolicies.AllowAll)]
13+
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache, IAuditService audits) : ControllerBase
1114
{
1215
/// <summary>
1316
/// Enqueues a job that updates xmldoc content from the latest release/pre-release tags.
1417
/// </summary>
1518
/// <returns>The unique identifier of the enqueued job.</returns>
16-
[Authorize("github")]
17-
[EnableCors(CorsPolicies.AllowAuthenticated)]
19+
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
1820
[HttpPost("admin/update/xmldoc")]
1921
public IActionResult UpdateXmldocContent()
2022
{
@@ -26,27 +28,169 @@ public IActionResult UpdateXmldocContent()
2628
/// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats.
2729
/// </summary>
2830
/// <returns>The unique identifier of the enqueued job.</returns>
29-
[Authorize("github")]
30-
[EnableCors(CorsPolicies.AllowAuthenticated)]
31+
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
3132
[HttpPost("admin/update/tags")]
3233
public IActionResult UpdateTagMetadata()
3334
{
3435
var jobId = hangfire.UpdateTagMetadata();
3536
return Ok(jobId);
3637
}
3738

38-
[Authorize("github")]
39-
[EnableCors(CorsPolicies.AllowAuthenticated)]
39+
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
4040
[HttpPost("admin/cache/clear")]
4141
public IActionResult ClearCache()
4242
{
4343
cache.Clear();
4444
return Ok();
4545
}
4646

47+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")]
48+
[HttpGet("admin/audits/pending")]
49+
public async Task<IActionResult> GetPendingAudits()
50+
{
51+
var edits = await audits.GetPendingItems<FeatureEditViewEntity>(User.Identity);
52+
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);
53+
54+
return Ok(new { edits = edits.ToArray(), other = ops.ToArray() });
55+
}
56+
57+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")]
58+
[HttpGet("profile/activity")]
59+
public async Task<IActionResult> GetUserActivity()
60+
{
61+
if (User.Identity is not IIdentity identity)
62+
{
63+
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
64+
return Unauthorized("User identity is not available.");
65+
}
66+
67+
var activity = await audits.GetAllActivity(identity);
68+
return Ok(activity);
69+
}
70+
71+
private static readonly AuditActivityType[] EditActivityTypes = [
72+
AuditActivityType.SubmitEdit,
73+
AuditActivityType.ApproveEdit,
74+
AuditActivityType.RejectEdit
75+
];
76+
77+
private static readonly AuditActivityType[] OpActivityTypes = [
78+
AuditActivityType.SubmitCreate,
79+
AuditActivityType.ApproveCreate,
80+
AuditActivityType.RejectCreate,
81+
AuditActivityType.SubmitDelete,
82+
AuditActivityType.ApproveDelete,
83+
AuditActivityType.RejectDelete
84+
];
85+
86+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
87+
[HttpGet("admin/audits/{id}")]
88+
public async Task<IActionResult> GetAudit([FromRoute] int id, [FromQuery] string type)
89+
{
90+
if (!Enum.TryParse<AuditActivityType>(type, ignoreCase: true, out var validType))
91+
{
92+
return BadRequest("Invalid activity type.");
93+
}
94+
95+
var edit = (FeatureEditViewEntity?)null;
96+
var op = (FeatureOpEntity?)null;
97+
98+
if (EditActivityTypes.Contains(validType))
99+
{
100+
edit = await audits.GetItem<FeatureEditViewEntity>(id);
101+
}
102+
else if (OpActivityTypes.Contains(validType))
103+
{
104+
op = await audits.GetItem<FeatureOpEntity>(id);
105+
}
106+
107+
return Ok(new { edits = new[] { edit }, other = op is null ? [] : new[] { op } });
108+
}
109+
110+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
111+
[HttpGet("admin/audits/feature/{featureId}")]
112+
public async Task<IActionResult> GetPendingAudits([FromRoute] int featureId)
113+
{
114+
var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity, featureId);
115+
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity, featureId);
116+
117+
return Ok(new { edits = edits.ToArray(), other = ops.ToArray() });
118+
}
119+
120+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
121+
[HttpPost("admin/audits/approve/{id}")]
122+
public async Task<IActionResult> ApprovePendingAudit([FromRoute] int id)
123+
{
124+
if (User.Identity is not IIdentity identity)
125+
{
126+
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
127+
return Unauthorized("User identity is not available.");
128+
}
129+
130+
var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity);
131+
AuditEntity? audit;
132+
133+
audit = edits.SingleOrDefault(e => e.Id == id);
134+
if (audit is null)
135+
{
136+
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);
137+
audit = ops.SingleOrDefault(e => e.Id == id);
138+
}
139+
140+
if (audit is null)
141+
{
142+
// TODO log this
143+
return BadRequest("Invalid ID");
144+
}
145+
146+
if (!audit.IsPending)
147+
{
148+
// TODO log this
149+
return BadRequest($"This operation has already been audited");
150+
}
151+
152+
await audits.Approve(audit, identity);
153+
return Ok("Operation was approved successfully.");
154+
}
155+
156+
[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
157+
[HttpPost("admin/audits/reject/{id}")]
158+
public async Task<IActionResult> RejectPendingAudit([FromRoute] int id)
159+
{
160+
if (User.Identity is not IIdentity identity)
161+
{
162+
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
163+
return Unauthorized("User identity is not available.");
164+
}
165+
166+
var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity);
167+
AuditEntity? audit;
168+
169+
audit = edits.SingleOrDefault(e => e.Id == id);
170+
if (audit is null)
171+
{
172+
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);
173+
audit = ops.SingleOrDefault(e => e.Id == id);
174+
}
175+
176+
if (audit is null)
177+
{
178+
// TODO log this
179+
return BadRequest("Invalid ID");
180+
}
181+
182+
if (!audit.IsPending)
183+
{
184+
// TODO log this
185+
return BadRequest($"This operation has already been audited");
186+
}
187+
188+
await audits.Reject(audit, identity);
189+
return Ok("Operation was rejected successfully.");
190+
}
191+
47192
#if DEBUG
48193
[AllowAnonymous]
49-
[EnableCors(CorsPolicies.AllowAll)]
50194
[HttpGet("admin/config/current")]
51195
public IActionResult Config()
52196
{

rubberduckvba.Server/Api/Auth/AuthController.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ namespace rubberduckvba.Server.Api.Auth;
1010

1111
public record class UserViewModel
1212
{
13-
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false };
13+
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false, IsReviewer = false, IsWriter = false };
1414

1515
public string Name { get; init; } = default!;
1616
public bool IsAuthenticated { get; init; }
1717
public bool IsAdmin { get; init; }
18+
public bool IsReviewer { get; init; }
19+
public bool IsWriter { get; init; }
1820
}
1921

2022
public record class SignInViewModel
@@ -59,7 +61,9 @@ public IActionResult Index()
5961
{
6062
Name = name,
6163
IsAuthenticated = isAuthenticated,
62-
IsAdmin = role == configuration.Value.OwnerOrg
64+
IsAdmin = role == RDConstants.Roles.AdminRole,
65+
IsReviewer = role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole,
66+
IsWriter = role == RDConstants.Roles.WriterRole || role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole,
6367
};
6468

6569
return Ok(model);
@@ -115,15 +119,16 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
115119
{
116120
return GuardInternalAction(() =>
117121
{
118-
Logger.LogInformation("OAuth token was received. State: {state}", vm.State);
122+
Logger.LogInformation("OAuth code was received. State: {state}", vm.State);
119123
var clientId = configuration.Value.ClientId;
120124
var clientSecret = configuration.Value.ClientSecret;
121125
var agent = configuration.Value.UserAgent;
122126

123127
var github = new GitHubClient(new ProductHeaderValue(agent));
124-
125128
var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);
129+
126130
var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult();
131+
127132
if (token is null)
128133
{
129134
Logger.LogWarning("OAuth access token was not created.");
@@ -171,6 +176,13 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
171176
Thread.CurrentPrincipal = HttpContext.User;
172177

173178
Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
179+
Response.Cookies.Append(GitHubAuthenticationHandler.AuthCookie, token, new CookieOptions
180+
{
181+
IsEssential = true,
182+
HttpOnly = true,
183+
Secure = true,
184+
Expires = DateTimeOffset.UtcNow.AddHours(1)
185+
});
174186
return token;
175187
}
176188
else

rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.IdentityModel.Tokens;
22
using System.IdentityModel.Tokens.Jwt;
33
using System.Security.Claims;
4+
using System.Security.Principal;
45
using System.Text;
56

67
namespace rubberduckvba.Server.Api.Auth;
@@ -18,4 +19,54 @@ public static string AsJWT(this ClaimsPrincipal principal, string secret, string
1819

1920
return new JwtSecurityTokenHandler().WriteToken(token);
2021
}
22+
23+
/// <summary>
24+
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
25+
/// </summary>
26+
public static bool IsAdmin(this ClaimsPrincipal principal)
27+
{
28+
return principal.IsInRole(RDConstants.Roles.AdminRole);
29+
}
30+
31+
/// <summary>
32+
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
33+
/// </summary>
34+
public static bool IsReviewer(this ClaimsPrincipal principal)
35+
{
36+
return principal.IsInRole(RDConstants.Roles.ReviewerRole);
37+
}
2138
}
39+
40+
public static class ClaimsIdentityExtensions
41+
{
42+
/// <summary>
43+
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
44+
/// </summary>
45+
public static bool IsAdmin(this IIdentity identity)
46+
{
47+
return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsAdmin();
48+
}
49+
50+
/// <summary>
51+
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
52+
/// </summary>
53+
public static bool IsReviewer(this IIdentity identity)
54+
{
55+
return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsReviewer();
56+
}
57+
58+
/// <summary>
59+
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
60+
/// </summary>
61+
public static bool IsAdmin(this ClaimsIdentity identity)
62+
{
63+
return identity.IsAuthenticated && identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole);
64+
}
65+
/// <summary>
66+
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
67+
/// </summary>
68+
public static bool IsReviewer(this ClaimsIdentity identity)
69+
{
70+
return identity != null && identity.IsAuthenticated && (identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole) || identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.ReviewerRole));
71+
}
72+
}

rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public static FeatureEditViewModel Default(RepositoryId repository, FeatureOptio
1717
Repositories = repositories,
1818

1919
RepositoryId = repository,
20-
ParentId = parent?.Id,
20+
FeatureId = parent?.Id,
2121
Name = parent is null ? "NewFeature" : $"New{parent.Name}Feature",
2222
Title = "Feature Title",
2323
ShortDescription = "A short description; markdown is supported.",
@@ -29,7 +29,7 @@ public Feature ToFeature()
2929
return new Feature
3030
{
3131
Id = Id ?? default,
32-
FeatureId = ParentId,
32+
FeatureId = FeatureId,
3333
RepositoryId = RepositoryId,
3434
Name = Name,
3535
Title = Title,
@@ -43,7 +43,7 @@ public Feature ToFeature()
4343
public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, RepositoryOptionViewModel[] repositories)
4444
{
4545
Id = model.Id;
46-
ParentId = model.FeatureId;
46+
FeatureId = model.FeatureId;
4747
RepositoryId = model.RepositoryId;
4848

4949
Name = model.Name;
@@ -59,7 +59,7 @@ public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, Re
5959
}
6060

6161
public int? Id { get; init; }
62-
public int? ParentId { get; init; }
62+
public int? FeatureId { get; init; }
6363
public RepositoryId RepositoryId { get; init; }
6464

6565
public string Name { get; init; }

rubberduckvba.Server/Api/Features/FeatureViewModel.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,6 @@ public class InspectionsFeatureViewModel : FeatureViewModel
242242
public InspectionsFeatureViewModel(FeatureGraph model, IEnumerable<QuickFixViewModel> quickFixes, IDictionary<int, Tag> tagsByAssetId, bool summaryOnly = false)
243243
: base(model, summaryOnly)
244244
{
245-
246245
Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, quickFixes, tagsByAssetId)).ToArray();
247246
}
248247

0 commit comments

Comments
 (0)