feat(authorization): query parameter filtering (#3990)

This allows for advanced filtering of the query parameters in ACL's.

Closes #2708
pull/3671/head^2
James Elliott 2022-10-19 14:09:22 +11:00 committed by GitHub
parent 46ae5b2bf3
commit 52102eea8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 784 additions and 44 deletions

View File

@ -201,9 +201,18 @@ func containsType(needle reflect.Type, haystack []reflect.Type) (contains bool)
return false return false
} }
//nolint:gocyclo
func readTags(prefix string, t reflect.Type) (tags []string) { func readTags(prefix string, t reflect.Type) (tags []string) {
tags = make([]string, 0) tags = make([]string, 0)
if t.Kind() != reflect.Struct {
if t.Kind() == reflect.Slice {
tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, "", true), t.Elem())...)
}
return
}
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {
field := t.Field(i) field := t.Field(i)
@ -223,13 +232,16 @@ func readTags(prefix string, t reflect.Type) (tags []string) {
continue continue
} }
case reflect.Slice: case reflect.Slice:
if field.Type.Elem().Kind() == reflect.Struct { switch field.Type.Elem().Kind() {
case reflect.Struct:
if !containsType(field.Type.Elem(), decodedTypes) { if !containsType(field.Type.Elem(), decodedTypes) {
tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false)) tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false))
tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...) tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...)
continue continue
} }
case reflect.Slice:
tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...)
} }
case reflect.Ptr: case reflect.Ptr:
switch field.Type.Elem().Kind() { switch field.Type.Elem().Kind() {
@ -268,6 +280,10 @@ func getKeyNameFromTagAndPrefix(prefix, name string, slice bool) string {
} }
if slice { if slice {
if name == "" {
return fmt.Sprintf("%s[]", prefix)
}
return fmt.Sprintf("%s.%s[]", prefix, nameParts[0]) return fmt.Sprintf("%s.%s[]", prefix, nameParts[0])
} }

View File

@ -42,6 +42,17 @@ access_control:
- HEAD - HEAD
resources: resources:
- '^/api.*' - '^/api.*'
query:
- - operator: 'present'
key: 'secure'
- operator: 'absent'
key: 'insecure'
- - operator: 'pattern'
key: 'token'
value: '^(abc123|zyx789)$'
- operator: 'not pattern'
key: 'random'
value: '^(1|2)$'
``` ```
## Options ## Options
@ -416,6 +427,61 @@ access_control:
- '^/api([/?].*)?$' - '^/api([/?].*)?$'
``` ```
#### query
{{< confkey type="list(list(object))" required="no" >}}
The query criteria is an advanced criteria which can allow configuration of rules that match specific query argument
keys against various rules. It's recommended to use [resources](#resources) rules instead for basic needs.
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.
Additionally each level of these lists does not have to be explicitly defined.
##### key
{{< confkey type="string" required="yes" >}}
The query argument key to check.
##### value
{{< confkey type="string" required="situational" >}}
The value to match against. This is required unless the operator is `absent` or `present`. It's recommended this value
is always quoted as per the examples.
##### operator
{{< confkey type="string" required="situational" >}}
The rule operator for this rule. Valid operators can be found in the
[Rule Operators](../../reference/guides/rule-operators.md#operators) reference guide.
If [key](#key) and [value](#value) are specified this defaults to `equal`, otherwise if [key](#key) is specified it
defaults to `present`.
##### Examples
```yaml
access_control:
rules:
- domain: app.example.com
policy: bypass
query:
- - operator: 'present'
key: 'secure'
- operator: 'absent'
key: 'insecure'
- - operator: 'pattern'
key: 'token'
value: '^(abc123|zyx789)$'
- operator: 'not pattern'
key: 'random'
value: '^(1|2)$'
```
## Policies ## Policies
The policy of the first matching rule in the configured list decides the policy applied to the request, if no rule The policy of the first matching rule in the configured list decides the policy applied to the request, if no rule

View File

@ -2,7 +2,7 @@
title: "Synology DSM" title: "Synology DSM"
description: "Integrating Synology DSM with the Authelia OpenID Connect Provider." description: "Integrating Synology DSM with the Authelia OpenID Connect Provider."
lead: "" lead: ""
date: 2022-06-15T17:51:47+10:00 date: 2022-10-18T21:22:13+11:00
draft: false draft: false
images: [] images: []
menu: menu:

View File

@ -0,0 +1,134 @@
---
title: "Access Control Rule Guide"
description: "A reference guide on access control rule operators"
lead: "This section contains a reference guide on access control rule operators."
date: 2022-09-09T15:44:23+10:00
draft: false
images: []
menu:
reference:
parent: "guides"
weight: 220
toc: true
---
## Operators
Rule operators are effectively words which alter the behaviour of particular access control rules. The following table
is a guide on their use.
| Operator | Effect |
|:-------------:|:--------------------------------------------------------------:|
| `equal` | Matches when the item value is equal to the provided value |
| `not equal` | Matches when the item value is not equal to the provided value |
| `present` | Matches when the item is present with any value |
| `absent` | Matches when the item is not present at all |
| `pattern` | Matches when the item matches the regex pattern |
| `not pattern` | Matches when the item doesn't match the regex pattern |
## Multi-level Logical Criteria
Criteria which is described as multi-level logical criteria indicates that it is a list of lists. The first level i.e.
the list least indented to the right will be referred to the `OR-list`, and the list most indented to the right will be
referred to the `AND-list`.
The OR-list matches if any of the criteria from it's AND-list's matches; in other words, a *__logical OR__*. The
AND-list matches if all of it's criteria matches the given request; in other words, a *__logical AND__*.
In addition to these rules, if the AND-list only needs one item, it can be represented without the second level.
### Examples
#### List of Lists
The following examples show various abstract examples to express a rule that matches either c, or a AND b;
i.e `(a AND b) OR (c)`. In relation to access control rules all of these should be treated the same. This format should
not be used for the configuration item type `list(list(object))`, see [List of List Objects](#list-of-list-objects)
instead.
##### Fully Expressed
```yaml
rule:
- - 'a'
- 'b'
- - 'c'
```
##### Omitted Level
```yaml
rule:
- - 'a'
- 'b'
- 'c'
```
##### Compact
```yaml
rule:
- ['a', 'b']
- ['c']
```
##### Compact with Omitted Level
```yaml
rule:
- ['a', 'b']
- 'c'
```
##### Super Compact
```yaml
rule: [['a', 'b'], ['c']]
```
#### List of List Objects
The following examples show various abstract examples that mirror the above rules however the AND-list is a list of
objects where the key is named `value`. This format should only be used for the configuration item type
`list(list(object))`, see [List of Lists](#list-of-lists) if you're not looking for a `list(list(object))`
##### Fully Expressed
```yaml
rule:
- - value: 'a'
- value: 'b'
- - value: 'c'
```
##### Omitted Level
```yaml
rule:
- - 'a'
- 'b'
- value: 'c'
```
##### Compact
```yaml
rule:
- ['a', 'b']
- ['c']
```
##### Compact with Omitted Level
```yaml
rule:
- ['a', 'b']
- 'c'
```
##### Super Compact
```yaml
rule: [['a', 'b'], ['c']]
```

View File

@ -0,0 +1,119 @@
package authorization
import (
"fmt"
"regexp"
"github.com/authelia/authelia/v4/internal/configuration/schema"
)
// NewAccessControlQuery creates a new AccessControlQuery rule type.
func NewAccessControlQuery(config [][]schema.ACLQueryRule) (rules []AccessControlQuery) {
if len(config) == 0 {
return nil
}
for i := 0; i < len(config); i++ {
var rule []ObjectMatcher
for j := 0; j < len(config[i]); j++ {
subRule, err := NewAccessControlQueryObjectMatcher(config[i][j])
if err != nil {
continue
}
rule = append(rule, subRule)
}
rules = append(rules, AccessControlQuery{Rules: rule})
}
return rules
}
// AccessControlQuery represents an ACL query args rule.
type AccessControlQuery struct {
Rules []ObjectMatcher
}
// IsMatch returns true if this rule matches the object.
func (acq AccessControlQuery) IsMatch(object Object) (isMatch bool) {
for _, rule := range acq.Rules {
if !rule.IsMatch(object) {
return false
}
}
return true
}
// NewAccessControlQueryObjectMatcher creates a new ObjectMatcher rule type from a schema.ACLQueryRule.
func NewAccessControlQueryObjectMatcher(rule schema.ACLQueryRule) (matcher ObjectMatcher, err error) {
switch rule.Operator {
case operatorPresent, operatorAbsent:
return &AccessControlQueryMatcherPresent{key: rule.Key, present: rule.Operator == operatorPresent}, nil
case operatorEqual, operatorNotEqual:
if value, ok := rule.Value.(string); ok {
return &AccessControlQueryMatcherEqual{key: rule.Key, value: value, equal: rule.Operator == operatorEqual}, nil
} else {
return nil, fmt.Errorf("rule value is not a string and is instead %T", rule.Value)
}
case operatorPattern, operatorNotPattern:
if pattern, ok := rule.Value.(*regexp.Regexp); ok {
return &AccessControlQueryMatcherPattern{key: rule.Key, pattern: pattern, match: rule.Operator == operatorPattern}, nil
} else {
return nil, fmt.Errorf("rule value is not a *regexp.Regexp and is instead %T", rule.Value)
}
default:
return nil, fmt.Errorf("invalid operator: %s", rule.Operator)
}
}
// AccessControlQueryMatcherEqual is a rule type that checks the equality of a query parameter.
type AccessControlQueryMatcherEqual struct {
key, value string
equal bool
}
// IsMatch returns true if this rule matches the object.
func (acl AccessControlQueryMatcherEqual) IsMatch(object Object) (isMatch bool) {
switch {
case acl.equal:
return object.URL.Query().Get(acl.key) == acl.value
default:
return object.URL.Query().Get(acl.key) != acl.value
}
}
// AccessControlQueryMatcherPresent is a rule type that checks the presence of a query parameter.
type AccessControlQueryMatcherPresent struct {
key string
present bool
}
// IsMatch returns true if this rule matches the object.
func (acl AccessControlQueryMatcherPresent) IsMatch(object Object) (isMatch bool) {
switch {
case acl.present:
return object.URL.Query().Has(acl.key)
default:
return !object.URL.Query().Has(acl.key)
}
}
// AccessControlQueryMatcherPattern is a rule type that checks a query parameter against regex.
type AccessControlQueryMatcherPattern struct {
key string
pattern *regexp.Regexp
match bool
}
// IsMatch returns true if this rule matches the object.
func (acl AccessControlQueryMatcherPattern) IsMatch(object Object) (isMatch bool) {
switch {
case acl.match:
return acl.pattern.MatchString(object.URL.Query().Get(acl.key))
default:
return !acl.pattern.MatchString(object.URL.Query().Get(acl.key))
}
}

View File

@ -22,6 +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 {
r := &AccessControlRule{ r := &AccessControlRule{
Position: pos, Position: pos,
Query: NewAccessControlQuery(rule.Query),
Methods: schemaMethodsToACL(rule.Methods), Methods: schemaMethodsToACL(rule.Methods),
Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap), Networks: schemaNetworksToACL(rule.Networks, networksMap, networksCacheMap),
Subjects: schemaSubjectsToACL(rule.Subjects), Subjects: schemaSubjectsToACL(rule.Subjects),
@ -46,6 +47,7 @@ type AccessControlRule struct {
Position int Position int
Domains []AccessControlDomain Domains []AccessControlDomain
Resources []AccessControlResource Resources []AccessControlResource
Query []AccessControlQuery
Methods []string Methods []string
Networks []*net.IPNet Networks []*net.IPNet
Subjects []AccessControlSubjects Subjects []AccessControlSubjects
@ -54,37 +56,42 @@ type AccessControlRule struct {
// IsMatch returns true if all elements of an AccessControlRule match the object and subject. // IsMatch returns true if all elements of an AccessControlRule match the object and subject.
func (acr *AccessControlRule) IsMatch(subject Subject, object Object) (match bool) { func (acr *AccessControlRule) IsMatch(subject Subject, object Object) (match bool) {
if !isMatchForDomains(subject, object, acr) { if !acr.MatchesDomains(subject, object) {
return false return false
} }
if !isMatchForResources(subject, object, acr) { if !acr.MatchesResources(subject, object) {
return false return false
} }
if !isMatchForMethods(object, acr) { if !acr.MatchesQuery(object) {
return false return false
} }
if !isMatchForNetworks(subject, acr) { if !acr.MatchesMethods(object) {
return false return false
} }
if !isMatchForSubjects(subject, acr) { if !acr.MatchesNetworks(subject) {
return false
}
if !acr.MatchesSubjects(subject) {
return false return false
} }
return true return true
} }
func isMatchForDomains(subject Subject, object Object, acl *AccessControlRule) (match bool) { // MatchesDomains returns true if the rule matches the domains.
func (acr *AccessControlRule) MatchesDomains(subject Subject, object Object) (matches bool) {
// If there are no domains in this rule then the domain condition is a match. // If there are no domains in this rule then the domain condition is a match.
if len(acl.Domains) == 0 { if len(acr.Domains) == 0 {
return true return true
} }
// Iterate over the domains until we find a match (return true) or until we exit the loop (return false). // Iterate over the domains until we find a match (return true) or until we exit the loop (return false).
for _, domain := range acl.Domains { for _, domain := range acr.Domains {
if domain.IsMatch(subject, object) { if domain.IsMatch(subject, object) {
return true return true
} }
@ -93,14 +100,15 @@ func isMatchForDomains(subject Subject, object Object, acl *AccessControlRule) (
return false return false
} }
func isMatchForResources(subject Subject, object Object, acl *AccessControlRule) (match bool) { // MatchesResources returns true if the rule matches the resources.
func (acr *AccessControlRule) MatchesResources(subject Subject, object Object) (matches bool) {
// If there are no resources in this rule then the resource condition is a match. // If there are no resources in this rule then the resource condition is a match.
if len(acl.Resources) == 0 { if len(acr.Resources) == 0 {
return true return true
} }
// Iterate over the resources until we find a match (return true) or until we exit the loop (return false). // Iterate over the resources until we find a match (return true) or until we exit the loop (return false).
for _, resource := range acl.Resources { for _, resource := range acr.Resources {
if resource.IsMatch(subject, object) { if resource.IsMatch(subject, object) {
return true return true
} }
@ -109,23 +117,42 @@ func isMatchForResources(subject Subject, object Object, acl *AccessControlRule)
return false return false
} }
func isMatchForMethods(object Object, acl *AccessControlRule) (match bool) { // MatchesQuery returns true if the rule matches the query arguments.
// If there are no methods in this rule then the method condition is a match. func (acr *AccessControlRule) MatchesQuery(object Object) (match bool) {
if len(acl.Methods) == 0 { // If there are no query rules in this rule then the query condition is a match.
if len(acr.Query) == 0 {
return true return true
} }
return utils.IsStringInSlice(object.Method, acl.Methods) // Iterate over the queries until we find a match (return true) or until we exit the loop (return false).
for _, query := range acr.Query {
if query.IsMatch(object) {
return true
}
} }
func isMatchForNetworks(subject Subject, acl *AccessControlRule) (match bool) { return false
}
// MatchesMethods returns true if the rule matches the method.
func (acr *AccessControlRule) MatchesMethods(object Object) (match bool) {
// If there are no methods in this rule then the method condition is a match.
if len(acr.Methods) == 0 {
return true
}
return utils.IsStringInSlice(object.Method, acr.Methods)
}
// MatchesNetworks returns true if the rule matches the networks.
func (acr *AccessControlRule) MatchesNetworks(subject Subject) (match bool) {
// If there are no networks in this rule then the network condition is a match. // If there are no networks in this rule then the network condition is a match.
if len(acl.Networks) == 0 { if len(acr.Networks) == 0 {
return true return true
} }
// Iterate over the networks until we find a match (return true) or until we exit the loop (return false). // Iterate over the networks until we find a match (return true) or until we exit the loop (return false).
for _, network := range acl.Networks { for _, network := range acr.Networks {
if network.Contains(subject.IP) { if network.Contains(subject.IP) {
return true return true
} }
@ -134,25 +161,26 @@ func isMatchForNetworks(subject Subject, acl *AccessControlRule) (match bool) {
return false return false
} }
// Same as isExactMatchForSubjects except it theoretically matches if subject is anonymous since they'd need to authenticate. // MatchesSubjects returns true if the rule matches the subjects.
func isMatchForSubjects(subject Subject, acl *AccessControlRule) (match bool) { func (acr *AccessControlRule) MatchesSubjects(subject Subject) (match bool) {
if subject.IsAnonymous() { if subject.IsAnonymous() {
return true return true
} }
return isExactMatchForSubjects(subject, acl) return acr.MatchesSubjectExact(subject)
} }
func isExactMatchForSubjects(subject Subject, acl *AccessControlRule) (match bool) { // MatchesSubjectExact returns true if the rule matches the subjects exactly.
func (acr *AccessControlRule) MatchesSubjectExact(subject Subject) (match bool) {
// If there are no subjects in this rule then the subject condition is a match. // If there are no subjects in this rule then the subject condition is a match.
if len(acl.Subjects) == 0 { if len(acr.Subjects) == 0 {
return true return true
} else if subject.IsAnonymous() { } else if subject.IsAnonymous() {
return false return false
} }
// Iterate over the subjects until we find a match (return true) or until we exit the loop (return false). // Iterate over the subjects until we find a match (return true) or until we exit the loop (return false).
for _, subjectRule := range acl.Subjects { for _, subjectRule := range acr.Subjects {
if subjectRule.IsMatch(subject) { if subjectRule.IsMatch(subject) {
return true return true
} }

View File

@ -88,12 +88,13 @@ func (p Authorizer) GetRuleMatchResults(subject Subject, object Object) (results
Rule: rule, Rule: rule,
Skipped: skipped, Skipped: skipped,
MatchDomain: isMatchForDomains(subject, object, rule), MatchDomain: rule.MatchesDomains(subject, object),
MatchResources: isMatchForResources(subject, object, rule), MatchResources: rule.MatchesResources(subject, object),
MatchMethods: isMatchForMethods(object, rule), MatchQuery: rule.MatchesQuery(object),
MatchNetworks: isMatchForNetworks(subject, rule), MatchMethods: rule.MatchesMethods(object),
MatchSubjects: isMatchForSubjects(subject, rule), MatchNetworks: rule.MatchesNetworks(subject),
MatchSubjectsExact: isExactMatchForSubjects(subject, rule), MatchSubjects: rule.MatchesSubjects(subject),
MatchSubjectsExact: rule.MatchesSubjectExact(subject),
} }
skipped = skipped || results[i].IsMatch() skipped = skipped || results[i].IsMatch()

View File

@ -208,6 +208,129 @@ func (s *AuthorizerSuite) TestShouldCheckFactorsPolicy() {
tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", "GET", Denied) tester.CheckAuthorizations(s.T(), UserWithGroups, "https://example.com/", "GET", Denied)
} }
func (s *AuthorizerSuite) TestShouldCheckQueryPolicy() {
tester := NewAuthorizerBuilder().
WithDefaultPolicy(deny).
WithRule(schema.ACLRule{
Domains: []string{"one.example.com"},
Query: [][]schema.ACLQueryRule{
{
{
Operator: operatorEqual,
Key: "test",
Value: "two",
},
{
Operator: operatorAbsent,
Key: "admin",
},
},
{
{
Operator: operatorPresent,
Key: "public",
},
},
},
Policy: oneFactor,
}).
WithRule(schema.ACLRule{
Domains: []string{"two.example.com"},
Query: [][]schema.ACLQueryRule{
{
{
Operator: operatorEqual,
Key: "test",
Value: "one",
},
},
{
{
Operator: operatorEqual,
Key: "test",
Value: "two",
},
},
},
Policy: twoFactor,
}).
WithRule(schema.ACLRule{
Domains: []string{"three.example.com"},
Query: [][]schema.ACLQueryRule{
{
{
Operator: operatorNotEqual,
Key: "test",
Value: "one",
},
{
Operator: operatorNotEqual,
Key: "test",
Value: "two",
},
},
},
Policy: twoFactor,
}).
WithRule(schema.ACLRule{
Domains: []string{"four.example.com"},
Query: [][]schema.ACLQueryRule{
{
{
Operator: operatorPattern,
Key: "test",
Value: regexp.MustCompile(`^(one|two|three)$`),
},
},
},
Policy: twoFactor,
}).
WithRule(schema.ACLRule{
Domains: []string{"five.example.com"},
Query: [][]schema.ACLQueryRule{
{
{
Operator: operatorNotPattern,
Key: "test",
Value: regexp.MustCompile(`^(one|two|three)$`),
},
},
},
Policy: twoFactor,
}).
Build()
testCases := []struct {
name, requestURL string
expected Level
}{
{"ShouldDenyAbsentRule", "https://one.example.com/?admin=true", Denied},
{"ShouldAllow1FAPresentRule", "https://one.example.com/?public=true", OneFactor},
{"ShouldAllow1FAEqualRule", "https://one.example.com/?test=two", OneFactor},
{"ShouldDenyAbsentRuleWithMatchingPresentRule", "https://one.example.com/?test=two&admin=true", Denied},
{"ShouldAllow2FARuleWithOneMatchingEqual", "https://two.example.com/?test=one&admin=true", TwoFactor},
{"ShouldAllow2FARuleWithAnotherMatchingEqual", "https://two.example.com/?test=two&admin=true", TwoFactor},
{"ShouldDenyRuleWithNotMatchingEqual", "https://two.example.com/?test=three&admin=true", Denied},
{"ShouldDenyRuleWithNotMatchingNotEqualAND1", "https://three.example.com/?test=one", Denied},
{"ShouldDenyRuleWithNotMatchingNotEqualAND2", "https://three.example.com/?test=two", Denied},
{"ShouldAllowRuleWithMatchingNotEqualAND", "https://three.example.com/?test=three", TwoFactor},
{"ShouldAllowRuleWithMatchingPatternOne", "https://four.example.com/?test=one", TwoFactor},
{"ShouldAllowRuleWithMatchingPatternTwo", "https://four.example.com/?test=two", TwoFactor},
{"ShouldAllowRuleWithMatchingPatternThree", "https://four.example.com/?test=three", TwoFactor},
{"ShouldDenyRuleWithNotMatchingPattern", "https://four.example.com/?test=five", Denied},
{"ShouldAllowRuleWithMatchingNotPattern", "https://five.example.com/?test=five", TwoFactor},
{"ShouldDenyRuleWithNotMatchingNotPatternOne", "https://five.example.com/?test=one", Denied},
{"ShouldDenyRuleWithNotMatchingNotPatternTwo", "https://five.example.com/?test=two", Denied},
{"ShouldDenyRuleWithNotMatchingNotPatternThree", "https://five.example.com/?test=three", Denied},
}
for _, tc := range testCases {
s.T().Run(tc.name, func(t *testing.T) {
tester.CheckAuthorizations(t, UserWithGroups, tc.requestURL, "GET", tc.expected)
})
}
}
func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() { func (s *AuthorizerSuite) TestShouldCheckRulePrecedence() {
tester := NewAuthorizerBuilder(). tester := NewAuthorizerBuilder().
WithDefaultPolicy(deny). WithDefaultPolicy(deny).

View File

@ -26,6 +26,15 @@ const (
deny = "deny" deny = "deny"
) )
const (
operatorPresent = "present"
operatorAbsent = "absent"
operatorEqual = "equal"
operatorNotEqual = "not equal"
operatorPattern = "pattern"
operatorNotPattern = "not pattern"
)
const ( const (
subexpNameUser = "User" subexpNameUser = "User"
subexpNameGroup = "Group" subexpNameGroup = "Group"

View File

@ -24,6 +24,11 @@ type SubjectObjectMatcher interface {
IsMatch(subject Subject, object Object) (match bool) IsMatch(subject Subject, object Object) (match bool)
} }
// ObjectMatcher is a matcher that takes an object.
type ObjectMatcher interface {
IsMatch(object Object) (match bool)
}
// 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
@ -43,7 +48,7 @@ func (s Subject) IsAnonymous() bool {
// Object represents a protected object for the purposes of ACL matching. // Object represents a protected object for the purposes of ACL matching.
type Object struct { type Object struct {
URL url.URL URL *url.URL
Domain string Domain string
Path string Path string
@ -63,7 +68,7 @@ func NewObjectRaw(targetURL *url.URL, method []byte) (object Object) {
// NewObject creates a new Object type from a URL and a method header. // NewObject creates a new Object type from a URL and a method header.
func NewObject(targetURL *url.URL, method string) (object Object) { func NewObject(targetURL *url.URL, method string) (object Object) {
return Object{ return Object{
URL: *targetURL, URL: targetURL,
Domain: targetURL.Hostname(), Domain: targetURL.Hostname(),
Path: utils.URLPathFullClean(targetURL), Path: utils.URLPathFullClean(targetURL),
Method: method, Method: method,
@ -78,6 +83,7 @@ type RuleMatchResult struct {
MatchDomain bool MatchDomain bool
MatchResources bool MatchResources bool
MatchQuery bool
MatchMethods bool MatchMethods bool
MatchNetworks bool MatchNetworks bool
MatchSubjects bool MatchSubjects bool

View File

@ -480,7 +480,7 @@ func cmdCryptoHashGetPassword(cmd *cobra.Command, args []string, useArgs, useRan
func hashReadPasswordWithPrompt(prompt string) (data []byte, err error) { func hashReadPasswordWithPrompt(prompt string) (data []byte, err error) {
fmt.Print(prompt) fmt.Print(prompt)
if data, err = term.ReadPassword(int(syscall.Stdin)); err != nil { //nolint:unconvert // Conversion required. if data, err = term.ReadPassword(int(syscall.Stdin)); err != nil { //nolint:unconvert,nolintlint
if err.Error() == "inappropriate ioctl for device" { if err.Error() == "inappropriate ioctl for device" {
return nil, fmt.Errorf("the terminal doesn't appear to be interactive either use the '--password' flag or use an interactive terminal: %w", err) return nil, fmt.Errorf("the terminal doesn't appear to be interactive either use the '--password' flag or use an interactive terminal: %w", err)
} }

View File

@ -26,6 +26,14 @@ type ACLRule struct {
Networks []string `koanf:"networks"` Networks []string `koanf:"networks"`
Resources []regexp.Regexp `koanf:"resources"` Resources []regexp.Regexp `koanf:"resources"`
Methods []string `koanf:"methods"` Methods []string `koanf:"methods"`
Query [][]ACLQueryRule `koanf:"query"`
}
// ACLQueryRule represents the ACL query criteria.
type ACLQueryRule struct {
Operator string `koanf:"operator"`
Key string `koanf:"key"`
Value any `koanf:"value"`
} }
// DefaultACLNetwork represents the default configuration related to access control network group configuration. // DefaultACLNetwork represents the default configuration related to access control network group configuration.

View File

@ -148,6 +148,10 @@ var Keys = []string{
"access_control.rules[].networks", "access_control.rules[].networks",
"access_control.rules[].resources", "access_control.rules[].resources",
"access_control.rules[].methods", "access_control.rules[].methods",
"access_control.rules[].query[][].operator",
"access_control.rules[].query[][].key",
"access_control.rules[].query[][].value",
"access_control.rules[].query",
"ntp.address", "ntp.address",
"ntp.version", "ntp.version",
"ntp.max_desync", "ntp.max_desync",

View File

@ -3,6 +3,7 @@ package validator
import ( import (
"fmt" "fmt"
"net" "net"
"regexp"
"strings" "strings"
"github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/authorization"
@ -103,6 +104,8 @@ func ValidateRules(config *schema.Configuration, validator *schema.StructValidat
validateMethods(rulePosition, rule, validator) validateMethods(rulePosition, rule, validator)
validateQuery(i, rule, config, validator)
if rule.Policy == policyBypass { if rule.Policy == policyBypass {
validateBypass(rulePosition, rule, validator) validateBypass(rulePosition, rule, validator)
} }
@ -149,3 +152,60 @@ func validateMethods(rulePosition int, rule schema.ACLRule, validator *schema.St
} }
} }
} }
//nolint:gocyclo
func validateQuery(i int, rule schema.ACLRule, config *schema.Configuration, validator *schema.StructValidator) {
for j := 0; j < len(config.AccessControl.Rules[i].Query); j++ {
for k := 0; k < len(config.AccessControl.Rules[i].Query[j]); k++ {
if config.AccessControl.Rules[i].Query[j][k].Operator == "" {
if config.AccessControl.Rules[i].Query[j][k].Key != "" {
switch config.AccessControl.Rules[i].Query[j][k].Value {
case "", nil:
config.AccessControl.Rules[i].Query[j][k].Operator = operatorPresent
default:
config.AccessControl.Rules[i].Query[j][k].Operator = operatorEqual
}
}
} else if !utils.IsStringInSliceFold(config.AccessControl.Rules[i].Query[j][k].Operator, validACLRuleOperators) {
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalid, ruleDescriptor(i+1, rule), config.AccessControl.Rules[i].Query[j][k].Operator, strings.Join(validACLRuleOperators, "', '")))
}
if config.AccessControl.Rules[i].Query[j][k].Key == "" {
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalidNoValue, ruleDescriptor(i+1, rule), "key"))
}
op := config.AccessControl.Rules[i].Query[j][k].Operator
if op == "" {
continue
}
switch v := config.AccessControl.Rules[i].Query[j][k].Value.(type) {
case nil:
if op != operatorAbsent && op != operatorPresent {
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalidNoValueOperator, ruleDescriptor(i+1, rule), "value", op))
}
case string:
switch op {
case operatorPresent, operatorAbsent:
if v != "" {
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalidValue, ruleDescriptor(i+1, rule), "value", op))
}
case operatorPattern, operatorNotPattern:
var (
pattern *regexp.Regexp
err error
)
if pattern, err = regexp.Compile(v); err != nil {
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalidValueParse, ruleDescriptor(i+1, rule), "value", err))
} else {
config.AccessControl.Rules[i].Query[j][k].Value = pattern
}
}
default:
validator.Push(fmt.Errorf(errFmtAccessControlRuleQueryInvalidValueType, ruleDescriptor(i+1, rule), v))
}
}
}
}

View File

@ -201,6 +201,149 @@ func (suite *AccessControl) TestShouldRaiseErrorInvalidSubject() {
suite.Assert().EqualError(suite.validator.Errors()[1], fmt.Sprintf(errAccessControlRuleBypassPolicyInvalidWithSubjects, ruleDescriptor(1, suite.config.AccessControl.Rules[0]))) suite.Assert().EqualError(suite.validator.Errors()[1], fmt.Sprintf(errAccessControlRuleBypassPolicyInvalidWithSubjects, ruleDescriptor(1, suite.config.AccessControl.Rules[0])))
} }
func (suite *AccessControl) TestShouldSetQueryDefaults() {
domains := []string{"public.example.com"}
suite.config.AccessControl.Rules = []schema.ACLRule{
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "", Key: "example"},
},
{
{Operator: "", Key: "example", Value: "test"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "pattern", Key: "a", Value: "^(x|y|z)$"},
},
},
},
}
ValidateRules(suite.config, suite.validator)
suite.Assert().Len(suite.validator.Warnings(), 0)
suite.Assert().Len(suite.validator.Errors(), 0)
suite.Assert().Equal("present", suite.config.AccessControl.Rules[0].Query[0][0].Operator)
suite.Assert().Equal("equal", suite.config.AccessControl.Rules[0].Query[1][0].Operator)
suite.Require().Len(suite.config.AccessControl.Rules, 2)
suite.Require().Len(suite.config.AccessControl.Rules[1].Query, 1)
suite.Require().Len(suite.config.AccessControl.Rules[1].Query[0], 1)
t := &regexp.Regexp{}
suite.Assert().IsType(t, suite.config.AccessControl.Rules[1].Query[0][0].Value)
}
func (suite *AccessControl) TestShouldErrorOnInvalidRulesQuery() {
domains := []string{"public.example.com"}
suite.config.AccessControl.Rules = []schema.ACLRule{
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "equal", Key: "example"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "present"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "present", Key: "a"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "absent", Key: "a"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "not", Key: "a", Value: "a"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "pattern", Key: "a", Value: "(bad pattern"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "present", Key: "a", Value: "not good"},
},
},
},
{
Domains: domains,
Policy: "bypass",
Query: [][]schema.ACLQueryRule{
{
{Operator: "present", Key: "a", Value: 5},
},
},
},
}
ValidateRules(suite.config, suite.validator)
suite.Assert().Len(suite.validator.Warnings(), 0)
suite.Require().Len(suite.validator.Errors(), 7)
suite.Assert().EqualError(suite.validator.Errors()[0], "access control: rule #1 (domain 'public.example.com'): 'query' option 'value' is invalid: must have a value when the operator is 'equal'")
suite.Assert().EqualError(suite.validator.Errors()[1], "access control: rule #2 (domain 'public.example.com'): 'query' option 'key' is invalid: must have a value")
suite.Assert().EqualError(suite.validator.Errors()[2], "access control: rule #5 (domain 'public.example.com'): 'query' option 'key' is invalid: must have a value")
suite.Assert().EqualError(suite.validator.Errors()[3], "access control: rule #6 (domain 'public.example.com'): 'query' option 'operator' with value 'not' is invalid: must be one of 'present', 'absent', 'equal', 'not equal', 'pattern', 'not pattern'")
suite.Assert().EqualError(suite.validator.Errors()[4], "access control: rule #7 (domain 'public.example.com'): 'query' option 'value' is invalid: error parsing regexp: missing closing ): `(bad pattern`")
suite.Assert().EqualError(suite.validator.Errors()[5], "access control: rule #8 (domain 'public.example.com'): 'query' option 'value' is invalid: must not have a value when the operator is 'present'")
suite.Assert().EqualError(suite.validator.Errors()[6], "access control: rule #9 (domain 'public.example.com'): 'query' option 'value' is invalid: expected type was string but got int")
}
func TestAccessControl(t *testing.T) { func TestAccessControl(t *testing.T) {
suite.Run(t, new(AccessControl)) suite.Run(t, new(AccessControl))
} }

View File

@ -211,6 +211,18 @@ const (
"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 " +
"invalid: must be one of '%s'" "invalid: must be one of '%s'"
errFmtAccessControlRuleQueryInvalid = "access control: rule %s: 'query' option 'operator' with value '%s' is " +
"invalid: must be one of '%s'"
errFmtAccessControlRuleQueryInvalidNoValue = "access control: rule %s: 'query' option '%s' is " +
"invalid: must have a value"
errFmtAccessControlRuleQueryInvalidNoValueOperator = "access control: rule %s: 'query' option '%s' is " +
"invalid: must have a value when the operator is '%s'"
errFmtAccessControlRuleQueryInvalidValue = "access control: rule %s: 'query' option '%s' is " +
"invalid: must not have a value when the operator is '%s'"
errFmtAccessControlRuleQueryInvalidValueParse = "access control: rule %s: 'query' option '%s' is " +
"invalid: %w"
errFmtAccessControlRuleQueryInvalidValueType = "access control: rule %s: 'query' option 'value' is " +
"invalid: expected type was string but got %T"
) )
// Theme Error constants. // Theme Error constants.
@ -317,9 +329,20 @@ var validRFC7231HTTPMethodVerbs = []string{"GET", "HEAD", "POST", "PUT", "PATCH"
var validRFC4918HTTPMethodVerbs = []string{"COPY", "LOCK", "MKCOL", "MOVE", "PROPFIND", "PROPPATCH", "UNLOCK"} var validRFC4918HTTPMethodVerbs = []string{"COPY", "LOCK", "MKCOL", "MOVE", "PROPFIND", "PROPPATCH", "UNLOCK"}
var validACLHTTPMethodVerbs = append(validRFC7231HTTPMethodVerbs, validRFC4918HTTPMethodVerbs...) const (
operatorPresent = "present"
operatorAbsent = "absent"
operatorEqual = "equal"
operatorNotEqual = "not equal"
operatorPattern = "pattern"
operatorNotPattern = "not pattern"
)
var validACLRulePolicies = []string{policyBypass, policyOneFactor, policyTwoFactor, policyDeny} var (
validACLHTTPMethodVerbs = append(validRFC7231HTTPMethodVerbs, validRFC4918HTTPMethodVerbs...)
validACLRulePolicies = []string{policyBypass, policyOneFactor, policyTwoFactor, policyDeny}
validACLRuleOperators = []string{operatorPresent, operatorAbsent, operatorEqual, operatorNotEqual, operatorPattern, operatorNotPattern}
)
var validDefault2FAMethods = []string{"totp", "webauthn", "mobile_push"} var validDefault2FAMethods = []string{"totp", "webauthn", "mobile_push"}