From 7e2269bb0d3cc574b7aa293c4314a8a43efe4dca Mon Sep 17 00:00:00 2001 From: Mathieu Guindon Date: Tue, 4 Feb 2025 05:56:19 -0500 Subject: [PATCH] closes #5 --- .../Api/Admin/AdminController.cs | 35 +++----- rubberduckvba.Server/Api/Admin/GitRef.cs | 18 +++++ .../Api/Admin/HangfireLauncherService.cs | 43 ++++++++++ .../Api/Admin/WebhookController.cs | 46 +++++++++++ .../Api/Admin/WebhookPayloadType.cs | 8 ++ .../Admin/WebhookPayloadValidationService.cs | 39 +++++++++ .../Sections/SyncXmldoc/SyncXmldocSection.cs | 40 +++++++++- .../SyncRequestParameters.cs | 1 - .../GitHubAuthenticationHandler.cs | 77 ++++++++++++++++++ rubberduckvba.Server/Program.cs | 16 ++++ .../RubberduckApiController.cs | 2 + .../Services/rubberduckdb/TagServices.cs | 80 +++++++++---------- 12 files changed, 335 insertions(+), 70 deletions(-) create mode 100644 rubberduckvba.Server/Api/Admin/GitRef.cs create mode 100644 rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs create mode 100644 rubberduckvba.Server/Api/Admin/WebhookController.cs create mode 100644 rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs create mode 100644 rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index 8b2489a..7ab5284 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -1,53 +1,42 @@ -using Hangfire; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using rubberduckvba.Server; -using rubberduckvba.Server.ContentSynchronization; -using rubberduckvba.Server.Hangfire; -using rubberduckvba.Server.Services; namespace rubberduckvba.Server.Api.Admin; [ApiController] -public class AdminController(ConfigurationOptions options, IBackgroundJobClient backgroundJob, ILogger logger) : ControllerBase +public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire) : ControllerBase { /// /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. - [Authorize("github")] + [Authorize("github", AuthenticationSchemes = "github")] [HttpPost("admin/update/xmldoc")] - public async ValueTask UpdateXmldocContent() + public IActionResult UpdateXmldocContent() { - var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; - var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!)); - logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); - - return await ValueTask.FromResult(Ok(jobId)); + var jobId = hangfire.UpdateXmldocContent(); + return Ok(jobId); } /// /// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats. /// /// The unique identifier of the enqueued job. - [Authorize("github")] + [Authorize("github", AuthenticationSchemes = "github")] [HttpPost("admin/update/tags")] - public async ValueTask UpdateTagMetadata() + public IActionResult UpdateTagMetadata() { - var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; - var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!)); - logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); - - return await ValueTask.FromResult(Ok(jobId)); + var jobId = hangfire.UpdateTagMetadata(); + return Ok(jobId); } #if DEBUG [HttpGet("admin/config/current")] - public async ValueTask Config() + public IActionResult Config() { - return await ValueTask.FromResult(Ok(options)); + return Ok(options); } #endif } diff --git a/rubberduckvba.Server/Api/Admin/GitRef.cs b/rubberduckvba.Server/Api/Admin/GitRef.cs new file mode 100644 index 0000000..fadb9a1 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/GitRef.cs @@ -0,0 +1,18 @@ +namespace rubberduckvba.Server.Api.Admin; + +public readonly record struct GitRef +{ + private readonly string _value; + + public GitRef(string value) + { + _value = value; + IsTag = value?.StartsWith("refs/tags/") ?? false; + Name = value?.Split('/').Last() ?? string.Empty; + } + + public bool IsTag { get; } + public string Name { get; } + + public override string ToString() => _value; +} diff --git a/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs new file mode 100644 index 0000000..41fac4e --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs @@ -0,0 +1,43 @@ +using Hangfire; +using rubberduckvba.Server; +using rubberduckvba.Server.ContentSynchronization; +using rubberduckvba.Server.Hangfire; +using rubberduckvba.Server.Services; + +namespace rubberduckvba.Server.Api.Admin; + +public class HangfireLauncherService(IBackgroundJobClient backgroundJob, ILogger logger) +{ + public string UpdateXmldocContent() + { + var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; + var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!)); + + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId."); + } + else + { + logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); + } + + return jobId; + } + + public string UpdateTagMetadata() + { + var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; + var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!)); + + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId."); + } + else + { + logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); + } + return jobId; + } +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookController.cs b/rubberduckvba.Server/Api/Admin/WebhookController.cs new file mode 100644 index 0000000..eae1b6a --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; + +namespace rubberduckvba.Server.Api.Admin; + +[ApiController] +public class WebhookController : RubberduckApiController +{ + private readonly WebhookPayloadValidationService _validator; + private readonly HangfireLauncherService _hangfire; + + public WebhookController( + ILogger logger, + HangfireLauncherService hangfire, + WebhookPayloadValidationService validator) + : base(logger) + { + _validator = validator; + _hangfire = hangfire; + } + + [Authorize("webhook", AuthenticationSchemes = "webhook-signature")] + [HttpPost("webhook/github")] + public IActionResult GitHub([FromBody] JToken payload) + { + var eventType = _validator.Validate(payload, Request.Headers, out var content, out var gitref); + + if (eventType == WebhookPayloadType.Push) + { + var jobId = _hangfire.UpdateXmldocContent(); + var message = $"Webhook push event was accepted. Tag '{gitref?.Name}' associated to the payload will be processed by JobId '{jobId}'."; + + Logger.LogInformation(message); + return Ok(message); + } + else if (eventType == WebhookPayloadType.Greeting) + { + Logger.LogInformation("Webhook push event was accepted; nothing to process. {content}", content); + return string.IsNullOrWhiteSpace(content) ? NoContent() : Ok(content); + } + + // reject the payload + return BadRequest(); + } +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs new file mode 100644 index 0000000..6b71cb7 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs @@ -0,0 +1,8 @@ +namespace rubberduckvba.Server.Api.Admin; + +public enum WebhookPayloadType +{ + Unsupported, + Greeting, + Push +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs new file mode 100644 index 0000000..38526a7 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json.Linq; + +namespace rubberduckvba.Server.Api.Admin; + +public class WebhookPayloadValidationService(ConfigurationOptions options) +{ + public WebhookPayloadType Validate(JToken payload, IHeaderDictionary headers, out string? content, out GitRef? gitref) + { + content = default; + gitref = default; + + if (!IsValidHeaders(headers) || !IsValidSource(payload) || !IsValidEvent(payload)) + { + return WebhookPayloadType.Unsupported; + } + + gitref = new GitRef(payload.Value("ref")); + if (!(payload.Value("created") && gitref.HasValue && gitref.Value.IsTag)) + { + content = payload.Value("zen"); + return WebhookPayloadType.Greeting; + } + + return WebhookPayloadType.Push; + } + + private bool IsValidHeaders(IHeaderDictionary headers) => + headers.TryGetValue("X-GitHub-Event", out Microsoft.Extensions.Primitives.StringValues values) && values.Contains("push"); + + private bool IsValidSource(JToken payload) => + payload["repository"].Value("name") == options.GitHubOptions.Value.Rubberduck && + payload["owner"].Value("id") == options.GitHubOptions.Value.RubberduckOrgId; + + private bool IsValidEvent(JToken payload) + { + var ev = payload["hook"]?["events"]?.Values() ?? []; + return ev.Contains("push"); + } +} diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs index c2719f1..3cd19de 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs @@ -90,6 +90,8 @@ protected override async Task ActionAsync(SyncRequestParameters input) { Context.LoadParameters(input); + var githubTags = await _github.GetAllTagsAsync(); + // LoadInspectionDefaultConfig var config = await _github.GetCodeAnalysisDefaultsConfigAsync(); Context.LoadInspectionDefaultConfig(config); @@ -108,10 +110,44 @@ await Task.WhenAll([ ]); // AcquireDbTags - var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false); - Context.LoadRubberduckDbMain(dbMain); + var ghMain = githubTags.Where(tag => !tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single(); + var ghNext = githubTags.Where(tag => tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single(); + await Task.Delay(TimeSpan.FromSeconds(2)); // just in case the tags job was scheduled at/around the same time + + var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false); var dbNext = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: true); + + var dbTags = _tagServices.GetAllTags().ToDictionary(e => e.Name); + List newTags = []; + if (ghMain.Name != dbMain.Name) + { + if (!dbTags.ContainsKey(ghMain.Name)) + { + newTags.Add(ghMain); + } + else + { + // that's an old tag then; do not process + throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})"); + } + } + if (ghNext.Name != dbNext.Name) + { + if (!dbTags.ContainsKey(ghMain.Name)) + { + newTags.Add(ghMain); + } + else + { + // that's an old tag then; do not process + throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})"); + } + } + + _tagServices.Create(newTags); + + Context.LoadRubberduckDbMain(dbMain); Context.LoadRubberduckDbNext(dbNext); Context.LoadDbTags([dbMain, dbNext]); diff --git a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs index 4571e31..8c9f85e 100644 --- a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs +++ b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs @@ -24,5 +24,4 @@ public record class XmldocSyncRequestParameters : SyncRequestParameters public record class TagSyncRequestParameters : SyncRequestParameters { - public string? Tag { get; init; } } diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 1ed3f61..b9e3a44 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -1,8 +1,11 @@  using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; +using rubberduckvba.Server.Api.Admin; using rubberduckvba.Server.Services; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using System.Text.Encodings.Web; namespace rubberduckvba.Server; @@ -32,3 +35,77 @@ protected async override Task HandleAuthenticateAsync() : AuthenticateResult.NoResult(); } } + +public class WebhookAuthenticationHandler : AuthenticationHandler +{ + private readonly ConfigurationOptions _configuration; + + public WebhookAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, + ConfigurationOptions configuration) + : base(options, logger, encoder) + { + _configuration = configuration; + } + + protected async override Task HandleAuthenticateAsync() + { + return await Task.Run(() => + { + var xGitHubEvent = Context.Request.Headers["X-GitHub-Event"]; + var xGitHubDelivery = Context.Request.Headers["X-GitHub-Delivery"]; + var xHubSignature = Context.Request.Headers["X-Hub-Signature"]; + var xHubSignature256 = Context.Request.Headers["X-Hub-Signature-256"]; + + if (!xGitHubEvent.Contains("push")) + { + // only authenticate push events + return AuthenticateResult.NoResult(); + } + + if (!Guid.TryParse(xGitHubDelivery.SingleOrDefault(), out _)) + { + // delivery should parse as a GUID + return AuthenticateResult.NoResult(); + } + + if (!xHubSignature.Any()) + { + // signature header should be present + return AuthenticateResult.NoResult(); + } + + var signature = xHubSignature256.SingleOrDefault(); + var payload = new StreamReader(Context.Request.Body).ReadToEnd(); + + if (!IsValidSignature(signature, payload)) + { + // encrypted signature must be present + return AuthenticateResult.NoResult(); + } + + var identity = new ClaimsIdentity("webhook", ClaimTypes.Name, ClaimTypes.Role); + identity.AddClaim(new Claim(ClaimTypes.Name, "rubberduck-vba-releasebot")); + identity.AddClaim(new Claim(ClaimTypes.Role, "rubberduck-webhook")); + identity.AddClaim(new Claim(ClaimTypes.Authentication, "webhook-signature")); + + var principal = new ClaimsPrincipal(identity); + return AuthenticateResult.Success(new AuthenticationTicket(principal, "webhook-signature")); + }); + } + + private bool IsValidSignature(string? signature, string payload) + { + if (string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + using var sha256 = SHA256.Create(); + + var secret = _configuration.GitHubOptions.Value.WebhookToken; + var bytes = Encoding.UTF8.GetBytes(secret + payload); + var check = $"sha256={Encoding.UTF8.GetString(sha256.ComputeHash(bytes))}"; + + return check == payload; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index 3ab720a..11351c4 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -21,6 +21,7 @@ using rubberduckvba.Server.Services.rubberduckdb; using System.Diagnostics; using System.Reflection; +using System.Security.Claims; namespace rubberduckvba.Server; @@ -46,10 +47,17 @@ public static void Main(string[] args) builder.Services.AddAuthentication(options => { options.RequireAuthenticatedSignIn = false; + options.DefaultAuthenticateScheme = "github"; + options.DefaultChallengeScheme = "github"; + options.AddScheme("github", builder => { builder.HandlerType = typeof(GitHubAuthenticationHandler); }); + options.AddScheme("webhook-signature", builder => + { + builder.HandlerType = typeof(WebhookAuthenticationHandler); + }); }); builder.Services.AddAuthorization(options => { @@ -57,6 +65,13 @@ public static void Main(string[] args) { builder.RequireAuthenticatedUser(); }); + options.AddPolicy("webhook", builder => + { + builder.RequireAuthenticatedUser() + .RequireClaim(ClaimTypes.Authentication, "webhook-signature") + .RequireClaim(ClaimTypes.Role, "rubberduck-webhook") + .RequireClaim(ClaimTypes.Name, "rubberduck-vba-releasebot"); + }); }); ConfigureServices(builder.Services); @@ -78,6 +93,7 @@ public static void Main(string[] args) app.UseHttpsRedirection(); app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/rubberduckvba.Server/RubberduckApiController.cs b/rubberduckvba.Server/RubberduckApiController.cs index 4c39b90..3e5125c 100644 --- a/rubberduckvba.Server/RubberduckApiController.cs +++ b/rubberduckvba.Server/RubberduckApiController.cs @@ -15,6 +15,8 @@ protected RubberduckApiController(ILogger logger) _logger = logger; } + protected ILogger Logger => _logger; + protected async Task GuardInternalActionAsync(Func> method, [CallerMemberName] string name = default!) { diff --git a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs index 9e63cb6..698d9f0 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs @@ -6,68 +6,57 @@ namespace rubberduckvba.Server.Services.rubberduckdb; public class TagServices(IRepository tagsRepository, IRepository tagAssetsRepository) { - private IEnumerable _allTags = []; - private IEnumerable _latestTags = []; - private TagGraph? _main; - private TagGraph? _next; - private bool _mustInvalidate = true; - - public IEnumerable GetAllTags() + public bool TryGetTag(string name, out Tag tag) { - if (_mustInvalidate || !_allTags.Any()) + var entity = tagsRepository.GetAll().SingleOrDefault(tag => tag.Name == name); + if (entity is null) { - _allTags = tagsRepository.GetAll().ToList(); - _latestTags = _allTags - .GroupBy(tag => tag.IsPreRelease) - .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) - .SelectMany(tags => tags.Take(1)) - .ToList(); - _mustInvalidate = false; + tag = default!; + return false; } - return _allTags.Select(e => new Tag(e)); + tag = new Tag(entity); + return true; } - public IEnumerable GetLatestTags() + public IEnumerable GetAllTags() { - if (_mustInvalidate || !_latestTags.Any()) - { - _ = GetAllTags(); - } - - return _latestTags.Select(e => new Tag(e)); + return tagsRepository.GetAll().Select(e => new Tag(e)); } + public IEnumerable GetLatestTags() => GetLatestTags(tagsRepository.GetAll().Select(e => new Tag(e))); + + public IEnumerable GetLatestTags(IEnumerable allTags) => allTags + .GroupBy(tag => tag.IsPreRelease) + .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) + .SelectMany(tags => tags.Take(1)) + .ToList(); + public TagGraph GetLatestTag(bool isPreRelease) { - var mustInvalidate = _mustInvalidate; - if (mustInvalidate || !_latestTags.Any()) - { - _ = GetAllTags(); // _mustInvalidate => false - } + var latestTags = GetLatestTags(); - if (!mustInvalidate && !isPreRelease && _main != null) + if (!isPreRelease) { - return _main; + var mainTag = latestTags.First(e => !e.IsPreRelease); + var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); + return new TagGraph(mainTag.ToEntity(), mainAssets); } - if (!mustInvalidate && isPreRelease && _next != null) + else { - return _next; + var nextTag = latestTags.First(e => e.IsPreRelease); + var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); + return new TagGraph(nextTag.ToEntity(), nextAssets); } - - var mainTag = _latestTags.First(e => !e.IsPreRelease); - var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); - _main = new TagGraph(mainTag, mainAssets); - - var nextTag = _latestTags.First(e => e.IsPreRelease); - var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); - _next = new TagGraph(nextTag, nextAssets); - - return isPreRelease ? _next : _main; } public void Create(IEnumerable tags) { + if (!tags.Any()) + { + return; + } + var tagEntities = tagsRepository.Insert(tags.Select(tag => tag.ToEntity())); var tagsByName = tagEntities.ToDictionary( tag => tag.Name, @@ -81,12 +70,15 @@ public void Create(IEnumerable tags) } _ = tagAssetsRepository.Insert(assets); - _mustInvalidate = true; } public void Update(IEnumerable tags) { + if (!tags.Any()) + { + return; + } + tagsRepository.Update(tags.Select(tag => tag.ToEntity())); - _mustInvalidate = true; } } \ No newline at end of file