From 3c1bb3ec1983e38f7d8ee3aa664c30521e12b5ff Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 1 Apr 2022 22:38:49 +1100 Subject: [PATCH] feat(authorization): domain regex match with named groups (#2789) This adds an option to match domains by regex including two special named matching groups. User matches the username of the user, and Group matches the groups a user is a member of. These are both case-insensitive and you can see examples in the docs. --- config.template.yml | 20 ++- docs/configuration/access-control.md | 129 ++++++++++++---- .../authorization/access_control_domain.go | 111 +++++++++++++- .../authorization/access_control_resource.go | 2 +- internal/authorization/access_control_rule.go | 4 +- .../authorization/access_control_subjects.go | 7 +- internal/authorization/authorizer_test.go | 141 +++++++++++++++++- internal/authorization/const.go | 26 +++- internal/authorization/types.go | 11 ++ internal/authorization/types_test.go | 13 ++ internal/authorization/util.go | 51 ++++--- internal/authorization/util_test.go | 3 + internal/configuration/config.template.yml | 20 ++- internal/configuration/decode_hooks.go | 35 +++++ internal/configuration/decode_hooks_test.go | 120 +++++++++++++++ internal/configuration/provider.go | 1 + internal/configuration/provider_test.go | 48 ++++++ .../configuration/schema/access_control.go | 21 ++- .../config_domain_bad_regex.yml | 126 ++++++++++++++++ .../test_resources/config_domain_regex.yml | 132 ++++++++++++++++ .../configuration/validator/access_control.go | 37 +++-- .../validator/access_control_test.go | 47 +++--- internal/configuration/validator/const.go | 8 +- 23 files changed, 970 insertions(+), 143 deletions(-) create mode 100644 internal/configuration/test_resources/config_domain_bad_regex.yml create mode 100644 internal/configuration/test_resources/config_domain_regex.yml diff --git a/config.template.yml b/config.template.yml index e03bbf241..abef31c09 100644 --- a/config.template.yml +++ b/config.template.yml @@ -366,6 +366,18 @@ access_control: - domain: public.example.com policy: bypass + ## Domain Regex examples. Generally we recommend just using a standard domain. + # - domain_regex: "^(?P\w+)\\.example\\.com$" + # policy: one_factor + # - domain_regex: "^(?P\w+)\\.example\\.com$" + # policy: one_factor + # - domain_regex: + # - "^appgroup-.*\\.example\\.com$" + # - "^appgroup2-.*\\.example\\.com$" + # policy: one_factor + # - domain_regex: "^.*\\.example.com$" + # policy: two_factor + - domain: secure.example.com policy: one_factor ## Network based rule, if not provided any network matches. @@ -397,21 +409,21 @@ access_control: ## Rules applied to 'dev' group - domain: dev.example.com resources: - - "^/groups/dev/.*$" + - '^/groups/dev/.*$' subject: "group:dev" policy: two_factor ## Rules applied to user 'john' - domain: dev.example.com resources: - - "^/users/john/.*$" + - '^/users/john/.*$' subject: "user:john" policy: two_factor ## Rules applied to user 'harry' - domain: dev.example.com resources: - - "^/users/harry/.*$" + - '^/users/harry/.*$' subject: "user:harry" policy: two_factor @@ -421,7 +433,7 @@ access_control: policy: two_factor - domain: "dev.example.com" resources: - - "^/users/bob/.*$" + - '^/users/bob/.*$' subject: "user:bob" policy: two_factor diff --git a/docs/configuration/access-control.md b/docs/configuration/access-control.md index 7c90395bc..44c008fbd 100644 --- a/docs/configuration/access-control.md +++ b/docs/configuration/access-control.md @@ -23,6 +23,7 @@ access_control: rules: - domain: public.example.com + domain_regex: "^\d+\\.public.example.com$" policy: one_factor networks: - internal @@ -35,7 +36,7 @@ access_control: - GET - HEAD resources: - - "^/api.*" + - '^/api.*' ``` ## Options @@ -96,6 +97,7 @@ A rule defines two primary things: The criteria is broken into several parts: * [domain](#domain): domain or list of domains targeted by the request. +* [domain_regex](#domain_regex): regex form of [domain](#domain). * [resources](#resources): pattern or list of patterns that the path should match. * [subject](#subject): the user or group of users to define the policy for. * [networks](#networks): the network addresses, ranges (CIDR notation) or groups from where the request originates. @@ -107,17 +109,6 @@ is a match for a given request is the rule applied; subsequent rules have *no ef carefully evaluate your rule list **in order** to see which rule matches a particular scenario. A comprehensive understanding of how rules apply is also recommended. -#### policy -
-type: string -{: .label .label-config .label-purple } -required: yes -{: .label .label-config .label-red } -
- -The specific [policy](#policies) to apply to the selected rule. This is not criteria for a match, this is the action to -take when a match is made. - #### domain
type: list(string) @@ -126,8 +117,12 @@ required: yes {: .label .label-config .label-red }
+_**Required:** This criteria OR the [domain_regex](#domain_regex) criteria are required._ + This criteria matches the domain name and has two methods of configuration, either as a single string or as a list of strings. When it's a list of strings the rule matches when **any** of the domains in the list match the request domain. +When used in conjunction with [domain_regex](#domain_regex) the rule will match when either the [domain](#domain) or the +[domain_regex](#domain_regex) criteria matches. Rules may start with a few different wildcards: @@ -136,12 +131,17 @@ Rules may start with a few different wildcards: string **must** be quoted like `"*.example.com"`. * The user wildcard is `{user}.`, which when in front of a domain dynamically matches the username of the user. For - example `{user}.example.com` would match `fred.example.com` if the user logged in was named `fred`. ***Note:** we're - considering refactoring this to just be regex which would likely allow many additional possibilities.* + example `{user}.example.com` would match `fred.example.com` if the user logged in was named `fred`. _**Warning:** this is + officially deprecated as the [domain_regex](#domain_regex) criteria completely replaces the functionality in a much + more useful way. It is strongly recommended you do not use this as it will be removed in a future version, most likely + v5.0.0._ * The group wildcard is `{group}.`, which when in front of a domain dynamically matches if the logged in user has the group in that location. For example `{group}.example.com` would match `admins.example.com` if the user logged in was - in the following groups `admins,users,people` because `admins` is in the list. + in the following groups `admins,users,people` because `admins` is in the list. _**Warning:** this is + officially deprecated as the [domain_regex](#domain_regex) criteria completely replaces the functionality in a much + more useful way. It is strongly recommended you do not use this as it will be removed in a future version, most likely + v5.0.0._ Domains in this section must be the domain configured in the [session](./session/index.md#domain) configuration or subdomains of that domain. This is because a website can only write cookies for a domain it is part of. It is @@ -177,6 +177,66 @@ access_control: policy: bypass ``` +### domain_regex +
+type: list(string) +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +_**Required:** This criteria OR the [domain](#domain) criteria are required._ + +_**Important Note:** If you intend to use this criteria with a bypass rule please read +[bypass and subjects](#bypass-and-user-identity) before doing so._ + +This criteria matches the domain name and has two methods of configuration, either as a single string or as a list of +strings. When it's a list of strings the rule matches when **any** of the domains in the list match the request domain. +When used in conjunction with [domain](#domain) the rule will match when either the [domain](#domain) or the +[domain_regex](#domain_regex) criteria matches. + +As this is a regex string you will either need to use single quotes or need to double-escape certain portions of the +regex in order make this work. + +This criteria takes any standard go regex pattern to match the requests. We additionally utilize two special named match +groups which match attributes of the user: + +| Group Name | Match Value | +|:----------:|:-----------------:| +| User | username | +| Group | groups (contains) | + +For the group match it matches if the user has any group name that matches, and both matches are case-insensitive due to +the fact domain names should not be compared in a case-sensitive way as per the +[RFC4343](https://datatracker.ietf.org/doc/html/rfc4343) abstract and +[RFC3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2) section 3.2.2. + +Examples: + +```yaml +access_control: + rules: + - domain: + - apple.example.com + - banana.example.com + policy: bypass + - domain_regex: + - "^user-(?P\w+)\\.example\\.com$" + - "^group-(?P\w+)\\.example\\.com$" + policy: one_factor +``` + +#### policy +
+type: string +{: .label .label-config .label-purple } +required: yes +{: .label .label-config .label-red } +
+ +The specific [policy](#policies) to apply to the selected rule. This is not criteria for a match, this is the action to +take when a match is made. + ### subject
type: list(list(string)) @@ -191,10 +251,10 @@ result in problematic security scenarios with badly thought out configurations a scenario that would require users to do this. If you have a scenario in mind please open an [issue](https://github.com/authelia/authelia/issues/new) on GitHub.* -This criteria matches identifying characteristics about the subject. Currently this is either user or groups the user -belongs to. This allows you to effectively control exactly what each user is authorized to access or to specifically -require two-factor authentication to specific users. Subjects are prefixed with either `user:` or `group:` to identify -which part of the identity to check. +This criteria matches identifying characteristics about the subject. This is either user's name or the name of the +group a user belongs to. This allows you to effectively control exactly what each user is authorized to access or to +specifically require two-factor authentication to specific users. Subjects are prefixed with either `user:` or `group:` +to identify which part of the identity to check. The format of this rule is unique in as much as it is a list of lists. The logic behind this format is to allow for both `OR` and `AND` logic. The first level of the list defines the `OR` logic, and the second level defines the `AND` logic. @@ -355,9 +415,9 @@ for debugging these regular expressions is called [Rego](https://regoio.herokuap they match the entire path including the query parameters. When upgrading you may be required to alter some of your resource rules to get them to operate as they previously did.* -It's important when configuring resource rules that you enclose them in quotes otherwise you may run into some issues -with escaping the expressions. Failure to do so may prevent Authelia from starting. It's technically optional but will -likely save you a lot of time if you do it for all resource rules. +As this is a regex string you will either need to use single quotes or need to double-escape certain portions of the +regex in order make this work. If you don't do either of these things either the regex may not be parsed, or it may not +be parsed correctly. It's technically optional but will likely save you a lot of time if you do it for all resource rules. Examples: @@ -370,16 +430,13 @@ access_control: - domain: app.example.com policy: bypass resources: - - "^/api([/?].*)?$" + - '^/api([/?].*)?$' ``` ## Policies -With **Authelia** you can define a list of rules that are going to be evaluated in -sequential order when authorization is delegated to Authelia. - -The first matching rule of the list defines the policy applied to the resource, if -no rule matches the resource a customizable default policy is applied. +The policy of the first matching rule in the configured list decides the policy applied to the request, if no rule +matches the request the [default_policy](#default_policy) is applied. ### deny @@ -393,6 +450,18 @@ This policy skips all authentication and allows anyone to use the resource. This that includes a [subject](#subject) restriction because the minimum authentication level required to obtain information about the subject is [one_factor](#one_factor). +#### bypass and user identity + +The [bypass](#bypass) policy cannot be used when the rule uses a criteria that requires we know the users identity. This +means: + +- If the rule defines [subjects](#subject) criteria +- If the rule defines [domain regex](#domain_regex) criteria which contains either the user or group named match groups + +This is because these criteria types require knowing who the user is in order to determine if their identity matches the +request. This information can only be known after 1FA, which means the minimum policy that can be used logically is +[one_factor](#one_factor). + ### one_factor This policy requires the user at minimum complete 1FA successfully (username and password). This means if they have @@ -454,13 +523,13 @@ access_control: - domain: dev.example.com resources: - - "^/groups/dev/.*$" + - '^/groups/dev/.*$' subject: "group:dev" policy: two_factor - domain: dev.example.com resources: - - "^/users/john/.*$" + - '^/users/john/.*$' subject: - ["group:dev", "user:john"] - "group:admins" diff --git a/internal/authorization/access_control_domain.go b/internal/authorization/access_control_domain.go index d7c60194f..f028d1cd2 100644 --- a/internal/authorization/access_control_domain.go +++ b/internal/authorization/access_control_domain.go @@ -2,11 +2,35 @@ package authorization import ( "fmt" + "regexp" "strings" "github.com/authelia/authelia/v4/internal/utils" ) +// NewAccessControlDomain creates a new SubjectObjectMatcher that matches the domain as a basic string. +func NewAccessControlDomain(domain string) SubjectObjectMatcher { + d := AccessControlDomain{} + + domain = strings.ToLower(domain) + + switch { + case strings.HasPrefix(domain, "*."): + d.Wildcard = true + d.Name = domain[1:] + case strings.HasPrefix(domain, "{user}"): + d.UserWildcard = true + d.Name = domain[7:] + case strings.HasPrefix(domain, "{group}"): + d.GroupWildcard = true + d.Name = domain[8:] + default: + d.Name = domain + } + + return d +} + // AccessControlDomain represents an ACL domain. type AccessControlDomain struct { Name string @@ -16,17 +40,88 @@ type AccessControlDomain struct { } // IsMatch returns true if the ACL domain matches the object domain. -func (acd AccessControlDomain) IsMatch(subject Subject, object Object) (match bool) { +func (acl AccessControlDomain) IsMatch(subject Subject, object Object) (match bool) { switch { - case acd.Wildcard: - return strings.HasSuffix(object.Domain, acd.Name) - case acd.UserWildcard: - return object.Domain == fmt.Sprintf("%s.%s", subject.Username, acd.Name) - case acd.GroupWildcard: + case acl.Wildcard: + return strings.HasSuffix(object.Domain, acl.Name) + case acl.UserWildcard: + return object.Domain == fmt.Sprintf("%s.%s", subject.Username, acl.Name) + case acl.GroupWildcard: prefix, suffix := domainToPrefixSuffix(object.Domain) - return suffix == acd.Name && utils.IsStringInSliceFold(prefix, subject.Groups) + return suffix == acl.Name && utils.IsStringInSliceFold(prefix, subject.Groups) default: - return object.Domain == acd.Name + return object.Domain == acl.Name } } + +// String returns a string representation of the SubjectObjectMatcher rule. +func (acl AccessControlDomain) String() string { + return fmt.Sprintf("domain:%s", acl.Name) +} + +// NewAccessControlDomainRegex creates a new SubjectObjectMatcher that matches the domain either in a basic way or +// dynamic User/Group subexpression group way. +func NewAccessControlDomainRegex(pattern regexp.Regexp) SubjectObjectMatcher { + var iuser, igroup = -1, -1 + + for i, group := range pattern.SubexpNames() { + switch group { + case subexpNameUser: + iuser = i + case subexpNameGroup: + igroup = i + } + } + + if iuser != -1 || igroup != -1 { + return AccessControlDomainRegex{Pattern: pattern, SubexpNameUser: iuser, SubexpNameGroup: igroup} + } + + return AccessControlDomainRegexBasic{Pattern: pattern} +} + +// AccessControlDomainRegexBasic represents a basic domain regex SubjectObjectMatcher. +type AccessControlDomainRegexBasic struct { + Pattern regexp.Regexp +} + +// IsMatch returns true if the ACL regex matches the object domain. +func (acl AccessControlDomainRegexBasic) IsMatch(_ Subject, object Object) (match bool) { + return acl.Pattern.MatchString(object.Domain) +} + +// String returns a text representation of a AccessControlDomainRegexBasic. +func (acl AccessControlDomainRegexBasic) String() string { + return fmt.Sprintf("domain_regex:%s", acl.Pattern.String()) +} + +// AccessControlDomainRegex represents an ACL domain regex. +type AccessControlDomainRegex struct { + Pattern regexp.Regexp + SubexpNameUser int + SubexpNameGroup int +} + +// IsMatch returns true if the ACL regex matches the object domain. +func (acl AccessControlDomainRegex) IsMatch(subject Subject, object Object) (match bool) { + matches := acl.Pattern.FindAllStringSubmatch(object.Domain, -1) + if matches == nil { + return false + } + + if acl.SubexpNameUser != -1 && !strings.EqualFold(subject.Username, matches[0][acl.SubexpNameUser]) { + return false + } + + if acl.SubexpNameGroup != -1 && !utils.IsStringInSliceFold(matches[0][acl.SubexpNameGroup], subject.Groups) { + return false + } + + return true +} + +// String returns a text representation of a AccessControlDomainRegex. +func (acl AccessControlDomainRegex) String() string { + return fmt.Sprintf("domain_regex(subexp):%s", acl.Pattern.String()) +} diff --git a/internal/authorization/access_control_resource.go b/internal/authorization/access_control_resource.go index 3482b4130..9bfca6d20 100644 --- a/internal/authorization/access_control_resource.go +++ b/internal/authorization/access_control_resource.go @@ -6,7 +6,7 @@ import ( // AccessControlResource represents an ACL resource. type AccessControlResource struct { - Pattern *regexp.Regexp + Pattern regexp.Regexp } // IsMatch returns true if the ACL resource match the object path. diff --git a/internal/authorization/access_control_rule.go b/internal/authorization/access_control_rule.go index 37c984661..29171e54c 100644 --- a/internal/authorization/access_control_rule.go +++ b/internal/authorization/access_control_rule.go @@ -22,7 +22,7 @@ func NewAccessControlRules(config schema.AccessControlConfiguration) (rules []*A func NewAccessControlRule(pos int, rule schema.ACLRule, networksMap map[string][]*net.IPNet, networksCacheMap map[string]*net.IPNet) *AccessControlRule { return &AccessControlRule{ Position: pos, - Domains: schemaDomainsToACL(rule.Domains), + Domains: schemaDomainsToACL(rule.Domains, rule.DomainsRegex), Resources: schemaResourcesToACL(rule.Resources), Methods: schemaMethodsToACL(rule.Methods), Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap), @@ -34,7 +34,7 @@ func NewAccessControlRule(pos int, rule schema.ACLRule, networksMap map[string][ // AccessControlRule controls and represents an ACL internally. type AccessControlRule struct { Position int - Domains []AccessControlDomain + Domains []SubjectObjectMatcher Resources []AccessControlResource Methods []string Networks []*net.IPNet diff --git a/internal/authorization/access_control_subjects.go b/internal/authorization/access_control_subjects.go index cb2c3751f..d7e1f0f9f 100644 --- a/internal/authorization/access_control_subjects.go +++ b/internal/authorization/access_control_subjects.go @@ -4,14 +4,9 @@ import ( "github.com/authelia/authelia/v4/internal/utils" ) -// AccessControlSubject abstracts an ACL subject of type `group:` or `user:`. -type AccessControlSubject interface { - IsMatch(subject Subject) (match bool) -} - // AccessControlSubjects represents an ACL subject. type AccessControlSubjects struct { - Subjects []AccessControlSubject + Subjects []SubjectMatcher } // AddSubject appends to the AccessControlSubjects based on a subject rule string. diff --git a/internal/authorization/authorizer_test.go b/internal/authorization/authorizer_test.go index 805025c4b..adc3708b2 100644 --- a/internal/authorization/authorizer_test.go +++ b/internal/authorization/authorizer_test.go @@ -3,6 +3,7 @@ package authorization import ( "net" "net/url" + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -230,6 +231,118 @@ func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() { tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", "GET", TwoFactor) } +func (s *AuthorizerSuite) TestShouldcheckDomainMatching() { + tester := NewAuthorizerBuilder(). + WithRule(schema.ACLRule{ + Domains: []string{"public.example.com"}, + Policy: bypass, + }). + WithRule(schema.ACLRule{ + Domains: []string{"one-factor.example.com"}, + Policy: oneFactor, + }). + WithRule(schema.ACLRule{ + Domains: []string{"two-factor.example.com"}, + Policy: twoFactor, + }). + WithRule(schema.ACLRule{ + Domains: []string{"*.example.com"}, + Policy: oneFactor, + Subjects: [][]string{{"group:admins"}}, + }). + WithRule(schema.ACLRule{ + Domains: []string{"*.example.com"}, + Policy: twoFactor, + }). + Build() + + tester.CheckAuthorizations(s.T(), John, "https://public.example.com", "GET", Bypass) + tester.CheckAuthorizations(s.T(), Bob, "https://public.example.com", "GET", Bypass) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example.com", "GET", Bypass) + + tester.CheckAuthorizations(s.T(), John, "https://one-factor.example.com", "GET", OneFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://one-factor.example.com", "GET", OneFactor) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://one-factor.example.com", "GET", OneFactor) + + tester.CheckAuthorizations(s.T(), John, "https://two-factor.example.com", "GET", TwoFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://two-factor.example.com", "GET", TwoFactor) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://two-factor.example.com", "GET", TwoFactor) + + tester.CheckAuthorizations(s.T(), John, "https://x.example.com", "GET", OneFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://x.example.com", "GET", TwoFactor) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://x.example.com", "GET", OneFactor) + + assert.Equal(s.T(), "public.example.com", tester.configuration.AccessControl.Rules[0].Domains[0]) + assert.Equal(s.T(), "domain:public.example.com", tester.rules[0].Domains[0].String()) + + assert.Equal(s.T(), "one-factor.example.com", tester.configuration.AccessControl.Rules[1].Domains[0]) + assert.Equal(s.T(), "domain:one-factor.example.com", tester.rules[1].Domains[0].String()) + + assert.Equal(s.T(), "two-factor.example.com", tester.configuration.AccessControl.Rules[2].Domains[0]) + assert.Equal(s.T(), "domain:two-factor.example.com", tester.rules[2].Domains[0].String()) + + assert.Equal(s.T(), "*.example.com", tester.configuration.AccessControl.Rules[3].Domains[0]) + assert.Equal(s.T(), "domain:.example.com", tester.rules[3].Domains[0].String()) + + assert.Equal(s.T(), "*.example.com", tester.configuration.AccessControl.Rules[4].Domains[0]) + assert.Equal(s.T(), "domain:.example.com", tester.rules[4].Domains[0].String()) +} + +func (s *AuthorizerSuite) TestShouldCheckDomainRegexMatching() { + createSliceRegexRule := func(t *testing.T, rules []string) []regexp.Regexp { + result, err := stringSliceToRegexpSlice(rules) + + require.NoError(t, err) + + return result + } + + tester := NewAuthorizerBuilder(). + WithRule(schema.ACLRule{ + DomainsRegex: createSliceRegexRule(s.T(), []string{`^.*\.example.com$`}), + Policy: bypass, + }). + WithRule(schema.ACLRule{ + DomainsRegex: createSliceRegexRule(s.T(), []string{`^.*\.example2.com$`}), + Policy: oneFactor, + }). + WithRule(schema.ACLRule{ + DomainsRegex: createSliceRegexRule(s.T(), []string{`^(?P[a-zA-Z0-9]+)\.regex.com$`}), + Policy: oneFactor, + }). + WithRule(schema.ACLRule{ + DomainsRegex: createSliceRegexRule(s.T(), []string{`^group-(?P[a-zA-Z0-9]+)\.regex.com$`}), + Policy: twoFactor, + }). + WithRule(schema.ACLRule{ + DomainsRegex: createSliceRegexRule(s.T(), []string{`^.*\.(one|two).com$`}), + Policy: twoFactor, + }). + Build() + + tester.CheckAuthorizations(s.T(), John, "https://john.regex.com", "GET", OneFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://john.regex.com", "GET", Denied) + tester.CheckAuthorizations(s.T(), Bob, "https://public.example.com", "GET", Bypass) + tester.CheckAuthorizations(s.T(), AnonymousUser, "https://public.example2.com", "GET", OneFactor) + tester.CheckAuthorizations(s.T(), John, "https://group-dev.regex.com", "GET", TwoFactor) + tester.CheckAuthorizations(s.T(), Bob, "https://group-dev.regex.com", "GET", Denied) + + assert.Equal(s.T(), "^.*\\.example.com$", tester.configuration.AccessControl.Rules[0].DomainsRegex[0].String()) + assert.Equal(s.T(), "domain_regex:^.*\\.example.com$", tester.rules[0].Domains[0].String()) + + assert.Equal(s.T(), "^.*\\.example2.com$", tester.configuration.AccessControl.Rules[1].DomainsRegex[0].String()) + assert.Equal(s.T(), "domain_regex:^.*\\.example2.com$", tester.rules[1].Domains[0].String()) + + assert.Equal(s.T(), "^(?P[a-zA-Z0-9]+)\\.regex.com$", tester.configuration.AccessControl.Rules[2].DomainsRegex[0].String()) + assert.Equal(s.T(), "domain_regex(subexp):^(?P[a-zA-Z0-9]+)\\.regex.com$", tester.rules[2].Domains[0].String()) + + assert.Equal(s.T(), "^group-(?P[a-zA-Z0-9]+)\\.regex.com$", tester.configuration.AccessControl.Rules[3].DomainsRegex[0].String()) + assert.Equal(s.T(), "domain_regex(subexp):^group-(?P[a-zA-Z0-9]+)\\.regex.com$", tester.rules[3].Domains[0].String()) + + assert.Equal(s.T(), "^.*\\.(one|two).com$", tester.configuration.AccessControl.Rules[4].DomainsRegex[0].String()) + assert.Equal(s.T(), "domain_regex:^.*\\.(one|two).com$", tester.rules[4].Domains[0].String()) +} + func (s *AuthorizerSuite) TestShouldCheckUserMatching() { tester := NewAuthorizerBuilder(). WithDefaultPolicy(deny). @@ -367,17 +480,25 @@ func (s *AuthorizerSuite) TestShouldCheckMethodMatching() { } func (s *AuthorizerSuite) TestShouldCheckResourceMatching() { + createSliceRegexRule := func(t *testing.T, rules []string) []regexp.Regexp { + result, err := stringSliceToRegexpSlice(rules) + + require.NoError(t, err) + + return result + } + tester := NewAuthorizerBuilder(). WithDefaultPolicy(deny). WithRule(schema.ACLRule{ Domains: []string{"resource.example.com"}, Policy: bypass, - Resources: []string{"^/bypass/[a-z]+$", "^/$", "embedded"}, + Resources: createSliceRegexRule(s.T(), []string{"^/bypass/[a-z]+$", "^/$", "embedded"}), }). WithRule(schema.ACLRule{ Domains: []string{"resource.example.com"}, Policy: oneFactor, - Resources: []string{"^/one_factor/[a-z]+$"}, + Resources: createSliceRegexRule(s.T(), []string{"^/one_factor/[a-z]+$"}), }). Build() @@ -424,17 +545,25 @@ func (s *AuthorizerSuite) TestShouldMatchAnyDomainIfBlank() { } func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() { + createSliceRegexRule := func(t *testing.T, rules []string) []regexp.Regexp { + result, err := stringSliceToRegexpSlice(rules) + + require.NoError(t, err) + + return result + } + tester := NewAuthorizerBuilder(). WithDefaultPolicy(deny). WithRule(schema.ACLRule{ Domains: []string{"public.example.com"}, - Resources: []string{"^/admin/.*$"}, + Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}), Subjects: [][]string{{"group:admins"}}, Policy: oneFactor, }). WithRule(schema.ACLRule{ Domains: []string{"public.example.com"}, - Resources: []string{"^/admin/.*$"}, + Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}), Policy: deny, }). WithRule(schema.ACLRule{ @@ -443,13 +572,13 @@ func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() { }). WithRule(schema.ACLRule{ Domains: []string{"public2.example.com"}, - Resources: []string{"^/admin/.*$"}, + Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}), Subjects: [][]string{{"group:admins"}}, Policy: bypass, }). WithRule(schema.ACLRule{ Domains: []string{"public2.example.com"}, - Resources: []string{"^/admin/.*$"}, + Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}), Policy: deny, }). WithRule(schema.ACLRule{ diff --git a/internal/authorization/const.go b/internal/authorization/const.go index e5023a2f3..b2e5ca5ac 100644 --- a/internal/authorization/const.go +++ b/internal/authorization/const.go @@ -14,12 +14,26 @@ const ( Denied Level = iota ) -const userPrefix = "user:" -const groupPrefix = "group:" +const ( + prefixUser = "user:" + prefixGroup = "group:" +) -const bypass = "bypass" -const oneFactor = "one_factor" -const twoFactor = "two_factor" -const deny = "deny" +const ( + bypass = "bypass" + oneFactor = "one_factor" + twoFactor = "two_factor" + deny = "deny" +) + +const ( + subexpNameUser = "User" + subexpNameGroup = "Group" +) + +var ( + // IdentitySubexpNames is a list of valid regex subexp names. + IdentitySubexpNames = []string{subexpNameUser, subexpNameGroup} +) const traceFmtACLHitMiss = "ACL %s Position %d for subject %s and object %s (Method %s)" diff --git a/internal/authorization/types.go b/internal/authorization/types.go index 7dcc3a131..aa4579351 100644 --- a/internal/authorization/types.go +++ b/internal/authorization/types.go @@ -7,6 +7,17 @@ import ( "strings" ) +// SubjectMatcher is a matcher that takes a subject. +type SubjectMatcher interface { + IsMatch(subject Subject) (match bool) +} + +// SubjectObjectMatcher is a matcher that takes both a subject and an object. +type SubjectObjectMatcher interface { + IsMatch(subject Subject, object Object) (match bool) + String() string +} + // Subject represents the identity of a user for the purposes of ACL matching. type Subject struct { Username string diff --git a/internal/authorization/types_test.go b/internal/authorization/types_test.go index be39a9225..0124c0f86 100644 --- a/internal/authorization/types_test.go +++ b/internal/authorization/types_test.go @@ -20,3 +20,16 @@ func TestShouldAppendQueryParamToURL(t *testing.T) { assert.Equal(t, "/api?type=none", object.Path) assert.Equal(t, "https", object.Scheme) } + +func TestShouldCreateNewObjectFromRaw(t *testing.T) { + targetURL, err := url.Parse("https://domain.example.com/api") + + require.NoError(t, err) + + object := NewObjectRaw(targetURL, []byte("GET")) + + assert.Equal(t, "domain.example.com", object.Domain) + assert.Equal(t, "GET", object.Method) + assert.Equal(t, "/api", object.Path) + assert.Equal(t, "https", object.Scheme) +} diff --git a/internal/authorization/util.go b/internal/authorization/util.go index ded829ab6..aedb6a9be 100644 --- a/internal/authorization/util.go +++ b/internal/authorization/util.go @@ -41,15 +41,28 @@ func LevelToPolicy(level Level) (policy string) { return deny } -func schemaSubjectToACLSubject(subjectRule string) (subject AccessControlSubject) { - if strings.HasPrefix(subjectRule, userPrefix) { - user := strings.Trim(subjectRule[len(userPrefix):], " ") +func stringSliceToRegexpSlice(strings []string) (regexps []regexp.Regexp, err error) { + for _, str := range strings { + pattern, err := regexp.Compile(str) + if err != nil { + return nil, err + } + + regexps = append(regexps, *pattern) + } + + return regexps, nil +} + +func schemaSubjectToACLSubject(subjectRule string) (subject SubjectMatcher) { + if strings.HasPrefix(subjectRule, prefixUser) { + user := strings.Trim(subjectRule[len(prefixUser):], " ") return AccessControlUser{Name: user} } - if strings.HasPrefix(subjectRule, groupPrefix) { - group := strings.Trim(subjectRule[len(groupPrefix):], " ") + if strings.HasPrefix(subjectRule, prefixGroup) { + group := strings.Trim(subjectRule[len(prefixGroup):], " ") return AccessControlGroup{Name: group} } @@ -57,35 +70,21 @@ func schemaSubjectToACLSubject(subjectRule string) (subject AccessControlSubject return nil } -func schemaDomainsToACL(domainRules []string) (domains []AccessControlDomain) { +func schemaDomainsToACL(domainRules []string, domainRegexRules []regexp.Regexp) (domains []SubjectObjectMatcher) { for _, domainRule := range domainRules { - domain := AccessControlDomain{} + domains = append(domains, NewAccessControlDomain(domainRule)) + } - domainRule = strings.ToLower(domainRule) - - switch { - case strings.HasPrefix(domainRule, "*."): - domain.Wildcard = true - domain.Name = domainRule[1:] - case strings.HasPrefix(domainRule, "{user}"): - domain.UserWildcard = true - domain.Name = domainRule[7:] - case strings.HasPrefix(domainRule, "{group}"): - domain.GroupWildcard = true - domain.Name = domainRule[8:] - default: - domain.Name = domainRule - } - - domains = append(domains, domain) + for _, domainRegexRule := range domainRegexRules { + domains = append(domains, NewAccessControlDomainRegex(domainRegexRule)) } return domains } -func schemaResourcesToACL(resourceRules []string) (resources []AccessControlResource) { +func schemaResourcesToACL(resourceRules []regexp.Regexp) (resources []AccessControlResource) { for _, resourceRule := range resourceRules { - resources = append(resources, AccessControlResource{regexp.MustCompile(resourceRule)}) + resources = append(resources, AccessControlResource{Pattern: resourceRule}) } return resources diff --git a/internal/authorization/util_test.go b/internal/authorization/util_test.go index 52354eaeb..7106beab6 100644 --- a/internal/authorization/util_test.go +++ b/internal/authorization/util_test.go @@ -185,6 +185,9 @@ func TestShouldParseACLNetworks(t *testing.T) { } func TestShouldReturnCorrectValidationLevel(t *testing.T) { + assert.False(t, IsAuthLevelSufficient(authentication.NotAuthenticated, Denied)) + assert.False(t, IsAuthLevelSufficient(authentication.OneFactor, Denied)) + assert.False(t, IsAuthLevelSufficient(authentication.TwoFactor, Denied)) assert.True(t, IsAuthLevelSufficient(authentication.NotAuthenticated, Bypass)) assert.True(t, IsAuthLevelSufficient(authentication.OneFactor, Bypass)) assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, Bypass)) diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index e03bbf241..abef31c09 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -366,6 +366,18 @@ access_control: - domain: public.example.com policy: bypass + ## Domain Regex examples. Generally we recommend just using a standard domain. + # - domain_regex: "^(?P\w+)\\.example\\.com$" + # policy: one_factor + # - domain_regex: "^(?P\w+)\\.example\\.com$" + # policy: one_factor + # - domain_regex: + # - "^appgroup-.*\\.example\\.com$" + # - "^appgroup2-.*\\.example\\.com$" + # policy: one_factor + # - domain_regex: "^.*\\.example.com$" + # policy: two_factor + - domain: secure.example.com policy: one_factor ## Network based rule, if not provided any network matches. @@ -397,21 +409,21 @@ access_control: ## Rules applied to 'dev' group - domain: dev.example.com resources: - - "^/groups/dev/.*$" + - '^/groups/dev/.*$' subject: "group:dev" policy: two_factor ## Rules applied to user 'john' - domain: dev.example.com resources: - - "^/users/john/.*$" + - '^/users/john/.*$' subject: "user:john" policy: two_factor ## Rules applied to user 'harry' - domain: dev.example.com resources: - - "^/users/harry/.*$" + - '^/users/harry/.*$' subject: "user:harry" policy: two_factor @@ -421,7 +433,7 @@ access_control: policy: two_factor - domain: "dev.example.com" resources: - - "^/users/bob/.*$" + - '^/users/bob/.*$' subject: "user:bob" policy: two_factor diff --git a/internal/configuration/decode_hooks.go b/internal/configuration/decode_hooks.go index 2bba3f9be..4258513a4 100644 --- a/internal/configuration/decode_hooks.go +++ b/internal/configuration/decode_hooks.go @@ -5,6 +5,7 @@ import ( "net/mail" "net/url" "reflect" + "regexp" "time" "github.com/mitchellh/mapstructure" @@ -137,3 +138,37 @@ func ToTimeDurationHookFunc() mapstructure.DecodeHookFuncType { return duration, nil } } + +// StringToRegexpFunc decodes a string into a *regexp.Regexp or regexp.Regexp. +func StringToRegexpFunc() mapstructure.DecodeHookFuncType { + return func(f reflect.Type, t reflect.Type, data interface{}) (value interface{}, err error) { + var ptr bool + + if f.Kind() != reflect.String { + return data, nil + } + + ptr = t.Kind() == reflect.Ptr + + typeRegexp := reflect.TypeOf(regexp.Regexp{}) + + if ptr && t.Elem() != typeRegexp { + return data, nil + } else if !ptr && t != typeRegexp { + return data, nil + } + + regexStr := data.(string) + + pattern, err := regexp.Compile(regexStr) + if err != nil { + return nil, fmt.Errorf("could not parse '%s' as regexp: %w", regexStr, err) + } + + if ptr { + return pattern, nil + } + + return *pattern, nil + } +} diff --git a/internal/configuration/decode_hooks_test.go b/internal/configuration/decode_hooks_test.go index c0ced716d..8adeca21d 100644 --- a/internal/configuration/decode_hooks_test.go +++ b/internal/configuration/decode_hooks_test.go @@ -3,10 +3,12 @@ package configuration import ( "net/url" "reflect" + "regexp" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStringToURLHookFunc_ShouldNotParseStrings(t *testing.T) { @@ -417,3 +419,121 @@ func TestToTimeDurationHookFunc_ShouldParse_FromZero(t *testing.T) { assert.NoError(t, err) assert.Equal(t, &expected, result) } + +func TestStringToRegexpFunc(t *testing.T) { + wantRegexp := func(regexpStr string) regexp.Regexp { + pattern := regexp.MustCompile(regexpStr) + + return *pattern + } + + testCases := []struct { + desc string + have interface{} + want regexp.Regexp + wantPtr *regexp.Regexp + wantErr string + wantGroupNames []string + }{ + { + desc: "should not parse regexp with open paren", + have: "hello(test one two", + wantErr: "could not parse 'hello(test one two' as regexp: error parsing regexp: missing closing ): `hello(test one two`", + }, + { + desc: "should parse valid regex", + have: "^(api|admin)$", + want: wantRegexp("^(api|admin)$"), + wantPtr: regexp.MustCompile("^(api|admin)$"), + }, + { + desc: "should parse valid regex with named groups", + have: "^(?Papi|admin)$", + want: wantRegexp("^(?Papi|admin)$"), + wantPtr: regexp.MustCompile("^(?Papi|admin)$"), + wantGroupNames: []string{"area"}, + }, + } + + hook := StringToRegexpFunc() + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + t.Run("non-ptr", func(t *testing.T) { + result, err := hook(reflect.TypeOf(tc.have), reflect.TypeOf(tc.want), tc.have) + if tc.wantErr != "" { + assert.EqualError(t, err, tc.wantErr) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + require.Equal(t, tc.want, result) + + resultRegexp := result.(regexp.Regexp) + + actualNames := resultRegexp.SubexpNames() + var names []string + + for _, name := range actualNames { + if name != "" { + names = append(names, name) + } + } + + if len(tc.wantGroupNames) != 0 { + t.Run("must_have_all_expected_subexp_group_names", func(t *testing.T) { + for _, name := range tc.wantGroupNames { + assert.Contains(t, names, name) + } + }) + t.Run("must_not_have_unexpected_subexp_group_names", func(t *testing.T) { + for _, name := range names { + assert.Contains(t, tc.wantGroupNames, name) + } + }) + } else { + t.Run("must_have_no_subexp_group_names", func(t *testing.T) { + assert.Len(t, names, 0) + }) + } + } + }) + t.Run("ptr", func(t *testing.T) { + result, err := hook(reflect.TypeOf(tc.have), reflect.TypeOf(tc.wantPtr), tc.have) + if tc.wantErr != "" { + assert.EqualError(t, err, tc.wantErr) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.wantPtr, result) + + resultRegexp := result.(*regexp.Regexp) + + actualNames := resultRegexp.SubexpNames() + var names []string + + for _, name := range actualNames { + if name != "" { + names = append(names, name) + } + } + + if len(tc.wantGroupNames) != 0 { + t.Run("must_have_all_expected_names", func(t *testing.T) { + for _, name := range tc.wantGroupNames { + assert.Contains(t, names, name) + } + }) + + t.Run("must_not_have_unexpected_names", func(t *testing.T) { + for _, name := range names { + assert.Contains(t, tc.wantGroupNames, name) + } + }) + } else { + assert.Len(t, names, 0) + } + } + }) + }) + } +} diff --git a/internal/configuration/provider.go b/internal/configuration/provider.go index 98758ab7a..7ea0c5281 100644 --- a/internal/configuration/provider.go +++ b/internal/configuration/provider.go @@ -47,6 +47,7 @@ func unmarshal(ko *koanf.Koanf, val *schema.StructValidator, path string, o inte StringToMailAddressHookFunc(), ToTimeDurationHookFunc(), StringToURLHookFunc(), + StringToRegexpFunc(), ), Metadata: nil, Result: o, diff --git a/internal/configuration/provider_test.go b/internal/configuration/provider_test.go index b770000f3..94a678c43 100644 --- a/internal/configuration/provider_test.go +++ b/internal/configuration/provider_test.go @@ -298,6 +298,54 @@ func TestShouldDecodeSMTPSenderWithName(t *testing.T) { assert.Equal(t, schema.RememberMeDisabled, config.Session.RememberMeDuration) } +func TestShouldParseRegex(t *testing.T) { + testReset() + + val := schema.NewStructValidator() + keys, config, err := Load(val, NewDefaultSources([]string{"./test_resources/config_domain_regex.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + + validator.ValidateKeys(keys, DefaultEnvPrefix, val) + + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) + + validator.ValidateRules(config, val) + + assert.Len(t, val.Errors(), 0) + assert.Len(t, val.Warnings(), 0) + + assert.Len(t, config.AccessControl.Rules[0].DomainsRegex[0].SubexpNames(), 2) + assert.Equal(t, "", config.AccessControl.Rules[0].DomainsRegex[0].SubexpNames()[0]) + assert.Equal(t, "", config.AccessControl.Rules[0].DomainsRegex[0].SubexpNames()[1]) + + assert.Len(t, config.AccessControl.Rules[1].DomainsRegex[0].SubexpNames(), 2) + assert.Equal(t, "", config.AccessControl.Rules[1].DomainsRegex[0].SubexpNames()[0]) + assert.Equal(t, "User", config.AccessControl.Rules[1].DomainsRegex[0].SubexpNames()[1]) + + assert.Len(t, config.AccessControl.Rules[2].DomainsRegex[0].SubexpNames(), 3) + assert.Equal(t, "", config.AccessControl.Rules[2].DomainsRegex[0].SubexpNames()[0]) + assert.Equal(t, "User", config.AccessControl.Rules[2].DomainsRegex[0].SubexpNames()[1]) + assert.Equal(t, "Group", config.AccessControl.Rules[2].DomainsRegex[0].SubexpNames()[2]) +} + +func TestShouldErrOnParseInvalidRegex(t *testing.T) { + testReset() + + val := schema.NewStructValidator() + keys, _, err := Load(val, NewDefaultSources([]string{"./test_resources/config_domain_bad_regex.yml"}, DefaultEnvPrefix, DefaultEnvDelimiter)...) + + assert.NoError(t, err) + + validator.ValidateKeys(keys, DefaultEnvPrefix, val) + + require.Len(t, val.Errors(), 1) + assert.Len(t, val.Warnings(), 0) + + assert.EqualError(t, val.Errors()[0], "error occurred during unmarshalling configuration: 1 error(s) decoding:\n\n* error decoding 'access_control.rules[0].domain_regex[0]': could not parse '^\\K(public|public2).example.com$' as regexp: error parsing regexp: invalid escape sequence: `\\K`") +} + func TestShouldNotReadConfigurationOnFSAccessDenied(t *testing.T) { if runtime.GOOS == constWindows { t.Skip("skipping test due to being on windows") diff --git a/internal/configuration/schema/access_control.go b/internal/configuration/schema/access_control.go index b251e85ed..7b12fe129 100644 --- a/internal/configuration/schema/access_control.go +++ b/internal/configuration/schema/access_control.go @@ -1,5 +1,9 @@ package schema +import ( + "regexp" +) + // AccessControlConfiguration represents the configuration related to ACLs. type AccessControlConfiguration struct { DefaultPolicy string `koanf:"default_policy"` @@ -7,20 +11,21 @@ type AccessControlConfiguration struct { Rules []ACLRule `koanf:"rules"` } -// ACLNetwork represents one ACL network group entry; "weak" coerces a single value into slice. +// ACLNetwork represents one ACL network group entry. type ACLNetwork struct { Name string `koanf:"name"` Networks []string `koanf:"networks"` } -// ACLRule represents one ACL rule entry; "weak" coerces a single value into slice. +// ACLRule represents one ACL rule entry. type ACLRule struct { - Domains []string `koanf:"domain"` - Policy string `koanf:"policy"` - Subjects [][]string `koanf:"subject"` - Networks []string `koanf:"networks"` - Resources []string `koanf:"resources"` - Methods []string `koanf:"methods"` + Domains []string `koanf:"domain"` + DomainsRegex []regexp.Regexp `koanf:"domain_regex"` + Policy string `koanf:"policy"` + Subjects [][]string `koanf:"subject"` + Networks []string `koanf:"networks"` + Resources []regexp.Regexp `koanf:"resources"` + Methods []string `koanf:"methods"` } // DefaultACLNetwork represents the default configuration related to access control network group configuration. diff --git a/internal/configuration/test_resources/config_domain_bad_regex.yml b/internal/configuration/test_resources/config_domain_bad_regex.yml new file mode 100644 index 000000000..37147b8d5 --- /dev/null +++ b/internal/configuration/test_resources/config_domain_bad_regex.yml @@ -0,0 +1,126 @@ +--- +default_redirection_url: https://home.example.com:8080/ + +server: + host: 127.0.0.1 + port: 9091 + +log: + level: debug + +totp: + issuer: authelia.com + +duo_api: + hostname: api-123456789.example.com + integration_key: ABCDEF + +authentication_backend: + ldap: + url: ldap://127.0.0.1 + base_dn: dc=example,dc=com + username_attribute: uid + additional_users_dn: ou=users + users_filter: (&({username_attribute}={input})(objectCategory=person)(objectClass=user)) + additional_groups_dn: ou=groups + groups_filter: (&(member={dn})(objectClass=groupOfNames)) + group_name_attribute: cn + mail_attribute: mail + user: cn=admin,dc=example,dc=com + +access_control: + default_policy: deny + + rules: + # Rules applied to everyone + - domain_regex: ^\K(public|public2).example.com$ + policy: bypass + + - domain: secure.example.com + policy: one_factor + # Network based rule, if not provided any network matches. + networks: + - 192.168.1.0/24 + - domain: secure.example.com + policy: two_factor + + - domain: [singlefactor.example.com, onefactor.example.com] + policy: one_factor + + # Rules applied to 'admins' group + - domain: "mx2.mail.example.com" + subject: "group:admins" + policy: deny + - domain: "*.example.com" + subject: "group:admins" + policy: two_factor + + # Rules applied to 'dev' group + - domain: dev.example.com + resources: + - "^/groups/dev/.*$" + subject: "group:dev" + policy: two_factor + + # Rules applied to user 'john' + - domain: dev.example.com + resources: + - "^/users/john/.*$" + subject: "user:john" + policy: two_factor + + # Rules applied to 'dev' group and user 'john' + - domain: dev.example.com + resources: + - "^/deny-all.*$" + subject: ["group:dev", "user:john"] + policy: deny + + # Rules applied to user 'harry' + - domain: dev.example.com + resources: + - "^/users/harry/.*$" + subject: "user:harry" + policy: two_factor + + # Rules applied to user 'bob' + - domain: "*.mail.example.com" + subject: "user:bob" + policy: two_factor + - domain: "dev.example.com" + resources: + - "^/users/bob/.*$" + subject: "user:bob" + policy: two_factor + +session: + name: authelia_session + expiration: 3600000 # 1 hour + inactivity: 300000 # 5 minutes + domain: example.com + redis: + host: 127.0.0.1 + port: 6379 + high_availability: + sentinel_name: test + +regulation: + max_retries: 3 + find_time: 120 + ban_time: 300 + +storage: + mysql: + host: 127.0.0.1 + port: 3306 + database: authelia + username: authelia + +notifier: + smtp: + username: test + host: 127.0.0.1 + port: 1025 + sender: admin@example.com + disable_require_tls: true +... diff --git a/internal/configuration/test_resources/config_domain_regex.yml b/internal/configuration/test_resources/config_domain_regex.yml new file mode 100644 index 000000000..24f194f8b --- /dev/null +++ b/internal/configuration/test_resources/config_domain_regex.yml @@ -0,0 +1,132 @@ +--- +default_redirection_url: https://home.example.com:8080/ + +server: + host: 127.0.0.1 + port: 9091 + +log: + level: debug + +totp: + issuer: authelia.com + +duo_api: + hostname: api-123456789.example.com + integration_key: ABCDEF + +authentication_backend: + ldap: + url: ldap://127.0.0.1 + base_dn: dc=example,dc=com + username_attribute: uid + additional_users_dn: ou=users + users_filter: (&({username_attribute}={input})(objectCategory=person)(objectClass=user)) + additional_groups_dn: ou=groups + groups_filter: (&(member={dn})(objectClass=groupOfNames)) + group_name_attribute: cn + mail_attribute: mail + user: cn=admin,dc=example,dc=com + +access_control: + default_policy: deny + + rules: + # Rules applied to everyone + - domain_regex: ^(public|public2).example.com$ + policy: bypass + + - domain_regex: ^portfolio-(?P[a-zA-Z0-9]+).example.com$ + policy: one_factor + + - domain_regex: ^portfolio-(?P[a-zA-Z0-9]+)-(?P[a-zA-Z0-9]+).example.com$ + policy: one_factor + + - domain: secure.example.com + policy: one_factor + # Network based rule, if not provided any network matches. + networks: + - 192.168.1.0/24 + - domain: secure.example.com + policy: two_factor + + - domain: [singlefactor.example.com, onefactor.example.com] + policy: one_factor + + # Rules applied to 'admins' group + - domain: "mx2.mail.example.com" + subject: "group:admins" + policy: deny + - domain: "*.example.com" + subject: "group:admins" + policy: two_factor + + # Rules applied to 'dev' group + - domain: dev.example.com + resources: + - "^/groups/dev/.*$" + subject: "group:dev" + policy: two_factor + + # Rules applied to user 'john' + - domain: dev.example.com + resources: + - "^/users/john/.*$" + subject: "user:john" + policy: two_factor + + # Rules applied to 'dev' group and user 'john' + - domain: dev.example.com + resources: + - "^/deny-all.*$" + subject: ["group:dev", "user:john"] + policy: deny + + # Rules applied to user 'harry' + - domain: dev.example.com + resources: + - "^/users/harry/.*$" + subject: "user:harry" + policy: two_factor + + # Rules applied to user 'bob' + - domain: "*.mail.example.com" + subject: "user:bob" + policy: two_factor + - domain: "dev.example.com" + resources: + - "^/users/bob/.*$" + subject: "user:bob" + policy: two_factor + +session: + name: authelia_session + expiration: 3600000 # 1 hour + inactivity: 300000 # 5 minutes + domain: example.com + redis: + host: 127.0.0.1 + port: 6379 + high_availability: + sentinel_name: test + +regulation: + max_retries: 3 + find_time: 120 + ban_time: 300 + +storage: + mysql: + host: 127.0.0.1 + port: 3306 + database: authelia + username: authelia + +notifier: + smtp: + username: test + host: 127.0.0.1 + port: 1025 + sender: admin@example.com + disable_require_tls: true +... diff --git a/internal/configuration/validator/access_control.go b/internal/configuration/validator/access_control.go index ec89b395a..3bd475cb2 100644 --- a/internal/configuration/validator/access_control.go +++ b/internal/configuration/validator/access_control.go @@ -3,9 +3,9 @@ package validator import ( "fmt" "net" - "regexp" "strings" + "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/utils" ) @@ -15,12 +15,6 @@ func IsPolicyValid(policy string) (isValid bool) { return utils.IsStringInSlice(policy, validACLRulePolicies) } -// IsResourceValid check if a resource is valid. -func IsResourceValid(resource string) (err error) { - _, err = regexp.Compile(resource) - return err -} - // IsSubjectValid check if a subject is valid. func IsSubjectValid(subject string) (isValid bool) { return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:") @@ -95,7 +89,7 @@ func ValidateRules(config *schema.Configuration, validator *schema.StructValidat for i, rule := range config.AccessControl.Rules { rulePosition := i + 1 - if len(rule.Domains) == 0 { + if len(rule.Domains)+len(rule.DomainsRegex) == 0 { validator.Push(fmt.Errorf(errFmtAccessControlRuleNoDomains, ruleDescriptor(rulePosition, rule))) } @@ -105,14 +99,25 @@ func ValidateRules(config *schema.Configuration, validator *schema.StructValidat validateNetworks(rulePosition, rule, config.AccessControl, validator) - validateResources(rulePosition, rule, validator) - validateSubjects(rulePosition, rule, validator) validateMethods(rulePosition, rule, validator) - if rule.Policy == policyBypass && len(rule.Subjects) != 0 { - validator.Push(fmt.Errorf(errAccessControlRuleBypassPolicyInvalidWithSubjects, ruleDescriptor(rulePosition, rule))) + if rule.Policy == policyBypass { + validateBypass(rulePosition, rule, validator) + } + } +} + +func validateBypass(rulePosition int, rule schema.ACLRule, validator *schema.StructValidator) { + if len(rule.Subjects) != 0 { + validator.Push(fmt.Errorf(errAccessControlRuleBypassPolicyInvalidWithSubjects, ruleDescriptor(rulePosition, rule))) + } + + for _, pattern := range rule.DomainsRegex { + if utils.IsStringSliceContainsAny(authorization.IdentitySubexpNames, pattern.SubexpNames()) { + validator.Push(fmt.Errorf(errAccessControlRuleBypassPolicyInvalidWithSubjectsWithGroupDomainRegex, ruleDescriptor(rulePosition, rule))) + return } } } @@ -127,14 +132,6 @@ func validateNetworks(rulePosition int, rule schema.ACLRule, config schema.Acces } } -func validateResources(rulePosition int, rule schema.ACLRule, validator *schema.StructValidator) { - for _, resource := range rule.Resources { - if err := IsResourceValid(resource); err != nil { - validator.Push(fmt.Errorf(errFmtAccessControlRuleResourceInvalid, ruleDescriptor(rulePosition, rule), resource, err)) - } - } -} - func validateSubjects(rulePosition int, rule schema.ACLRule, validator *schema.StructValidator) { for _, subjectRule := range rule.Subjects { for _, subject := range subjectRule { diff --git a/internal/configuration/validator/access_control_test.go b/internal/configuration/validator/access_control_test.go index 7e66864bf..3ce26fedf 100644 --- a/internal/configuration/validator/access_control_test.go +++ b/internal/configuration/validator/access_control_test.go @@ -2,6 +2,7 @@ package validator import ( "fmt" + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -35,6 +36,31 @@ func (suite *AccessControl) TestShouldValidateCompleteConfiguration() { suite.Assert().False(suite.validator.HasErrors()) } +func (suite *AccessControl) TestShouldValidateEitherDomainsOrDomainsRegex() { + domainsRegex := regexp.MustCompile(`^abc.example.com$`) + + suite.config.AccessControl.Rules = []schema.ACLRule{ + { + Domains: []string{"abc.example.com"}, + Policy: "bypass", + }, + { + DomainsRegex: []regexp.Regexp{*domainsRegex}, + Policy: "bypass", + }, + { + Policy: "bypass", + }, + } + + ValidateRules(suite.config, suite.validator) + + suite.Assert().False(suite.validator.HasWarnings()) + suite.Require().Len(suite.validator.Errors(), 1) + + assert.EqualError(suite.T(), suite.validator.Errors()[0], "access control: rule #3: rule is invalid: must have the option 'domain' or 'domain_regex' configured") +} + func (suite *AccessControl) TestShouldRaiseErrorInvalidDefaultPolicy() { suite.config.AccessControl.DefaultPolicy = testInvalidPolicy @@ -99,9 +125,9 @@ func (suite *AccessControl) TestShouldRaiseErrorsWithEmptyRules() { suite.Assert().False(suite.validator.HasWarnings()) suite.Require().Len(suite.validator.Errors(), 4) - suite.Assert().EqualError(suite.validator.Errors()[0], "access control: rule #1: rule is invalid: must have the option 'domain' configured") + suite.Assert().EqualError(suite.validator.Errors()[0], "access control: rule #1: rule is invalid: must have the option 'domain' or 'domain_regex' configured") suite.Assert().EqualError(suite.validator.Errors()[1], "access control: rule #1: rule 'policy' option '' is invalid: must be one of 'deny', 'two_factor', 'one_factor' or 'bypass'") - suite.Assert().EqualError(suite.validator.Errors()[2], "access control: rule #2: rule is invalid: must have the option 'domain' configured") + suite.Assert().EqualError(suite.validator.Errors()[2], "access control: rule #2: rule is invalid: must have the option 'domain' or 'domain_regex' configured") suite.Assert().EqualError(suite.validator.Errors()[3], "access control: rule #2: rule 'policy' option 'wrong' is invalid: must be one of 'deny', 'two_factor', 'one_factor' or 'bypass'") } @@ -155,23 +181,6 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidMethod() { suite.Assert().EqualError(suite.validator.Errors()[0], "access control: rule #1 (domain 'public.example.com'): 'methods' option 'HOP' is invalid: must be one of 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'TRACE', 'CONNECT', 'OPTIONS', 'COPY', 'LOCK', 'MKCOL', 'MOVE', 'PROPFIND', 'PROPPATCH', 'UNLOCK'") } -func (suite *AccessControl) TestShouldRaiseErrorInvalidResource() { - suite.config.AccessControl.Rules = []schema.ACLRule{ - { - Domains: []string{"public.example.com"}, - Policy: "bypass", - Resources: []string{"^/(api.*"}, - }, - } - - ValidateRules(suite.config, suite.validator) - - suite.Assert().False(suite.validator.HasWarnings()) - suite.Require().Len(suite.validator.Errors(), 1) - - suite.Assert().EqualError(suite.validator.Errors()[0], "access control: rule #1 (domain 'public.example.com'): 'resources' option '^/(api.*' is invalid: error parsing regexp: missing closing ): `^/(api.*`") -} - func (suite *AccessControl) TestShouldRaiseErrorInvalidSubject() { domains := []string{"public.example.com"} subjects := [][]string{{"invalid"}} diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 95ba84f9d..f2fe7920c 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -169,16 +169,17 @@ const ( errFmtAccessControlWarnNoRulesDefaultPolicy = "access control: no rules have been specified so the " + "'default_policy' of '%s' is going to be applied to all requests" errFmtAccessControlRuleNoDomains = "access control: rule %s: rule is invalid: must have the option " + - "'domain' configured" + "'domain' or 'domain_regex' configured" errFmtAccessControlRuleInvalidPolicy = "access control: rule %s: rule 'policy' option '%s' " + "is invalid: must be one of 'deny', 'two_factor', 'one_factor' or 'bypass'" errAccessControlRuleBypassPolicyInvalidWithSubjects = "access control: rule %s: 'policy' option 'bypass' is " + "not supported when 'subject' option is configured: see " + "https://www.authelia.com/docs/configuration/access-control.html#bypass" + errAccessControlRuleBypassPolicyInvalidWithSubjectsWithGroupDomainRegex = "access control: rule %s: 'policy' option 'bypass' is " + + "not supported when 'domain_regex' option contains the user or group named matches. For more information see: " + + "https://www.authelia.com/docs/configuration/access-control.html#bypass-and-user-identity" errFmtAccessControlRuleNetworksInvalid = "access control: rule %s: the network '%s' is not a " + "valid Group Name, IP, or CIDR notation" - errFmtAccessControlRuleResourceInvalid = "access control: rule %s: 'resources' option '%s' is " + - "invalid: %w" errFmtAccessControlRuleSubjectInvalid = "access control: rule %s: 'subject' option '%s' is " + "invalid: must start with 'user:' or 'group:'" errFmtAccessControlRuleMethodInvalid = "access control: rule %s: 'methods' option '%s' is " + @@ -327,6 +328,7 @@ var ValidKeys = []string{ "access_control.networks[].networks", "access_control.rules", "access_control.rules[].domain", + "access_control.rules[].domain_regex", "access_control.rules[].methods", "access_control.rules[].networks", "access_control.rules[].subject",