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.
pull/2785/head^2
James Elliott 2022-04-01 22:38:49 +11:00 committed by GitHub
parent 0116506330
commit 3c1bb3ec19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 970 additions and 143 deletions

View File

@ -366,6 +366,18 @@ access_control:
- domain: public.example.com - domain: public.example.com
policy: bypass policy: bypass
## Domain Regex examples. Generally we recommend just using a standard domain.
# - domain_regex: "^(?P<User>\w+)\\.example\\.com$"
# policy: one_factor
# - domain_regex: "^(?P<Group>\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 - domain: secure.example.com
policy: one_factor policy: one_factor
## Network based rule, if not provided any network matches. ## Network based rule, if not provided any network matches.
@ -397,21 +409,21 @@ access_control:
## Rules applied to 'dev' group ## Rules applied to 'dev' group
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/groups/dev/.*$" - '^/groups/dev/.*$'
subject: "group:dev" subject: "group:dev"
policy: two_factor policy: two_factor
## Rules applied to user 'john' ## Rules applied to user 'john'
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/users/john/.*$" - '^/users/john/.*$'
subject: "user:john" subject: "user:john"
policy: two_factor policy: two_factor
## Rules applied to user 'harry' ## Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/users/harry/.*$" - '^/users/harry/.*$'
subject: "user:harry" subject: "user:harry"
policy: two_factor policy: two_factor
@ -421,7 +433,7 @@ access_control:
policy: two_factor policy: two_factor
- domain: "dev.example.com" - domain: "dev.example.com"
resources: resources:
- "^/users/bob/.*$" - '^/users/bob/.*$'
subject: "user:bob" subject: "user:bob"
policy: two_factor policy: two_factor

View File

