Skip to content

Admin features, roles #77

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

Merged
merged 10 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 152 additions & 8 deletions rubberduckvba.Server/Api/Admin/AdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using rubberduckvba.Server.Model.Entity;
using rubberduckvba.Server.Services;
using System.Security.Principal;

namespace rubberduckvba.Server.Api.Admin;

[ApiController]
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase
[EnableCors(CorsPolicies.AllowAll)]
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache, IAuditService audits) : ControllerBase
{
/// <summary>
/// Enqueues a job that updates xmldoc content from the latest release/pre-release tags.
/// </summary>
/// <returns>The unique identifier of the enqueued job.</returns>
[Authorize("github")]
[EnableCors(CorsPolicies.AllowAuthenticated)]
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
[HttpPost("admin/update/xmldoc")]
public IActionResult UpdateXmldocContent()
{
Expand All @@ -26,27 +28,169 @@ public IActionResult UpdateXmldocContent()
/// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats.
/// </summary>
/// <returns>The unique identifier of the enqueued job.</returns>
[Authorize("github")]
[EnableCors(CorsPolicies.AllowAuthenticated)]
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
[HttpPost("admin/update/tags")]
public IActionResult UpdateTagMetadata()
{
var jobId = hangfire.UpdateTagMetadata();
return Ok(jobId);
}

[Authorize("github")]
[EnableCors(CorsPolicies.AllowAuthenticated)]
[Authorize("github", Roles = RDConstants.Roles.AdminRole)]
[HttpPost("admin/cache/clear")]
public IActionResult ClearCache()
{
cache.Clear();
return Ok();
}

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")]
[HttpGet("admin/audits/pending")]
public async Task<IActionResult> GetPendingAudits()
{
var edits = await audits.GetPendingItems<FeatureEditViewEntity>(User.Identity);
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);

return Ok(new { edits = edits.ToArray(), other = ops.ToArray() });
}

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole},{RDConstants.Roles.WriterRole}")]
[HttpGet("profile/activity")]
public async Task<IActionResult> GetUserActivity()
{
if (User.Identity is not IIdentity identity)
{
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
return Unauthorized("User identity is not available.");
}

var activity = await audits.GetAllActivity(identity);
return Ok(activity);
}

private static readonly AuditActivityType[] EditActivityTypes = [
AuditActivityType.SubmitEdit,
AuditActivityType.ApproveEdit,
AuditActivityType.RejectEdit
];

private static readonly AuditActivityType[] OpActivityTypes = [
AuditActivityType.SubmitCreate,
AuditActivityType.ApproveCreate,
AuditActivityType.RejectCreate,
AuditActivityType.SubmitDelete,
AuditActivityType.ApproveDelete,
AuditActivityType.RejectDelete
];

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
[HttpGet("admin/audits/{id}")]
public async Task<IActionResult> GetAudit([FromRoute] int id, [FromQuery] string type)
{
if (!Enum.TryParse<AuditActivityType>(type, ignoreCase: true, out var validType))
{
return BadRequest("Invalid activity type.");
}

var edit = (FeatureEditViewEntity?)null;
var op = (FeatureOpEntity?)null;

if (EditActivityTypes.Contains(validType))
{
edit = await audits.GetItem<FeatureEditViewEntity>(id);
}
else if (OpActivityTypes.Contains(validType))
{
op = await audits.GetItem<FeatureOpEntity>(id);
}

return Ok(new { edits = new[] { edit }, other = op is null ? [] : new[] { op } });
}

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
[HttpGet("admin/audits/feature/{featureId}")]
public async Task<IActionResult> GetPendingAudits([FromRoute] int featureId)
{
var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity, featureId);
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity, featureId);

return Ok(new { edits = edits.ToArray(), other = ops.ToArray() });
}

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
[HttpPost("admin/audits/approve/{id}")]
public async Task<IActionResult> ApprovePendingAudit([FromRoute] int id)
{
if (User.Identity is not IIdentity identity)
{
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
return Unauthorized("User identity is not available.");
}

var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity);
AuditEntity? audit;

audit = edits.SingleOrDefault(e => e.Id == id);
if (audit is null)
{
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);
audit = ops.SingleOrDefault(e => e.Id == id);
}

if (audit is null)
{
// TODO log this
return BadRequest("Invalid ID");
}

if (!audit.IsPending)
{
// TODO log this
return BadRequest($"This operation has already been audited");
}

await audits.Approve(audit, identity);
return Ok("Operation was approved successfully.");
}

[Authorize("github", Roles = $"{RDConstants.Roles.AdminRole},{RDConstants.Roles.ReviewerRole}")]
[HttpPost("admin/audits/reject/{id}")]
public async Task<IActionResult> RejectPendingAudit([FromRoute] int id)
{
if (User.Identity is not IIdentity identity)
{
// this is arguably a bug in the authentication middleware, but we can handle it gracefully here
return Unauthorized("User identity is not available.");
}

var edits = await audits.GetPendingItems<FeatureEditEntity>(User.Identity);
AuditEntity? audit;

audit = edits.SingleOrDefault(e => e.Id == id);
if (audit is null)
{
var ops = await audits.GetPendingItems<FeatureOpEntity>(User.Identity);
audit = ops.SingleOrDefault(e => e.Id == id);
}

if (audit is null)
{
// TODO log this
return BadRequest("Invalid ID");
}

if (!audit.IsPending)
{
// TODO log this
return BadRequest($"This operation has already been audited");
}

await audits.Reject(audit, identity);
return Ok("Operation was rejected successfully.");
}

