diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0205ec1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +# EditorConfig: https://EditorConfig.org + +root = true + +# All Files +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# XML Configuration Files +[*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct,refactorlog,runsettings}] +indent_size = 2 + +# JSON Files +[*.{json,json5,webmanifest}] +indent_size = 2 + +# Project Files +[*.{csproj,sqlproj}] +indent_size = 2 + +# YAML Files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown Files +[*.md] +trim_trailing_whitespace = false + +# Web Files +[*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,pcss,svg,vue}] +indent_size = 2 + +# Batch Files +[*.{cmd,bat}] +end_of_line = crlf + +# Bash Files +[*.sh] +end_of_line = lf + +[*.{cs,vb}] +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true +dotnet_style_namespace_match_folder = true + +[*.cs] +csharp_using_directive_placement = outside_namespace +csharp_style_namespace_declarations = file_scoped:warning diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5387cb2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: +- package-ecosystem: nuget + directory: "/" + schedule: + interval: daily + time: "11:00" + open-pull-requests-limit: 10 + diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..015aff9 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,99 @@ +name: Build + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_ENVIRONMENT: github + ASPNETCORE_ENVIRONMENT: github + BUILD_PATH: '${{github.workspace}}/artifacts' + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 8.0.x + + - name: Restore Dependencies + run: dotnet restore + + - name: Build Solution + run: dotnet build --no-restore --configuration Release + + - name: Run Test + run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --settings coverlet.runsettings + + - name: Report Coverage + if: success() + uses: coverallsapp/github-action@v2 + with: + file: "${{github.workspace}}/test/*/TestResults/*/coverage.info" + format: lcov + + - name: Create Packages + if: success() && github.event_name != 'pull_request' + run: dotnet pack --configuration Release --include-symbols --include-source --no-build --output "${{env.BUILD_PATH}}" + + - name: Upload Packages + if: success() && github.event_name != 'pull_request' + uses: actions/upload-artifact@v3 + with: + name: packages + path: "${{env.BUILD_PATH}}" + + deploy: + runs-on: ubuntu-latest + needs: build + if: success() && github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + + steps: + - name: Download Artifact + uses: actions/download-artifact@v3 + with: + name: packages + + - name: Publish Packages GitHub + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://nuget.pkg.github.com/loresoft/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + done + + - name: Publish Packages feedz + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://f.feedz.io/loresoft/open/nuget/index.json --api-key ${{ secrets.FEEDDZ_KEY }} --skip-duplicate + done + + - name: Publish Packages Nuget + if: startsWith(github.ref, 'refs/tags/v') + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_KEY }} --skip-duplicate + done + diff --git a/Authorizone.sln b/Authorizone.sln new file mode 100644 index 0000000..b3913a2 --- /dev/null +++ b/Authorizone.sln @@ -0,0 +1,38 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorizone", "src\Authorizone\Authorizone.csproj", "{9CE78075-9509-4113-B645-45A082C68839}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorizone.Tests", "test\Authorizone.Tests\Authorizone.Tests.csproj", "{DA3CCF24-533A-436A-9929-28AF83D29B32}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{4F71B12A-605F-48FE-94F5-FF3B776FEA6D}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9CE78075-9509-4113-B645-45A082C68839}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CE78075-9509-4113-B645-45A082C68839}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CE78075-9509-4113-B645-45A082C68839}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CE78075-9509-4113-B645-45A082C68839}.Release|Any CPU.Build.0 = Release|Any CPU + {DA3CCF24-533A-436A-9929-28AF83D29B32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA3CCF24-533A-436A-9929-28AF83D29B32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA3CCF24-533A-436A-9929-28AF83D29B32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA3CCF24-533A-436A-9929-28AF83D29B32}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E07DA442-1D4F-4528-BB2E-29E884BFFA66} + EndGlobalSection +EndGlobal diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..4eac22c --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,14 @@ + + + + + + + lcov + Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,TestSDKAutoGeneratedCode + true + + + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..6482efc Binary files /dev/null and b/logo.png differ diff --git a/src/Authorizone/AuthorizationActions.cs b/src/Authorizone/AuthorizationActions.cs new file mode 100644 index 0000000..c6cca46 --- /dev/null +++ b/src/Authorizone/AuthorizationActions.cs @@ -0,0 +1,6 @@ +namespace Authorizone; + +public static class AuthorizationActions +{ + public const string All = "all"; +} diff --git a/src/Authorizone/AuthorizationBuilder.cs b/src/Authorizone/AuthorizationBuilder.cs new file mode 100644 index 0000000..80a3e65 --- /dev/null +++ b/src/Authorizone/AuthorizationBuilder.cs @@ -0,0 +1,39 @@ +namespace Authorizone; + +public class AuthorizationBuilder +{ + private readonly List _rules = []; + + public AuthorizationBuilder Allow(string action, string subject, IEnumerable? fields = null) + { + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action cannot be null or whitespace.", nameof(action)); + + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or whitespace.", nameof(subject)); + + var rule = new AuthorizationRule(action, subject, fields?.ToList()); + _rules.Add(rule); + + return this; + } + + public AuthorizationBuilder Forbid(string action, string subject, IEnumerable? fields = null) + { + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action cannot be null or whitespace.", nameof(action)); + + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or whitespace.", nameof(subject)); + + var rule = new AuthorizationRule(action, subject, fields?.ToList(), true); + _rules.Add(rule); + + return this; + } + + public AuthorizationContext Build() + { + return new AuthorizationContext(_rules); + } +} diff --git a/src/Authorizone/AuthorizationBuilderExtensions.cs b/src/Authorizone/AuthorizationBuilderExtensions.cs new file mode 100644 index 0000000..e01ea9d --- /dev/null +++ b/src/Authorizone/AuthorizationBuilderExtensions.cs @@ -0,0 +1,134 @@ +namespace Authorizone; + +public static class AuthorizationBuilderExtensions +{ + public static AuthorizationBuilder Allow(this AuthorizationBuilder builder, IEnumerable actions, IEnumerable subjects, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (actions == null) + throw new ArgumentNullException(nameof(actions)); + + if (subjects == null) + throw new ArgumentNullException(nameof(subjects)); + + var subjectList = subjects.ToList(); + + foreach (var action in actions) + { + foreach (var subject in subjectList) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Allow(action, subject, fields); + } + } + + return builder; + } + + public static AuthorizationBuilder Allow(this AuthorizationBuilder builder, IEnumerable actions, string subject, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (actions == null) + throw new ArgumentNullException(nameof(actions)); + + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or whitespace.", nameof(subject)); + + foreach (var action in actions) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Allow(action, subject, fields); + } + + return builder; + } + + public static AuthorizationBuilder Allow(this AuthorizationBuilder builder, string action, IEnumerable subjects, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (subjects == null) + throw new ArgumentNullException(nameof(subjects)); + + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action cannot be null or whitespace.", nameof(action)); + + foreach (var subject in subjects) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Allow(action, subject, fields); + } + + return builder; + } + + public static AuthorizationBuilder Forbid(this AuthorizationBuilder builder, IEnumerable actions, IEnumerable subjects, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (actions == null) + throw new ArgumentNullException(nameof(actions)); + + if (subjects == null) + throw new ArgumentNullException(nameof(subjects)); + + var subjectList = subjects.ToList(); + + foreach (var action in actions) + { + foreach (var subject in subjectList) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Forbid(action, subject, fields); + } + } + + return builder; + } + + public static AuthorizationBuilder Forbid(this AuthorizationBuilder builder, IEnumerable actions, string subject, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (actions == null) + throw new ArgumentNullException(nameof(actions)); + + if (string.IsNullOrWhiteSpace(subject)) + throw new ArgumentException("Subject cannot be null or whitespace.", nameof(subject)); + + foreach (var action in actions) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Forbid(action, subject, fields); + } + + return builder; + } + + public static AuthorizationBuilder Forbid(this AuthorizationBuilder builder, string action, IEnumerable subjects, IEnumerable? fields = null) + { + if (builder == null) + throw new ArgumentNullException(nameof(builder)); + + if (subjects == null) + throw new ArgumentNullException(nameof(subjects)); + + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action cannot be null or whitespace.", nameof(action)); + + foreach (var subject in subjects) + { + // ReSharper disable once PossibleMultipleEnumeration + builder.Forbid(action, subject, fields); + } + + return builder; + } +} diff --git a/src/Authorizone/AuthorizationContext.cs b/src/Authorizone/AuthorizationContext.cs new file mode 100644 index 0000000..620a795 --- /dev/null +++ b/src/Authorizone/AuthorizationContext.cs @@ -0,0 +1,70 @@ +namespace Authorizone; + +public class AuthorizationContext(IReadOnlyCollection rules, StringComparer? stringComparer = null) +{ + public IReadOnlyCollection Rules { get; } = rules ?? throw new ArgumentNullException(nameof(rules)); + + public StringComparer StringComparer { get; } = stringComparer ?? StringComparer.InvariantCultureIgnoreCase; + + + public bool Authorized(string? action, string? subject, string? field = null) + { + if (action is null || subject is null) + return false; + + var matchedRules = MatchRules(action, subject, field); + bool? state = null; + + foreach (var matchedRule in matchedRules) + { + if (matchedRule.Denied == true) + state = state != null && (state.Value && false); + else + state = state == null || (state.Value || true); + } + + return state ?? false; + } + + public bool Unauthorized(string? action, string? subject, string? field = null) => !Authorized(action, subject, field); + + public IEnumerable MatchRules(string? action, string? subject, string? field = null) + { + if (action is null || subject is null) + return Enumerable.Empty(); + + return Rules.Where(r => RuleMatcher(r, action, subject, field)); + } + + + private bool RuleMatcher(AuthorizationRule rule, string action, string subject, string? field = null) + { + return SubjectMather(rule, subject) + && ActionMather(rule, action) + && FieldMatcher(rule, field); + } + + private bool SubjectMather(AuthorizationRule rule, string subject) + { + // can match global all or requested subject + return StringComparer.Equals(rule.Subject, subject) + || StringComparer.Equals(rule.Subject, AuthorizationSubjects.All); + } + + private bool ActionMather(AuthorizationRule rule, string action) + { + // can match global manage action or requested action + return StringComparer.Equals(rule.Action, action) + || StringComparer.Equals(rule.Action, AuthorizationActions.All); + } + + private bool FieldMatcher(AuthorizationRule rule, string? field) + { + // if rule doesn't have fields, all allowed + if (field == null || rule.Fields == null || rule.Fields.Count == 0) + return true; + + // ensure rule has field + return rule.Fields.Contains(field, StringComparer); + } +} diff --git a/src/Authorizone/AuthorizationRule.cs b/src/Authorizone/AuthorizationRule.cs new file mode 100644 index 0000000..4803dc0 --- /dev/null +++ b/src/Authorizone/AuthorizationRule.cs @@ -0,0 +1,7 @@ +namespace Authorizone; + +public record AuthorizationRule( + string Action, + string Subject, + List? Fields = null, + bool? Denied = null); diff --git a/src/Authorizone/AuthorizationSubjects.cs b/src/Authorizone/AuthorizationSubjects.cs new file mode 100644 index 0000000..b389cd1 --- /dev/null +++ b/src/Authorizone/AuthorizationSubjects.cs @@ -0,0 +1,6 @@ +namespace Authorizone; + +public static class AuthorizationSubjects +{ + public const string All = "all"; +} diff --git a/src/Authorizone/Authorizone.csproj b/src/Authorizone/Authorizone.csproj new file mode 100644 index 0000000..a410a7b --- /dev/null +++ b/src/Authorizone/Authorizone.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0;net6.0;net8.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..42c48a9 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,55 @@ + + + + An isomorphic authorization library for restricting resources by action, subjct and fields + Copyright © $([System.DateTime]::Now.ToString(yyyy)) LoreSoft + LoreSoft + en-US + true + authorization;auth + https://github.com/loresoft/Authorizone + MIT + logo.png + README.md + git + https://github.com/loresoft/Authorizone + true + + + + portable + true + true + snupkg + + + + latest + enable + 1591 + + + + v + + + + + + + + + + + true + \ + false + + + true + \ + false + + + + diff --git a/test/Authorizone.Tests/AuthorizationContextTests.cs b/test/Authorizone.Tests/AuthorizationContextTests.cs new file mode 100644 index 0000000..27743da --- /dev/null +++ b/test/Authorizone.Tests/AuthorizationContextTests.cs @@ -0,0 +1,81 @@ +namespace Authorizone.Tests; + +public class AuthorizationContextTests +{ + [Fact] + public void AllowByDefault() + { + var context = new AuthorizationBuilder() + .Allow("test", AuthorizationSubjects.All) + .Allow(AuthorizationActions.All, "Post") + .Forbid("publish", "Post") + .Build(); + + Assert.True(context.Authorized("read", "Post")); + Assert.True(context.Authorized("update", "Post")); + Assert.True(context.Authorized("archive", "Post")); + Assert.False(context.Authorized(null, "Post")); + Assert.False(context.Authorized("archive", null)); + Assert.False(context.Authorized("read", "User")); + Assert.True(context.Authorized("delete", "Post")); + Assert.False(context.Authorized("publish", "Post")); + Assert.True(context.Authorized("test", "User")); + Assert.True(context.Authorized("test", "Post")); + } + + [Fact] + public void AllowConstructRules() + { + var context = new AuthorizationBuilder() + .Allow("read", "Article") + .Allow("update", "Article") + .Build(); + + Assert.True(context.Authorized("read", "Article")); + Assert.True(context.Authorized("update", "Article")); + Assert.False(context.Authorized("delete", "Article")); + } + + [Fact] + public void AllowSpecifyMultipleActions() + { + var context = new AuthorizationBuilder() + .Allow(["read", "update"], "Post") + .Build(); + + Assert.True(context.Authorized("read", "Post")); + Assert.True(context.Authorized("update", "Post")); + Assert.False(context.Authorized("delete", "Post")); + } + + [Fact] + public void AllowSpecifyMultipleSubjects() + { + var context = new AuthorizationBuilder() + .Allow("read", ["Post", "User"]) + .Build(); + + Assert.True(context.Authorized("read", "Post")); + Assert.True(context.Authorized("read", "User")); + Assert.False(context.Authorized("read", "Article")); + } + + [Fact] + public void AllowRulesWithFields() + { + var context = new AuthorizationBuilder() + .Allow("read", "Post", ["title", "id"]) + .Allow("read", "User") + .Build(); + + Assert.True(context.Authorized("read", "Post")); + Assert.True(context.Authorized("read", "Post", "id")); + Assert.True(context.Authorized("read", "Post", "title")); + Assert.False(context.Authorized("read", "Post", "ssn")); + + Assert.True(context.Authorized("read", "User")); + Assert.True(context.Authorized("read", "User", "id")); + } + + +} diff --git a/test/Authorizone.Tests/Authorizone.Tests.csproj b/test/Authorizone.Tests/Authorizone.Tests.csproj new file mode 100644 index 0000000..9545ba2 --- /dev/null +++ b/test/Authorizone.Tests/Authorizone.Tests.csproj @@ -0,0 +1,34 @@ + + + + net6.0;net8.0 + enable + enable + latest + + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Authorizone.Tests/GlobalUsings.cs b/test/Authorizone.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/test/Authorizone.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file