From 32d29987619d20b8daf054ab302c5fba1c047c5c Mon Sep 17 00:00:00 2001 From: Jordan van Gogh Date: Mon, 1 Apr 2019 22:27:57 +0200 Subject: [PATCH] added role based security plumbing --- Quartzmin.sln | 37 +-------------- .../Quartzmin/ApplicationBuilderExtensions.cs | 2 +- .../Controllers/CalendarsController.cs | 11 +++++ .../Controllers/ExecutionsController.cs | 3 ++ .../Controllers/HistoryController.cs | 2 + .../Controllers/JobDataMapController.cs | 3 ++ .../Quartzmin/Controllers/JobsController.cs | 17 ++++++- .../Controllers/PageControllerBase.cs | 47 +++++++++++++++++-- .../Controllers/SchedulerController.cs | 27 ++++++++++- .../Controllers/TriggersController.cs | 21 +++++++-- Source/Quartzmin/Quartzmin.csproj | 6 +++ Source/Quartzmin/QuartzminOptions.cs | 4 +- .../Security/AuthorizeUserAttribute.cs | 42 +++++++++++++++++ .../Security/HttpContextExtensions.cs | 43 +++++++++++++++++ .../Security/IAuthorizationProvider.cs | 9 ++++ .../Security/OwinContextExtensions.cs | 40 ++++++++++++++++ .../Security/UserAuthorizationFilter.cs | 31 ++++++++++++ Source/Quartzmin/Security/UserPermissions.cs | 25 ++++++++++ Source/Quartzmin/Services.cs | 7 ++- Source/Quartzmin/Views/Calendars/Edit.hbs | 7 +++ Source/Quartzmin/Views/Calendars/Index.hbs | 3 ++ Source/Quartzmin/Views/Executions/Index.hbs | 6 ++- Source/Quartzmin/Views/Jobs/Edit.hbs | 28 ++++++----- Source/Quartzmin/Views/Jobs/Index.hbs | 25 +++++++--- Source/Quartzmin/Views/Layout.hbs | 40 ++++++++++------ Source/Quartzmin/Views/Scheduler/Index.hbs | 9 +++- Source/Quartzmin/Views/Triggers/Edit.hbs | 6 +++ Source/Quartzmin/Views/Triggers/Index.hbs | 11 +++++ 28 files changed, 428 insertions(+), 84 deletions(-) create mode 100644 Source/Quartzmin/Security/AuthorizeUserAttribute.cs create mode 100644 Source/Quartzmin/Security/HttpContextExtensions.cs create mode 100644 Source/Quartzmin/Security/IAuthorizationProvider.cs create mode 100644 Source/Quartzmin/Security/OwinContextExtensions.cs create mode 100644 Source/Quartzmin/Security/UserAuthorizationFilter.cs create mode 100644 Source/Quartzmin/Security/UserPermissions.cs diff --git a/Quartzmin.sln b/Quartzmin.sln index 8e421a0..a127dcb 100644 --- a/Quartzmin.sln +++ b/Quartzmin.sln @@ -19,7 +19,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NetCoreSelfHost", "Source\E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormSelfHost", "Source\Examples\WinFormSelfHost\WinFormSelfHost.csproj", "{C54F4403-F40A-497C-B3A7-D1F1E1830D52}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreDocker", "Source\Examples\AspNetCoreDocker\AspNetCoreDocker.csproj", "{68D816F2-21BF-4376-ABF4-70390E4783C5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetCoreDocker", "Source\Examples\AspNetCoreDocker\AspNetCoreDocker.csproj", "{68D816F2-21BF-4376-ABF4-70390E4783C5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -73,39 +73,4 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0357313D-BD09-4C5D-AF0D-439B3BD33B5A} EndGlobalSection - GlobalSection(TeamFoundationVersionControl) = preSolution - SccNumberOfProjects = 9 - SccEnterpriseProvider = {4CA58AB2-18FA-4F8D-95D4-32DDF27D184C} - SccTeamFoundationServer = http://tfs/defaultcollection - SccLocalPath0 = . - SccProjectUniqueName1 = Source\\Quartzmin\\Quartzmin.csproj - SccProjectName1 = Source/Quartzmin - SccLocalPath1 = Source\\Quartzmin - SccProjectUniqueName2 = Source\\Quartz.Plugins.RecentHistory\\Quartz.Plugins.RecentHistory.csproj - SccProjectName2 = Source/Quartz.Plugins.RecentHistory - SccLocalPath2 = Source\\Quartz.Plugins.RecentHistory - SccProjectUniqueName3 = Source\\Quartzmin.SelfHost\\Quartzmin.SelfHost.csproj - SccProjectName3 = Source/Quartzmin.SelfHost - SccLocalPath3 = Source\\Quartzmin.SelfHost - SccProjectUniqueName4 = Source\\Examples\\NetCoreSelfHost\\NetCoreSelfHost.csproj - SccProjectTopLevelParentUniqueName4 = Quartzmin.sln - SccProjectName4 = Source/Examples/NetCoreSelfHost - SccLocalPath4 = Source\\Examples\\NetCoreSelfHost - SccProjectUniqueName5 = Source\\Examples\\AspNetCoreHost\\AspNetCoreHost.csproj - SccProjectTopLevelParentUniqueName5 = Quartzmin.sln - SccProjectName5 = Source/Examples/AspNetCoreHost - SccLocalPath5 = Source\\Examples\\AspNetCoreHost - SccProjectUniqueName6 = Source\\Examples\\AspNetWebHost\\AspNetWebHost.csproj - SccProjectTopLevelParentUniqueName6 = Quartzmin.sln - SccProjectName6 = Source/Examples/AspNetWebHost - SccLocalPath6 = Source\\Examples\\AspNetWebHost - SccProjectUniqueName7 = Source\\Examples\\WinFormSelfHost\\WinFormSelfHost.csproj - SccProjectTopLevelParentUniqueName7 = Quartzmin.sln - SccProjectName7 = Source/Examples/WinFormSelfHost - SccLocalPath7 = Source\\Examples\\WinFormSelfHost - SccProjectUniqueName8 = Source\\Examples\\AspNetCoreDocker\\AspNetCoreDocker.csproj - SccProjectTopLevelParentUniqueName8 = Quartzmin.sln - SccProjectName8 = Source/Examples/AspNetCoreDocker - SccLocalPath8 = Source\\Examples\\AspNetCoreDocker - EndGlobalSection EndGlobal diff --git a/Source/Quartzmin/ApplicationBuilderExtensions.cs b/Source/Quartzmin/ApplicationBuilderExtensions.cs index dfee55d..243d8af 100644 --- a/Source/Quartzmin/ApplicationBuilderExtensions.cs +++ b/Source/Quartzmin/ApplicationBuilderExtensions.cs @@ -38,7 +38,7 @@ public static void UseQuartzmin(this IApplicationBuilder app, QuartzminOptions o await context.Response.WriteAsync(services.ViewEngine.ErrorPage(ex)); }); }); - + app.UseMvc(routes => { routes.MapRoute( diff --git a/Source/Quartzmin/Controllers/CalendarsController.cs b/Source/Quartzmin/Controllers/CalendarsController.cs index 59bae37..b2fb32c 100644 --- a/Source/Quartzmin/Controllers/CalendarsController.cs +++ b/Source/Quartzmin/Controllers/CalendarsController.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -21,6 +22,7 @@ namespace Quartzmin.Controllers public class CalendarsController : PageControllerBase { [HttpGet] + [AuthorizeUser(UserPermissions.ViewCalendars)] public async Task Index() { var calendarNames = await Scheduler.GetCalendarNames(); @@ -37,6 +39,7 @@ public async Task Index() } [HttpGet] + [AuthorizeUser(UserPermissions.CreateNewCalendars)] public IActionResult New() { ViewBag.IsNew = true; @@ -49,6 +52,7 @@ public IActionResult New() } [HttpGet] + [AuthorizeUser(UserPermissions.ViewCalendars)] public async Task Edit(string name) { var calendar = await Scheduler.GetCalendar(name); @@ -75,6 +79,12 @@ private void RemoveLastEmpty(List list) [HttpPost, JsonErrorResponse] public async Task Save([FromBody] CalendarViewModel[] chain, bool isNew) { + if ((isNew && !UserHasPermissions(UserPermissions.CreateNewCalendars)) || + !UserHasPermissions(UserPermissions.EditCalendars)) + { + return Unauthorized(); + } + var result = new ValidationResult(); if (chain.Length == 0 || string.IsNullOrEmpty(chain[0].Name)) @@ -137,6 +147,7 @@ public class DeleteArgs [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.DeleteCalendars)] public async Task Delete([FromBody] DeleteArgs args) { if (!await Scheduler.DeleteCalendar(args.Name)) diff --git a/Source/Quartzmin/Controllers/ExecutionsController.cs b/Source/Quartzmin/Controllers/ExecutionsController.cs index 2635871..5d2d9d8 100644 --- a/Source/Quartzmin/Controllers/ExecutionsController.cs +++ b/Source/Quartzmin/Controllers/ExecutionsController.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using System.Globalization; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -23,6 +24,7 @@ namespace Quartzmin.Controllers public class ExecutionsController : PageControllerBase { [HttpGet] + [AuthorizeUser(UserPermissions.ViewExecutions)] public async Task Index() { var currentlyExecutingJobs = await Scheduler.GetCurrentlyExecutingJobs(); @@ -53,6 +55,7 @@ public class InterruptArgs } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.InterruptExecutions)] public async Task Interrupt([FromBody] InterruptArgs args) { if (!await Scheduler.Interrupt(args.Id)) diff --git a/Source/Quartzmin/Controllers/HistoryController.cs b/Source/Quartzmin/Controllers/HistoryController.cs index cf0e84d..bc77f39 100644 --- a/Source/Quartzmin/Controllers/HistoryController.cs +++ b/Source/Quartzmin/Controllers/HistoryController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -19,6 +20,7 @@ namespace Quartzmin.Controllers public class HistoryController : PageControllerBase { [HttpGet] + [AuthorizeUser(UserPermissions.ViewHistory)] public async Task Index() { var store = Scheduler.Context.GetExecutionHistoryStore(); diff --git a/Source/Quartzmin/Controllers/JobDataMapController.cs b/Source/Quartzmin/Controllers/JobDataMapController.cs index e77104e..91b0c61 100644 --- a/Source/Quartzmin/Controllers/JobDataMapController.cs +++ b/Source/Quartzmin/Controllers/JobDataMapController.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using System.Threading; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -25,6 +26,7 @@ namespace Quartzmin.Controllers public class JobDataMapController : PageControllerBase { [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ViewJobs)] public async Task ChangeType() { var formData = await Request.GetFormData(); @@ -78,6 +80,7 @@ private class BadRequestResult : IActionResult #endif [HttpGet, ActionName("TypeHandlers.js")] + [AuthorizeUser(UserPermissions.ViewJobs)] public IActionResult TypeHandlersScript() { var etag = Services.TypeHandlers.LastModified.ETag(); diff --git a/Source/Quartzmin/Controllers/JobsController.cs b/Source/Quartzmin/Controllers/JobsController.cs index 5b5b527..37094d5 100644 --- a/Source/Quartzmin/Controllers/JobsController.cs +++ b/Source/Quartzmin/Controllers/JobsController.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -23,6 +24,7 @@ namespace Quartzmin.Controllers public class JobsController : PageControllerBase { [HttpGet] + [AuthorizeUser(UserPermissions.ViewJobs)] public async Task Index() { var keys = (await Scheduler.GetJobKeys(GroupMatcher.AnyGroup())).OrderBy(x => x.ToString()); @@ -55,6 +57,7 @@ public async Task Index() } [HttpGet] + [AuthorizeUser(UserPermissions.CreateNewJobs)] public async Task New() { var job = new JobPropertiesViewModel() { IsNew = true }; @@ -68,6 +71,7 @@ public async Task New() } [HttpGet] + [AuthorizeUser(UserPermissions.TriggerJobs)] public async Task Trigger(string name, string group) { if (!EnsureValidKey(name, group)) return BadRequest(); @@ -85,6 +89,7 @@ public async Task Trigger(string name, string group) } [HttpPost, ActionName("Trigger"), JsonErrorResponse] + [AuthorizeUser(UserPermissions.TriggerJobs)] public async Task PostTrigger(string name, string group) { if (!EnsureValidKey(name, group)) return BadRequest(); @@ -104,6 +109,7 @@ public async Task PostTrigger(string name, string group) } [HttpGet] + [AuthorizeUser(UserPermissions.ViewJobs)] public async Task Edit(string name, string group, bool clone = false) { if (!EnsureValidKey(name, group)) return BadRequest(); @@ -142,11 +148,17 @@ private async Task GetJobDetail(JobKey key) throw new InvalidOperationException("Job " + key + " not found."); return job; - } + } [HttpPost, JsonErrorResponse] public async Task Save([FromForm] JobViewModel model, bool trigger) { + if ((model.Job.IsNew && !UserHasPermissions(UserPermissions.CreateNewJobs)) || + !UserHasPermissions(UserPermissions.EditJobs)) + { + return Unauthorized(); + } + var jobModel = model.Job; var jobDataMap = (await Request.GetJobDataMapForm()).GetModel(Services); @@ -188,6 +200,7 @@ IJobDetail BuildJob(JobBuilder builder) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.DeleteJobs)] public async Task Delete([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -201,6 +214,7 @@ public async Task Delete([FromBody] KeyModel model) } [HttpGet, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ViewJobs)] public async Task AdditionalData() { var keys = await Scheduler.GetJobKeys(GroupMatcher.AnyGroup()); @@ -226,6 +240,7 @@ public async Task AdditionalData() } [HttpGet] + [AuthorizeUser(UserPermissions.CreateNewJobs)] public Task Duplicate(string name, string group) { return Edit(name, group, clone: true); diff --git a/Source/Quartzmin/Controllers/PageControllerBase.cs b/Source/Quartzmin/Controllers/PageControllerBase.cs index 93794d3..da92b71 100644 --- a/Source/Quartzmin/Controllers/PageControllerBase.cs +++ b/Source/Quartzmin/Controllers/PageControllerBase.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Quartzmin.Models; using Quartz; +using Quartzmin.Security; namespace Quartzmin.Controllers { @@ -35,6 +36,16 @@ protected IEnumerable GetHeader(string key) var values = Request.Headers[key]; return values == StringValues.Empty ? (IEnumerable)null : values; } + + protected bool UserHasPermissions(params UserPermissions[] userPermissions) + { + return HttpContext.DoesUserHavePermissions(userPermissions); + } + + protected UserPermissions[] GetUserPermissions() + { + return HttpContext.GetUserPermissions(); + } } #endif #if NETFRAMEWORK @@ -57,7 +68,7 @@ private class ContentResult : IActionResult public Task ExecuteAsync(CancellationToken cancellationToken) { - var msg = new HttpResponseMessage() { Content = new StringContent(Content) }; + var msg = new HttpResponseMessage() {Content = new StringContent(Content)}; msg.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(ContentType); if (!string.IsNullOrEmpty(ETag)) @@ -65,7 +76,7 @@ public Task ExecuteAsync(CancellationToken cancellationToke if (LastModified != null) msg.Content.Headers.LastModified = LastModified; - + return Task.FromResult(msg); } } @@ -81,6 +92,15 @@ protected IEnumerable GetHeader(string key) return null; } + protected bool UserHasPermissions(params UserPermissions[] userPermissions) + { + return Request.GetOwinContext().DoesUserHavePermissions(userPermissions); + } + + protected UserPermissions[] GetUserPermissions() + { + return Request.GetOwinContext().GetUserPermissions(); + } } #endif #endregion @@ -89,6 +109,8 @@ public abstract partial class PageControllerBase { protected IScheduler Scheduler => Services.Scheduler; + protected IAuthorizationProvider AuthorizationProvider => Services.AuthorizationProvider; + protected dynamic ViewBag { get; } = new ExpandoObject(); internal class Page @@ -105,10 +127,27 @@ internal class Page public object Model { get; set; } - public Page(PageControllerBase controller, object model = null) + public object UserPermissions { get; private set; } + + public Page(PageControllerBase controller, object model = null, UserPermissions[] userPermissions = null) { _controller = controller; Model = model; + + SetUserPermissions(userPermissions); + } + + private void SetUserPermissions(UserPermissions[] userPermissions = null) + { + dynamic userPermissionsBag = new ExpandoObject(); + + var properties = userPermissionsBag as IDictionary; + foreach (var userPermission in Enum.GetValues(typeof(UserPermissions)).Cast()) + { + properties["Can" + userPermission] = userPermissions?.Contains(userPermission) ?? true; + } + + UserPermissions = userPermissionsBag; } } @@ -119,7 +158,7 @@ protected IActionResult View(object model) protected IActionResult View(string viewName, object model) { - string content = Services.ViewEngine.Render($"{GetRouteData("controller")}/{viewName}.hbs", new Page(this, model)); + string content = Services.ViewEngine.Render($"{GetRouteData("controller")}/{viewName}.hbs", new Page(this, model, GetUserPermissions())); return Html(content); } diff --git a/Source/Quartzmin/Controllers/SchedulerController.cs b/Source/Quartzmin/Controllers/SchedulerController.cs index 6ad4529..bae5da7 100644 --- a/Source/Quartzmin/Controllers/SchedulerController.cs +++ b/Source/Quartzmin/Controllers/SchedulerController.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using System.Globalization; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -99,25 +100,35 @@ public async Task Action([FromBody] ActionArgs args) switch (args.Action.ToLower()) { case "shutdown": + RequireUserPermissions(UserPermissions.ControlScheduler); await Scheduler.Shutdown(); break; case "standby": + RequireUserPermissions(UserPermissions.ControlScheduler); await Scheduler.Standby(); break; case "start": + RequireUserPermissions(UserPermissions.ControlScheduler); await Scheduler.Start(); break; case "pause": if (string.IsNullOrEmpty(args.Name)) { + RequireUserPermissions(UserPermissions.ControlScheduler); await Scheduler.PauseAll(); } else { if (args.Groups == "trigger-groups") + { + RequireUserPermissions(UserPermissions.ControlTriggers); await Scheduler.PauseTriggers(GroupMatcher.GroupEquals(args.Name)); + } else if (args.Groups == "job-groups") + { + RequireUserPermissions(UserPermissions.ControlJobs); await Scheduler.PauseJobs(GroupMatcher.GroupEquals(args.Name)); + } else throw new InvalidOperationException("Invalid groups: " + args.Groups); } @@ -125,14 +136,21 @@ public async Task Action([FromBody] ActionArgs args) case "resume": if (string.IsNullOrEmpty(args.Name)) { + RequireUserPermissions(UserPermissions.ControlScheduler); await Scheduler.ResumeAll(); } else { if (args.Groups == "trigger-groups") + { + RequireUserPermissions(UserPermissions.ControlTriggers); await Scheduler.ResumeTriggers(GroupMatcher.GroupEquals(args.Name)); + } else if (args.Groups == "job-groups") + { + RequireUserPermissions(UserPermissions.ControlJobs); await Scheduler.ResumeJobs(GroupMatcher.GroupEquals(args.Name)); + } else throw new InvalidOperationException("Invalid groups: " + args.Groups); } @@ -140,7 +158,14 @@ public async Task Action([FromBody] ActionArgs args) default: throw new InvalidOperationException("Invalid action: " + args.Action); } - } + void RequireUserPermissions(params UserPermissions[] userPermissions) + { + if (!UserHasPermissions(userPermissions)) + { + throw new UnauthorizedAccessException("Missing required permissions to perform this action"); + } + } + } } } diff --git a/Source/Quartzmin/Controllers/TriggersController.cs b/Source/Quartzmin/Controllers/TriggersController.cs index 0ad8d60..aa35457 100644 --- a/Source/Quartzmin/Controllers/TriggersController.cs +++ b/Source/Quartzmin/Controllers/TriggersController.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Quartzmin.Security; #region Target-Specific Directives #if NETSTANDARD @@ -23,6 +24,7 @@ namespace Quartzmin.Controllers public class TriggersController : PageControllerBase { [HttpGet] + [AuthorizeUser(UserPermissions.ViewTriggers)] public async Task Index() { var keys = (await Scheduler.GetTriggerKeys(GroupMatcher.AnyGroup())).OrderBy(x => x.ToString()); @@ -70,6 +72,7 @@ public async Task Index() } [HttpGet] + [AuthorizeUser(UserPermissions.CreateNewTriggers)] public async Task New() { var model = await TriggerPropertiesViewModel.Create(Scheduler); @@ -84,6 +87,7 @@ public async Task New() } [HttpGet] + [AuthorizeUser(UserPermissions.ViewTriggers)] public async Task Edit(string name, string group, bool clone = false) { if (!EnsureValidKey(name, group)) return BadRequest(); @@ -144,6 +148,12 @@ public async Task Edit(string name, string group, bool clone = fa [HttpPost, JsonErrorResponse] public async Task Save([FromForm] TriggerViewModel model) { + if ((model.Trigger.IsNew && !UserHasPermissions(UserPermissions.CreateNewTriggers)) || + !UserHasPermissions(UserPermissions.EditTriggers)) + { + return Unauthorized(); + } + var triggerModel = model.Trigger; var jobDataMap = (await Request.GetJobDataMapForm()).GetModel(Services); @@ -192,6 +202,7 @@ public async Task Save([FromForm] TriggerViewModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.DeleteTriggers)] public async Task Delete([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -205,6 +216,7 @@ public async Task Delete([FromBody] KeyModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ControlTriggers)] public async Task Resume([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -213,6 +225,7 @@ public async Task Resume([FromBody] KeyModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ControlTriggers)] public async Task Pause([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -221,6 +234,7 @@ public async Task Pause([FromBody] KeyModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ControlJobs)] public async Task PauseJob([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -229,6 +243,7 @@ public async Task PauseJob([FromBody] KeyModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ControlJobs)] public async Task ResumeJob([FromBody] KeyModel model) { if (!EnsureValidKey(model)) return BadRequest(); @@ -237,6 +252,7 @@ public async Task ResumeJob([FromBody] KeyModel model) } [HttpPost, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ViewTriggers)] public IActionResult Cron() { var cron = Request.ReadAsString()?.Trim(); @@ -284,6 +300,7 @@ private async Task GetTrigger(TriggerKey key) } [HttpGet, JsonErrorResponse] + [AuthorizeUser(UserPermissions.ViewTriggers)] public async Task AdditionalData() { var keys = await Scheduler.GetTriggerKeys(GroupMatcher.AnyGroup()); @@ -303,9 +320,9 @@ public async Task AdditionalData() return View(list); } - [HttpGet] + [AuthorizeUser(UserPermissions.CreateNewTriggers)] public Task Duplicate(string name, string group) { return Edit(name, group, clone: true); @@ -313,7 +330,5 @@ public Task Duplicate(string name, string group) bool EnsureValidKey(string name, string group) => !(string.IsNullOrEmpty(name) || string.IsNullOrEmpty(group)); bool EnsureValidKey(KeyModel model) => EnsureValidKey(model.Name, model.Group); - } - } diff --git a/Source/Quartzmin/Quartzmin.csproj b/Source/Quartzmin/Quartzmin.csproj index 1feec98..b4ff9e5 100644 --- a/Source/Quartzmin/Quartzmin.csproj +++ b/Source/Quartzmin/Quartzmin.csproj @@ -75,4 +75,10 @@ + + + ..\..\packages\Microsoft.Owin.2.1.0\lib\net45\Microsoft.Owin.dll + + + diff --git a/Source/Quartzmin/QuartzminOptions.cs b/Source/Quartzmin/QuartzminOptions.cs index a3c8ef9..78389a9 100644 --- a/Source/Quartzmin/QuartzminOptions.cs +++ b/Source/Quartzmin/QuartzminOptions.cs @@ -2,7 +2,7 @@ using Quartzmin.TypeHandlers; using System.Collections.Generic; using System.IO; - +using Quartzmin.Security; using Number = Quartzmin.TypeHandlers.NumberHandler.UnderlyingType; namespace Quartzmin @@ -22,6 +22,8 @@ public class QuartzminOptions public IScheduler Scheduler { get; set; } + public IAuthorizationProvider AuthorizationProvider { get; set; } + /// /// Supported value types in job data map. /// diff --git a/Source/Quartzmin/Security/AuthorizeUserAttribute.cs b/Source/Quartzmin/Security/AuthorizeUserAttribute.cs new file mode 100644 index 0000000..ab23926 --- /dev/null +++ b/Source/Quartzmin/Security/AuthorizeUserAttribute.cs @@ -0,0 +1,42 @@ +#if NETSTANDARD +using Microsoft.AspNetCore.Mvc; + +namespace Quartzmin.Security +{ + public class AuthorizeUserAttribute : TypeFilterAttribute + { + public AuthorizeUserAttribute(params UserPermissions[] requiredUserPermissions) : base(typeof(UserAuthorizationFilter)) + { + Arguments = new object[] { requiredUserPermissions }; + } + } +} +#endif +#if NETFRAMEWORK +using System; +using System.Net; +using System.Net.Http; +using System.Web.Http; +using System.Web.Http.Controllers; + +namespace Quartzmin.Security +{ + public class AuthorizeUserAttribute : AuthorizeAttribute + { + public UserPermissions[] RequiredUserPermissions { get; } + + public AuthorizeUserAttribute(params UserPermissions[] requiredUserPermissions) + { + RequiredUserPermissions = requiredUserPermissions ?? throw new ArgumentNullException(nameof(requiredUserPermissions)); + } + + protected override void HandleUnauthorizedRequest(HttpActionContext actionContext) + { + if (!actionContext.Request.GetOwinContext().DoesUserHavePermissions(RequiredUserPermissions)) + { + actionContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized); + } + } + } +} +#endif \ No newline at end of file diff --git a/Source/Quartzmin/Security/HttpContextExtensions.cs b/Source/Quartzmin/Security/HttpContextExtensions.cs new file mode 100644 index 0000000..41f7901 --- /dev/null +++ b/Source/Quartzmin/Security/HttpContextExtensions.cs @@ -0,0 +1,43 @@ +#if NETSTANDARD +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace Quartzmin.Security +{ + internal static class HttpContextExtensions + { + internal static bool DoesUserHavePermissions(this HttpContext httpContext, params UserPermissions[] userPermissions) + { + var items = httpContext.Items; + var itemKey = typeof(Services); + + if (items.ContainsKey(itemKey)) + { + if (items[itemKey] is Services services && services.AuthorizationProvider != null) + { + var currentUserPermissions = services.AuthorizationProvider.GetUserPermissions(httpContext.User); + return userPermissions?.All(p => currentUserPermissions.Contains(p)) ?? true; + } + } + + return true; + } + + internal static UserPermissions[] GetUserPermissions(this HttpContext httpContext) + { + var items = httpContext.Items; + var itemKey = typeof(Services); + + if (items.ContainsKey(itemKey)) + { + if (items[itemKey] is Services services && services.AuthorizationProvider != null) + { + return services.AuthorizationProvider.GetUserPermissions(httpContext.User); + } + } + + return null; + } + } +} +#endif \ No newline at end of file diff --git a/Source/Quartzmin/Security/IAuthorizationProvider.cs b/Source/Quartzmin/Security/IAuthorizationProvider.cs new file mode 100644 index 0000000..a1cd8f5 --- /dev/null +++ b/Source/Quartzmin/Security/IAuthorizationProvider.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; + +namespace Quartzmin.Security +{ + public interface IAuthorizationProvider + { + UserPermissions[] GetUserPermissions(ClaimsPrincipal claimsPrincipal); + } +} \ No newline at end of file diff --git a/Source/Quartzmin/Security/OwinContextExtensions.cs b/Source/Quartzmin/Security/OwinContextExtensions.cs new file mode 100644 index 0000000..bd14eec --- /dev/null +++ b/Source/Quartzmin/Security/OwinContextExtensions.cs @@ -0,0 +1,40 @@ +#if NETFRAMEWORK +using System.Linq; +using System.Security.Claims; +using Microsoft.Owin; + +namespace Quartzmin.Security +{ + internal static class OwinContextExtensions + { + internal static bool DoesUserHavePermissions(this IOwinContext owinContext, params UserPermissions[] userPermissions) + { + var services = owinContext.Get(Services.ContextKey); + if (services?.AuthorizationProvider != null) + { + if (owinContext.Request.User is ClaimsPrincipal claimsPrincipal) + { + var currentUserPermissions = services.AuthorizationProvider.GetUserPermissions(claimsPrincipal); + return userPermissions?.All(p => currentUserPermissions.Contains(p)) ?? true; + } + } + + return true; + } + + internal static UserPermissions[] GetUserPermissions(this IOwinContext owinContext) + { + var services = owinContext.Get(Services.ContextKey); + if (services?.AuthorizationProvider != null) + { + if (owinContext.Request.User is ClaimsPrincipal claimsPrincipal) + { + return services.AuthorizationProvider.GetUserPermissions(claimsPrincipal); + } + } + + return null; + } + } +} +#endif \ No newline at end of file diff --git a/Source/Quartzmin/Security/UserAuthorizationFilter.cs b/Source/Quartzmin/Security/UserAuthorizationFilter.cs new file mode 100644 index 0000000..4fd1fe5 --- /dev/null +++ b/Source/Quartzmin/Security/UserAuthorizationFilter.cs @@ -0,0 +1,31 @@ +#if NETSTANDARD +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Quartzmin.Security +{ + public class UserAuthorizationFilter : IAuthorizationFilter + { + public UserPermissions[] RequiredUserPermissions { get; } + + public UserAuthorizationFilter(params UserPermissions[] requiredUserPermissions) + { + RequiredUserPermissions = requiredUserPermissions ?? new UserPermissions[0]; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!context.HttpContext.DoesUserHavePermissions(RequiredUserPermissions)) + { + context.Result = new UnauthorizedResult(); + } + } + } +} +#endif diff --git a/Source/Quartzmin/Security/UserPermissions.cs b/Source/Quartzmin/Security/UserPermissions.cs new file mode 100644 index 0000000..500ba76 --- /dev/null +++ b/Source/Quartzmin/Security/UserPermissions.cs @@ -0,0 +1,25 @@ +namespace Quartzmin.Security +{ + public enum UserPermissions + { + ControlScheduler, + ViewCalendars, + CreateNewCalendars, + EditCalendars, + DeleteCalendars, + ViewExecutions, + InterruptExecutions, + ViewHistory, + ViewJobs, + CreateNewJobs, + TriggerJobs, + EditJobs, + DeleteJobs, + ControlJobs, + ViewTriggers, + CreateNewTriggers, + EditTriggers, + DeleteTriggers, + ControlTriggers + } +} \ No newline at end of file diff --git a/Source/Quartzmin/Services.cs b/Source/Quartzmin/Services.cs index 3f14597..6ce2eb1 100644 --- a/Source/Quartzmin/Services.cs +++ b/Source/Quartzmin/Services.cs @@ -1,6 +1,8 @@ -using HandlebarsDotNet; +using System.Linq; +using HandlebarsDotNet; using Quartz; using Quartzmin.Helpers; +using Quartzmin.Security; namespace Quartzmin { @@ -18,6 +20,8 @@ public class Services public IScheduler Scheduler { get; set; } + public IAuthorizationProvider AuthorizationProvider { get; set; } + internal Cache Cache { get; private set; } public static Services Create(QuartzminOptions options) @@ -32,6 +36,7 @@ public static Services Create(QuartzminOptions options) { Options = options, Scheduler = options.Scheduler, + AuthorizationProvider = options.AuthorizationProvider, Handlebars = HandlebarsDotNet.Handlebars.Create(handlebarsConfiguration), }; diff --git a/Source/Quartzmin/Views/Calendars/Edit.hbs b/Source/Quartzmin/Views/Calendars/Edit.hbs index 5591b08..86e7f8c 100644 --- a/Source/Quartzmin/Views/Calendars/Edit.hbs +++ b/Source/Quartzmin/Views/Calendars/Edit.hbs @@ -11,10 +11,17 @@ + +{{#if UserPermissions.CanCreateNewJobs}} New +{{/if}}
diff --git a/Source/Quartzmin/Views/Executions/Index.hbs b/Source/Quartzmin/Views/Executions/Index.hbs index b5072e1..664933a 100644 --- a/Source/Quartzmin/Views/Executions/Index.hbs +++ b/Source/Quartzmin/Views/Executions/Index.hbs @@ -24,7 +24,11 @@ {{ScheduledFireTime}} {{ActualFireTime}} {{RunTime}} - + + {{#if ../UserPermissions.CanInterruptExecutions}} + + {{/if}} + {{/each}} diff --git a/Source/Quartzmin/Views/Jobs/Edit.hbs b/Source/Quartzmin/Views/Jobs/Edit.hbs index 4e44084..7481512 100644 --- a/Source/Quartzmin/Views/Jobs/Edit.hbs +++ b/Source/Quartzmin/Views/Jobs/Edit.hbs @@ -10,18 +10,24 @@ - - New - + +{{#if UserPermissions.CanCreateNewJobs}} + + New + +{{/if}}
@@ -64,10 +67,18 @@
diff --git a/Source/Quartzmin/Views/Layout.hbs b/Source/Quartzmin/Views/Layout.hbs index 92a508b..f5b503c 100644 --- a/Source/Quartzmin/Views/Layout.hbs +++ b/Source/Quartzmin/Views/Layout.hbs @@ -39,24 +39,34 @@ {{MenuItemActionLink text='Overview' controller='Scheduler'}} - {{MenuItemActionLink 'Jobs'}} - {{MenuItemActionLink 'Triggers'}} - {{MenuItemActionLink 'Executions'}} - {{MenuItemActionLink 'History'}} - {{MenuItemActionLink 'Calendars'}} + {{#if UserPermissions.CanViewJobs}} + {{MenuItemActionLink 'Jobs'}} + {{/if}} + {{#if UserPermissions.CanViewTriggers}} + {{MenuItemActionLink 'Triggers'}} + {{/if}} + {{#if UserPermissions.CanViewExecutions}} + {{MenuItemActionLink 'Executions'}} + {{/if}} + {{#if UserPermissions.CanViewHistory}} + {{MenuItemActionLink 'History'}} + {{/if}} + {{#if UserPermissions.CanViewCalendars}} + {{MenuItemActionLink 'Calendars'}} + {{/if}} + + + --> diff --git a/Source/Quartzmin/Views/Scheduler/Index.hbs b/Source/Quartzmin/Views/Scheduler/Index.hbs index f567058..5c2314c 100644 --- a/Source/Quartzmin/Views/Scheduler/Index.hbs +++ b/Source/Quartzmin/Views/Scheduler/Index.hbs @@ -66,8 +66,8 @@
+ {{#if ../UserPermissions.CanControlScheduler}}

Actions

-

{{#if MetaData.InStandbyMode}} @@ -78,10 +78,15 @@

+ {{/if}} + {{#if ../UserPermissions.CanControlJobs}} {{>GroupActions items=JobGroups id='job-groups' header='Job Groups'}} - {{>GroupActions items=TriggerGroups id='trigger-groups' header='Trigger Groups'}} + {{/if}} + {{#if ../UserPermissions.CanControlTriggers}} + {{>GroupActions items=TriggerGroups id='trigger-groups' header='Trigger Groups'}} + {{/if}}
diff --git a/Source/Quartzmin/Views/Triggers/Edit.hbs b/Source/Quartzmin/Views/Triggers/Edit.hbs index 54a0f11..346a17d 100644 --- a/Source/Quartzmin/Views/Triggers/Edit.hbs +++ b/Source/Quartzmin/Views/Triggers/Edit.hbs @@ -10,10 +10,16 @@ + +{{#if UserPermissions.CanCreateNewTriggers}} New +{{/if}}
@@ -49,8 +52,10 @@ {{JobGroup}} + {{#if ../UserPermissions.CanControlTriggers}} Pause All Resume All + {{/if}} {{/if}} @@ -81,10 +86,16 @@