#if DEBUG
[AllowAnonymous]
[EnableCors(CorsPolicies.AllowAll)]
[HttpGet("admin/config/current")]
public IActionResult Config()
{
Expand Down
20 changes: 16 additions & 4 deletions rubberduckvba.Server/Api/Auth/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ namespace rubberduckvba.Server.Api.Auth;

public record class UserViewModel
{
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false };
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false, IsReviewer = false, IsWriter = false };

public string Name { get; init; } = default!;
public bool IsAuthenticated { get; init; }
public bool IsAdmin { get; init; }
public bool IsReviewer { get; init; }
public bool IsWriter { get; init; }
}

public record class SignInViewModel
Expand Down Expand Up @@ -59,7 +61,9 @@ public IActionResult Index()
{
Name = name,
IsAuthenticated = isAuthenticated,
IsAdmin = role == configuration.Value.OwnerOrg
IsAdmin = role == RDConstants.Roles.AdminRole,
IsReviewer = role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole,
IsWriter = role == RDConstants.Roles.WriterRole || role == RDConstants.Roles.AdminRole || role == RDConstants.Roles.ReviewerRole,
};

return Ok(model);
Expand Down Expand Up @@ -115,15 +119,16 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
{
return GuardInternalAction(() =>
{
Logger.LogInformation("OAuth token was received. State: {state}", vm.State);
Logger.LogInformation("OAuth code was received. State: {state}", vm.State);
var clientId = configuration.Value.ClientId;
var clientSecret = configuration.Value.ClientSecret;
var agent = configuration.Value.UserAgent;

var github = new GitHubClient(new ProductHeaderValue(agent));

var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);

var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult();

if (token is null)
{
Logger.LogWarning("OAuth access token was not created.");
Expand Down Expand Up @@ -171,6 +176,13 @@ public IActionResult OnGitHubCallback(SignInViewModel vm)
Thread.CurrentPrincipal = HttpContext.User;

Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
Response.Cookies.Append(GitHubAuthenticationHandler.AuthCookie, token, new CookieOptions
{
IsEssential = true,
HttpOnly = true,
Secure = true,
Expires = DateTimeOffset.UtcNow.AddHours(1)
});
return token;
}
else
Expand Down
51 changes: 51 additions & 0 deletions rubberduckvba.Server/Api/Auth/ClaimsPrincipalExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Principal;
using System.Text;

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

return new JwtSecurityTokenHandler().WriteToken(token);
}

/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
/// </summary>
public static bool IsAdmin(this ClaimsPrincipal principal)
{
return principal.IsInRole(RDConstants.Roles.AdminRole);
}

/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
/// </summary>
public static bool IsReviewer(this ClaimsPrincipal principal)
{
return principal.IsInRole(RDConstants.Roles.ReviewerRole);
}
}

public static class ClaimsIdentityExtensions
{
/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
/// </summary>
public static bool IsAdmin(this IIdentity identity)
{
return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsAdmin();
}

/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
/// </summary>
public static bool IsReviewer(this IIdentity identity)
{
return identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsReviewer();
}

/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-admin</c> role.
/// </summary>
public static bool IsAdmin(this ClaimsIdentity identity)
{
return identity.IsAuthenticated && identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole);
}
/// <summary>
/// <c>true</c> if the user is authenticated and has the <c>rd-reviewer</c> or <c>rd-admin</c> role.
/// </summary>
public static bool IsReviewer(this ClaimsIdentity identity)
{
return identity != null && identity.IsAuthenticated && (identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.AdminRole) || identity.HasClaim(ClaimTypes.Role, RDConstants.Roles.ReviewerRole));
}
}
8 changes: 4 additions & 4 deletions rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public static FeatureEditViewModel Default(RepositoryId repository, FeatureOptio
Repositories = repositories,

RepositoryId = repository,
ParentId = parent?.Id,
FeatureId = parent?.Id,
Name = parent is null ? "NewFeature" : $"New{parent.Name}Feature",
Title = "Feature Title",
ShortDescription = "A short description; markdown is supported.",
Expand All @@ -29,7 +29,7 @@ public Feature ToFeature()
return new Feature
{
Id = Id ?? default,
FeatureId = ParentId,
FeatureId = FeatureId,
RepositoryId = RepositoryId,
Name = Name,
Title = Title,
Expand All @@ -43,7 +43,7 @@ public Feature ToFeature()
public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, RepositoryOptionViewModel[] repositories)
{
Id = model.Id;
ParentId = model.FeatureId;
FeatureId = model.FeatureId;
RepositoryId = model.RepositoryId;

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

public int? Id { get; init; }
public int? ParentId { get; init; }
public int? FeatureId { get; init; }
public RepositoryId RepositoryId { get; init; }

public string Name { get; init; }
Expand Down
1 change: 0 additions & 1 deletion rubberduckvba.Server/Api/Features/FeatureViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@ public class InspectionsFeatureViewModel : FeatureViewModel
public InspectionsFeatureViewModel(FeatureGraph model, IEnumerable<QuickFixViewModel> quickFixes, IDictionary<int, Tag> tagsByAssetId, bool summaryOnly = false)
: base(model, summaryOnly)
{

Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, quickFixes, tagsByAssetId)).ToArray();
}

Expand Down
Loading