@ -23,6 +23,7 @@ access_control:
rules: rules:
- domain: public.example.com - domain: public.example.com
domain_regex: "^\d+\\.public.example.com$"
policy: one_factor policy: one_factor
networks: networks:
- internal - internal
@ -35,7 +36,7 @@ access_control:
- GET - GET
- HEAD - HEAD
resources: resources:
- "^/api.*" - '^/api.*'
``` ```
## Options ## Options
@ -96,6 +97,7 @@ A rule defines two primary things:
The criteria is broken into several parts: The criteria is broken into several parts:
* [domain](#domain): domain or list of domains targeted by the request. * [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. * [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. * [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. * [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 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. understanding of how rules apply is also recommended.
#### policy
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
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 #### domain
<div markdown="1"> <div markdown="1">
type: list(string) type: list(string)
@ -126,8 +117,12 @@ required: yes
{: .label .label-config .label-red } {: .label .label-config .label-red }
</div> </div>
_**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 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. 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: 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"`. 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 * 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 example `{user}.example.com` would match `fred.example.com` if the user logged in was named `fred`. _**Warning:** this is
considering refactoring this to just be regex which would likely allow many additional possibilities.* 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 * 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 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 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 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 policy: bypass
``` ```
### domain_regex
<div markdown="1">
type: list(string)
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
_**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<User>\w+)\\.example\\.com$"
- "^group-(?P<Group>\w+)\\.example\\.com$"
policy: one_factor
```
#### policy
<div markdown="1">
type: string
{: .label .label-config .label-purple }
required: yes
{: .label .label-config .label-red }
</div>
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 ### subject
<div markdown="1"> <div markdown="1">
type: list(list(string)) 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 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.* [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 This criteria matches identifying characteristics about the subject. This is either user's name or the name of the
belongs to. This allows you to effectively control exactly what each user is authorized to access or to specifically group a user belongs to. This allows you to effectively control exactly what each user is authorized to access or to
require two-factor authentication to specific users. Subjects are prefixed with either `user:` or `group:` to identify specifically require two-factor authentication to specific users. Subjects are prefixed with either `user:` or `group:`
which part of the identity to check. 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 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. `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 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.* 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 As this is a regex string you will either need to use single quotes or need to double-escape certain portions of the
with escaping the expressions. Failure to do so may prevent Authelia from starting. It's technically optional but will 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
likely save you a lot of time if you do it for all resource rules. 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: Examples:
@ -370,16 +430,13 @@ access_control:
- domain: app.example.com - domain: app.example.com
policy: bypass policy: bypass
resources: resources:
- "^/api([/?].*)?$" - '^/api([/?].*)?$'
``` ```
## Policies ## Policies
With **Authelia** you can define a list of rules that are going to be evaluated in The policy of the first matching rule in the configured list decides the policy applied to the request, if no rule
sequential order when authorization is delegated to Authelia. matches the request the [default_policy](#default_policy) is applied.
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.
### deny ### 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 that includes a [subject](#subject) restriction because the minimum authentication level required to obtain information
about the subject is [one_factor](#one_factor). 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 ### one_factor
This policy requires the user at minimum complete 1FA successfully (username and password). This means if they have 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 - domain: dev.example.com
resources: resources:
- "^/groups/dev/.*$" - '^/groups/dev/.*$'
subject: "group:dev" subject: "group:dev"
policy: two_factor policy: two_factor
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/users/john/.*$" - '^/users/john/.*$'
subject: subject:
- ["group:dev", "user:john"] - ["group:dev", "user:john"]
- "group:admins" - "group:admins"

View File

@ -2,11 +2,35 @@ package authorization
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/authelia/authelia/v4/internal/utils" "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. // AccessControlDomain represents an ACL domain.
type AccessControlDomain struct { type AccessControlDomain struct {
Name string Name string
@ -16,17 +40,88 @@ type AccessControlDomain struct {
} }
// IsMatch returns true if the ACL domain matches the object domain. // 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 { switch {
case acd.Wildcard: case acl.Wildcard:
return strings.HasSuffix(object.Domain, acd.Name) return strings.HasSuffix(object.Domain, acl.Name)
case acd.UserWildcard: case acl.UserWildcard:
return object.Domain == fmt.Sprintf("%s.%s", subject.Username, acd.Name) return object.Domain == fmt.Sprintf("%s.%s", subject.Username, acl.Name)
case acd.GroupWildcard: case acl.GroupWildcard:
prefix, suffix := domainToPrefixSuffix(object.Domain) prefix, suffix := domainToPrefixSuffix(object.Domain)
return suffix == acd.Name && utils.IsStringInSliceFold(prefix, subject.Groups) return suffix == acl.Name && utils.IsStringInSliceFold(prefix, subject.Groups)
default: 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())
}

View File

@ -6,7 +6,7 @@ import (
// AccessControlResource represents an ACL resource. // AccessControlResource represents an ACL resource.
type AccessControlResource struct { type AccessControlResource struct {
Pattern *regexp.Regexp Pattern regexp.Regexp
} }
// IsMatch returns true if the ACL resource match the object path. // IsMatch returns true if the ACL resource match the object path.

View File

@ -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 { func NewAccessControlRule(pos int, rule schema.ACLRule, networksMap map[string][]*net.IPNet, networksCacheMap map[string]*net.IPNet) *AccessControlRule {
return &AccessControlRule{ return &AccessControlRule{
Position: pos, Position: pos,
Domains: schemaDomainsToACL(rule.Domains), Domains: schemaDomainsToACL(rule.Domains, rule.DomainsRegex),
Resources: schemaResourcesToACL(rule.Resources), Resources: schemaResourcesToACL(rule.Resources),
Methods: schemaMethodsToACL(rule.Methods), Methods: schemaMethodsToACL(rule.Methods),
Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap), 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. // AccessControlRule controls and represents an ACL internally.
type AccessControlRule struct { type AccessControlRule struct {
Position int Position int
Domains []AccessControlDomain Domains []SubjectObjectMatcher
Resources []AccessControlResource Resources []AccessControlResource
Methods []string Methods []string
Networks []*net.IPNet Networks []*net.IPNet

View File

@ -4,14 +4,9 @@ import (
"github.com/authelia/authelia/v4/internal/utils" "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. // AccessControlSubjects represents an ACL subject.
type AccessControlSubjects struct { type AccessControlSubjects struct {
Subjects []AccessControlSubject Subjects []SubjectMatcher
} }
// AddSubject appends to the AccessControlSubjects based on a subject rule string. // AddSubject appends to the AccessControlSubjects based on a subject rule string.

View File

@ -3,6 +3,7 @@ package authorization
import ( import (
"net" "net"
"net/url" "net/url"
"regexp"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -230,6 +231,118 @@ func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() {
tester.CheckAuthorizations(s.T(), John, "https://public.example.com/", "GET", TwoFactor) 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<User>[a-zA-Z0-9]+)\.regex.com$`}),
Policy: oneFactor,
}).
WithRule(schema.ACLRule{
DomainsRegex: createSliceRegexRule(s.T(), []string{`^group-(?P<Group>[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<User>[a-zA-Z0-9]+)\\.regex.com$", tester.configuration.AccessControl.Rules[2].DomainsRegex[0].String())
assert.Equal(s.T(), "domain_regex(subexp):^(?P<User>[a-zA-Z0-9]+)\\.regex.com$", tester.rules[2].Domains[0].String())
assert.Equal(s.T(), "^group-(?P<Group>[a-zA-Z0-9]+)\\.regex.com$", tester.configuration.AccessControl.Rules[3].DomainsRegex[0].String())
assert.Equal(s.T(), "domain_regex(subexp):^group-(?P<Group>[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() { func (s *AuthorizerSuite) TestShouldCheckUserMatching() {
tester := NewAuthorizerBuilder(). tester := NewAuthorizerBuilder().
WithDefaultPolicy(deny). WithDefaultPolicy(deny).
@ -367,17 +480,25 @@ func (s *AuthorizerSuite) TestShouldCheckMethodMatching() {
} }
func (s *AuthorizerSuite) TestShouldCheckResourceMatching() { 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(). tester := NewAuthorizerBuilder().
WithDefaultPolicy(deny). WithDefaultPolicy(deny).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"resource.example.com"}, Domains: []string{"resource.example.com"},
Policy: bypass, Policy: bypass,
Resources: []string{"^/bypass/[a-z]+$", "^/$", "embedded"}, Resources: createSliceRegexRule(s.T(), []string{"^/bypass/[a-z]+$", "^/$", "embedded"}),
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"resource.example.com"}, Domains: []string{"resource.example.com"},
Policy: oneFactor, Policy: oneFactor,
Resources: []string{"^/one_factor/[a-z]+$"}, Resources: createSliceRegexRule(s.T(), []string{"^/one_factor/[a-z]+$"}),
}). }).
Build() Build()
@ -424,17 +545,25 @@ func (s *AuthorizerSuite) TestShouldMatchAnyDomainIfBlank() {
} }
func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() { 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(). tester := NewAuthorizerBuilder().
WithDefaultPolicy(deny). WithDefaultPolicy(deny).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"public.example.com"}, Domains: []string{"public.example.com"},
Resources: []string{"^/admin/.*$"}, Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}),
Subjects: [][]string{{"group:admins"}}, Subjects: [][]string{{"group:admins"}},
Policy: oneFactor, Policy: oneFactor,
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"public.example.com"}, Domains: []string{"public.example.com"},
Resources: []string{"^/admin/.*$"}, Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}),
Policy: deny, Policy: deny,
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
@ -443,13 +572,13 @@ func (s *AuthorizerSuite) TestShouldMatchResourceWithSubjectRules() {
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"public2.example.com"}, Domains: []string{"public2.example.com"},
Resources: []string{"^/admin/.*$"}, Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}),
Subjects: [][]string{{"group:admins"}}, Subjects: [][]string{{"group:admins"}},
Policy: bypass, Policy: bypass,
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{
Domains: []string{"public2.example.com"}, Domains: []string{"public2.example.com"},
Resources: []string{"^/admin/.*$"}, Resources: createSliceRegexRule(s.T(), []string{"^/admin/.*$"}),
Policy: deny, Policy: deny,
}). }).
WithRule(schema.ACLRule{ WithRule(schema.ACLRule{

View File

@ -14,12 +14,26 @@ const (
Denied Level = iota Denied Level = iota
) )
const userPrefix = "user:" const (
const groupPrefix = "group:" prefixUser = "user:"
prefixGroup = "group:"
)
const bypass = "bypass" const (
const oneFactor = "one_factor" bypass = "bypass"
const twoFactor = "two_factor" oneFactor = "one_factor"
const deny = "deny" 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)" const traceFmtACLHitMiss = "ACL %s Position %d for subject %s and object %s (Method %s)"

View File

@ -7,6 +7,17 @@ import (
"strings" "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. // Subject represents the identity of a user for the purposes of ACL matching.
type Subject struct { type Subject struct {
Username string Username string

View File

@ -20,3 +20,16 @@ func TestShouldAppendQueryParamToURL(t *testing.T) {
assert.Equal(t, "/api?type=none", object.Path) assert.Equal(t, "/api?type=none", object.Path)
assert.Equal(t, "https", object.Scheme) 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)
}

View File

@ -41,15 +41,28 @@ func LevelToPolicy(level Level) (policy string) {
return deny return deny
} }
func schemaSubjectToACLSubject(subjectRule string) (subject AccessControlSubject) { func stringSliceToRegexpSlice(strings []string) (regexps []regexp.Regexp, err error) {
if strings.HasPrefix(subjectRule, userPrefix) { for _, str := range strings {
user := strings.Trim(subjectRule[len(userPrefix):], " ") 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} return AccessControlUser{Name: user}
} }
if strings.HasPrefix(subjectRule, groupPrefix) { if strings.HasPrefix(subjectRule, prefixGroup) {
group := strings.Trim(subjectRule[len(groupPrefix):], " ") group := strings.Trim(subjectRule[len(prefixGroup):], " ")
return AccessControlGroup{Name: group} return AccessControlGroup{Name: group}
} }
@ -57,35 +70,21 @@ func schemaSubjectToACLSubject(subjectRule string) (subject AccessControlSubject
return nil return nil
} }
func schemaDomainsToACL(domainRules []string) (domains []AccessControlDomain) { func schemaDomainsToACL(domainRules []string, domainRegexRules []regexp.Regexp) (domains []SubjectObjectMatcher) {
for _, domainRule := range domainRules { 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 return domains
} }
func schemaResourcesToACL(resourceRules []string) (resources []AccessControlResource) { func schemaResourcesToACL(resourceRules []regexp.Regexp) (resources []AccessControlResource) {
for _, resourceRule := range resourceRules { for _, resourceRule := range resourceRules {
resources = append(resources, AccessControlResource{regexp.MustCompile(resourceRule)}) resources = append(resources, AccessControlResource{Pattern: resourceRule})
} }
return resources return resources

View File

@ -185,6 +185,9 @@ func TestShouldParseACLNetworks(t *testing.T) {
} }
func TestShouldReturnCorrectValidationLevel(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.NotAuthenticated, Bypass))
assert.True(t, IsAuthLevelSufficient(authentication.OneFactor, Bypass)) assert.True(t, IsAuthLevelSufficient(authentication.OneFactor, Bypass))
assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, Bypass)) assert.True(t, IsAuthLevelSufficient(authentication.TwoFactor, Bypass))

View File

@ -366,6 +366,18 @@ access_control:
- domain: public.example.com - domain: public.example.com
policy: bypass policy: bypass
## Domain Regex examples. Generally we recommend just using a standard domain.
# - domain_regex: "^(?P<User>\w+)\\.example\\.com$"
# policy: one_factor
# - domain_regex: "^(?P<Group>\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 - domain: secure.example.com
policy: one_factor policy: one_factor
## Network based rule, if not provided any network matches. ## Network based rule, if not provided any network matches.
@ -397,21 +409,21 @@ access_control:
## Rules applied to 'dev' group ## Rules applied to 'dev' group
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/groups/dev/.*$" - '^/groups/dev/.*$'
subject: "group:dev" subject: "group:dev"
policy: two_factor policy: two_factor
## Rules applied to user 'john' ## Rules applied to user 'john'
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/users/john/.*$" - '^/users/john/.*$'
subject: "user:john" subject: "user:john"
policy: two_factor policy: two_factor
## Rules applied to user 'harry' ## Rules applied to user 'harry'
- domain: dev.example.com - domain: dev.example.com
resources: resources:
- "^/users/harry/.*$" - '^/users/harry/.*$'
subject: "user:harry" subject: "user:harry"
policy: two_factor policy: two_factor
@ -421,7 +433,7 @@ access_control:
policy: two_factor policy: two_factor
- domain: "dev.example.com" - domain: "dev.example.com"
resources: resources:
- "^/users/bob/.*$" - '^/users/bob/.*$'
subject: "user:bob" subject: "user:bob"
policy: two_factor policy: two_factor

View File

@ -5,6 +5,7 @@ import (
"net/mail" "net/mail"
"net/url" "net/url"
"reflect" "reflect"
"regexp"
"time" "time"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
@ -137,3 +138,37 @@ func ToTimeDurationHookFunc() mapstructure.DecodeHookFuncType {
return duration, nil 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
}
}

View File

@ -3,10 +3,12 @@ package configuration
import ( import (
"net/url" "net/url"
"reflect" "reflect"
"regexp"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestStringToURLHookFunc_ShouldNotParseStrings(t *testing.T) { func TestStringToURLHookFunc_ShouldNotParseStrings(t *testing.T) {
@ -417,3 +419,121 @@ func TestToTimeDurationHookFunc_ShouldParse_FromZero(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, &expected, result) 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: "^(?P<area>api|admin)$",
want: wantRegexp("^(?P<area>api|admin)$"),
wantPtr: regexp.MustCompile("^(?P<area>api|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)
}
}
})
})
}
}

View File

@ -47,6 +47,7 @@ func unmarshal(ko *koanf.Koanf, val *schema.StructValidator, path string, o inte
StringToMailAddressHookFunc(), StringToMailAddressHookFunc(),
ToTimeDurationHookFunc(), ToTimeDurationHookFunc(),
StringToURLHookFunc(), StringToURLHookFunc(),
StringToRegexpFunc(),
), ),
Metadata: nil, Metadata: nil,
Result: o, Result: o,

View File

@ -298,6 +298,54 @@ func TestShouldDecodeSMTPSenderWithName(t *testing.T) {
assert.Equal(t, schema.RememberMeDisabled, config.Session.RememberMeDuration) 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) { func TestShouldNotReadConfigurationOnFSAccessDenied(t *testing.T) {
if runtime.GOOS == constWindows { if runtime.GOOS == constWindows {
t.Skip("skipping test due to being on windows") t.Skip("skipping test due to being on windows")

View File

@ -1,5 +1,9 @@
package schema package schema
import (
"regexp"
)
// AccessControlConfiguration represents the configuration related to ACLs. // AccessControlConfiguration represents the configuration related to ACLs.
type AccessControlConfiguration struct { type AccessControlConfiguration struct {
DefaultPolicy string `koanf:"default_policy"` DefaultPolicy string `koanf:"default_policy"`
@ -7,19 +11,20 @@ type AccessControlConfiguration struct {
Rules []ACLRule `koanf:"rules"` 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 { type ACLNetwork struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Networks []string `koanf:"networks"` 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 { type ACLRule struct {
Domains []string `koanf:"domain"` Domains []string `koanf:"domain"`
DomainsRegex []regexp.Regexp `koanf:"domain_regex"`
Policy string `koanf:"policy"` Policy string `koanf:"policy"`
Subjects [][]string `koanf:"subject"` Subjects [][]string `koanf:"subject"`
Networks []string `koanf:"networks"` Networks []string `koanf:"networks"`
Resources []string `koanf:"resources"` Resources []regexp.Regexp `koanf:"resources"`
Methods []string `koanf:"methods"` Methods []string `koanf:"methods"`
} }

View File

@ -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
...

View File

@ -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<User>[a-zA-Z0-9]+).example.com$
policy: one_factor
- domain_regex: ^portfolio-(?P<User>[a-zA-Z0-9]+)-(?P<Group>[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
...

View File

@ -3,9 +3,9 @@ package validator
import ( import (
"fmt" "fmt"
"net" "net"
"regexp"
"strings" "strings"
"github.com/authelia/authelia/v4/internal/authorization"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/utils" "github.com/authelia/authelia/v4/internal/utils"
) )
@ -15,12 +15,6 @@ func IsPolicyValid(policy string) (isValid bool) {
return utils.IsStringInSlice(policy, validACLRulePolicies) 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. // IsSubjectValid check if a subject is valid.
func IsSubjectValid(subject string) (isValid bool) { func IsSubjectValid(subject string) (isValid bool) {
return subject == "" || strings.HasPrefix(subject, "user:") || strings.HasPrefix(subject, "group:") 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 { for i, rule := range config.AccessControl.Rules {
rulePosition := i + 1 rulePosition := i + 1
if len(rule.Domains) == 0 { if len(rule.Domains)+len(rule.DomainsRegex) == 0 {
validator.Push(fmt.Errorf(errFmtAccessControlRuleNoDomains, ruleDescriptor(rulePosition, rule))) validator.Push(fmt.Errorf(errFmtAccessControlRuleNoDomains, ruleDescriptor(rulePosition, rule)))
} }
@ -105,15 +99,26 @@ func ValidateRules(config *schema.Configuration, validator *schema.StructValidat
validateNetworks(rulePosition, rule, config.AccessControl, validator) validateNetworks(rulePosition, rule, config.AccessControl, validator)
validateResources(rulePosition, rule, validator)
validateSubjects(rulePosition, rule, validator) validateSubjects(rulePosition, rule, validator)
validateMethods(rulePosition, rule, validator) validateMethods(rulePosition, rule, validator)
if rule.Policy == policyBypass && len(rule.Subjects) != 0 { 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))) 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) { func validateSubjects(rulePosition int, rule schema.ACLRule, validator *schema.StructValidator) {
for _, subjectRule := range rule.Subjects { for _, subjectRule := range rule.Subjects {
for _, subject := range subjectRule { for _, subject := range subjectRule {

View File

@ -2,6 +2,7 @@ package validator
import ( import (
"fmt" "fmt"
"regexp"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -35,6 +36,31 @@ func (suite *AccessControl) TestShouldValidateCompleteConfiguration() {
suite.Assert().False(suite.validator.HasErrors()) 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() { func (suite *AccessControl) TestShouldRaiseErrorInvalidDefaultPolicy() {
suite.config.AccessControl.DefaultPolicy = testInvalidPolicy suite.config.AccessControl.DefaultPolicy = testInvalidPolicy
@ -99,9 +125,9 @@ func (suite *AccessControl) TestShouldRaiseErrorsWithEmptyRules() {
suite.Assert().False(suite.validator.HasWarnings()) suite.Assert().False(suite.validator.HasWarnings())
suite.Require().Len(suite.validator.Errors(), 4) 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()[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'") 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'") 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() { func (suite *AccessControl) TestShouldRaiseErrorInvalidSubject() {
domains := []string{"public.example.com"} domains := []string{"public.example.com"}
subjects := [][]string{{"invalid"}} subjects := [][]string{{"invalid"}}

View File

@ -169,16 +169,17 @@ const (
errFmtAccessControlWarnNoRulesDefaultPolicy = "access control: no rules have been specified so the " + errFmtAccessControlWarnNoRulesDefaultPolicy = "access control: no rules have been specified so the " +
"'default_policy' of '%s' is going to be applied to all requests" "'default_policy' of '%s' is going to be applied to all requests"
errFmtAccessControlRuleNoDomains = "access control: rule %s: rule is invalid: must have the option " + 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' " + errFmtAccessControlRuleInvalidPolicy = "access control: rule %s: rule 'policy' option '%s' " +
"is invalid: must be one of 'deny', 'two_factor', 'one_factor' or 'bypass'" "is invalid: must be one of 'deny', 'two_factor', 'one_factor' or 'bypass'"
errAccessControlRuleBypassPolicyInvalidWithSubjects = "access control: rule %s: 'policy' option 'bypass' is " + errAccessControlRuleBypassPolicyInvalidWithSubjects = "access control: rule %s: 'policy' option 'bypass' is " +
"not supported when 'subject' option is configured: see " + "not supported when 'subject' option is configured: see " +
"https://www.authelia.com/docs/configuration/access-control.html#bypass" "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 " + errFmtAccessControlRuleNetworksInvalid = "access control: rule %s: the network '%s' is not a " +
"valid Group Name, IP, or CIDR notation" "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 " + errFmtAccessControlRuleSubjectInvalid = "access control: rule %s: 'subject' option '%s' is " +
"invalid: must start with 'user:' or 'group:'" "invalid: must start with 'user:' or 'group:'"
errFmtAccessControlRuleMethodInvalid = "access control: rule %s: 'methods' option '%s' is " + errFmtAccessControlRuleMethodInvalid = "access control: rule %s: 'methods' option '%s' is " +
@ -327,6 +328,7 @@ var ValidKeys = []string{
"access_control.networks[].networks", "access_control.networks[].networks",
"access_control.rules", "access_control.rules",
"access_control.rules[].domain", "access_control.rules[].domain",
"access_control.rules[].domain_regex",
"access_control.rules[].methods", "access_control.rules[].methods",
"access_control.rules[].networks", "access_control.rules[].networks",
"access_control.rules[].subject", "access_control.rules[].subject",