Add access control logic to the auth and proxy endpoints
Signed-off-by: Antonio Mika <antoniomika@gmail.com>
This commit is contained in:
parent
3a55dadbe8
commit
d5bf4e059d
|
|
@ -40,20 +40,40 @@ BEWARE that the domain you want to redirect to (`my-oidc-provider.example.com` i
|
|||
|
||||
### Auth
|
||||
|
||||
This endpoint returns 202 Accepted response or a 401 Unauthorized response.
|
||||
This endpoint is used for Nginx subrequest authentication. It returns the following status codes based on the request state:
|
||||
* **202 Accepted:** The user is authenticated and passes all authorization logic checks.
|
||||
* **403 Forbidden:** The user is authenticated but fails the configured logic checks (e.g., wrong group or email domain).
|
||||
* **401 Unauthorized:** The user is unable to authenticate (missing or invalid session).
|
||||
|
||||
It can be configured using the following query parameters:
|
||||
- `allowed_groups`: comma separated list of allowed groups
|
||||
- `allowed_email_domains`: comma separated list of allowed email domains
|
||||
- `allowed_emails`: comma separated list of allowed emails
|
||||
- `allowed_users`: comma separated list of allowed users
|
||||
- `require_all_matches`: (boolean, default: `true`) Determines if all defined constraints must pass.
|
||||
- `constraints_required`: (boolean, default: `false`) Determines if the request is denied when no constraints are present.
|
||||
|
||||
**Logic Behavior:**
|
||||
* **Default (AND Logic):** If multiple constraints are provided (e.g., `allowed_users` AND `allowed_groups`), the user must satisfy **ALL** of them.
|
||||
* **OR Logic:** If `require_all_matches=false` is set, the user must satisfy **AT LEAST ONE** of the provided constraints.
|
||||
* **Empty State:** If no constraints are provided, the request is allowed by default. Set `constraints_required=true` to deny requests that do not match at least one specific restriction.
|
||||
|
||||
### Proxy (/)
|
||||
|
||||
This endpoint returns the upstream response if authenticated.
|
||||
If unauthenticated it returns a 401 Unauthorized. If the authenticatd user
|
||||
is not in one of the allowed groups, or emails then it returns a 403 forbidden
|
||||
This endpoint proxies the request to the upstream service. It returns the following status codes based on the request state:
|
||||
* **Upstream Response:** The user is authenticated and passes all authorization logic checks.
|
||||
* **403 Forbidden:** The user is authenticated but fails the configured logic checks.
|
||||
* **401 Unauthorized:** The user is unable to authenticate.
|
||||
|
||||
It can be configured using the following query parameters:
|
||||
- `allowed_groups`: comma separated list of allowed groups
|
||||
- `allowed_email_domains`: comma separated list of allowed email domains
|
||||
- `allowed_emails`: comma separated list of allowed emails
|
||||
- `allowed_users`: comma separated list of allowed users
|
||||
- `require_all_matches`: (boolean, default: `true`) Determines if all defined constraints must pass.
|
||||
- `constraints_required`: (boolean, default: `false`) Determines if the request is denied when no constraints are present.
|
||||
|
||||
**Logic Behavior:**
|
||||
* **Default (AND Logic):** If multiple constraints are provided (e.g., `allowed_users` AND `allowed_groups`), the user must satisfy **ALL** of them.
|
||||
* **OR Logic:** If `require_all_matches=false` is set, the user must satisfy **AT LEAST ONE** of the provided constraints.
|
||||
* **Empty State:** If no constraints are provided, the request is allowed by default. Set `constraints_required=true` to deny requests that do not match at least one specific restriction.
|
||||
|
|
|
|||
|
|
@ -1154,19 +1154,55 @@ func authOnlyAuthorize(req *http.Request, s *sessionsapi.SessionState) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
constraints := []func(*http.Request, *sessionsapi.SessionState) bool{
|
||||
// By default, all checks are required to pass (AND logic).
|
||||
// If `require_all_matches=false` is set, only one check needs to pass (OR logic).
|
||||
requireAllMatches := !(req.URL.Query().Get("require_all_matches") == "false")
|
||||
|
||||
// By default, if no constraints are set, the request is allowed.
|
||||
// If `constraints_required=true` is set, at least one constraint must be present
|
||||
// and pass for the request to be allowed.
|
||||
constraintsRequired := req.URL.Query().Get("constraints_required") == "true"
|
||||
|
||||
constraints := []func(*http.Request, *sessionsapi.SessionState) (allowed, found bool){
|
||||
checkAllowedGroups,
|
||||
checkAllowedEmailDomains,
|
||||
checkAllowedEmails,
|
||||
checkAllowedUsers,
|
||||
}
|
||||
|
||||
for _, constraint := range constraints {
|
||||
if !constraint(req, s) {
|
||||
return false
|
||||
var constraintsFound bool
|
||||
var passedCount int
|
||||
var failedCount int
|
||||
|
||||
for _, check := range constraints {
|
||||
allowed, found := check(req, s)
|
||||
|
||||
// We only care about constraints that are actually configured (found)
|
||||
if found {
|
||||
constraintsFound = true
|
||||
if allowed {
|
||||
passedCount++
|
||||
} else {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
// CASE 1: No constraints were configured/found.
|
||||
if !constraintsFound {
|
||||
// By default, allow the request if no constraints are set.
|
||||
// If constraints are required, deny because none were present.
|
||||
return !constraintsRequired
|
||||
}
|
||||
|
||||
// CASE 2: Constraints were found. Apply the combination logic.
|
||||
if requireAllMatches {
|
||||
// Default behavior: All configured constraints must pass (AND logic).
|
||||
return failedCount == 0
|
||||
}
|
||||
|
||||
// Alternative behavior: Only one configured constraint needs to pass (OR logic).
|
||||
return passedCount > 0
|
||||
}
|
||||
|
||||
// extractAllowedEntities aims to extract and split allowed entities linked by a key,
|
||||
|
|
@ -1189,15 +1225,15 @@ func extractAllowedEntities(req *http.Request, key string) map[string]struct{} {
|
|||
|
||||
// checkAllowedEmailDomains allow email domain restrictions based on the `allowed_email_domains`
|
||||
// querystring parameter
|
||||
func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bool {
|
||||
func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) {
|
||||
allowedEmailDomains := extractAllowedEntities(req, "allowed_email_domains")
|
||||
if len(allowedEmailDomains) == 0 {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
|
||||
splitEmail := strings.Split(s.Email, "@")
|
||||
if len(splitEmail) != 2 {
|
||||
return false
|
||||
return false, true
|
||||
}
|
||||
|
||||
endpoint, _ := url.Parse("")
|
||||
|
|
@ -1208,36 +1244,52 @@ func checkAllowedEmailDomains(req *http.Request, s *sessionsapi.SessionState) bo
|
|||
allowedEmailDomainsList = append(allowedEmailDomainsList, ed)
|
||||
}
|
||||
|
||||
return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList)
|
||||
return util.IsEndpointAllowed(endpoint, allowedEmailDomainsList), true
|
||||
}
|
||||
|
||||
// checkAllowedGroups allow secondary group restrictions based on the `allowed_groups`
|
||||
// querystring parameter
|
||||
func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) bool {
|
||||
func checkAllowedGroups(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) {
|
||||
allowedGroups := extractAllowedEntities(req, "allowed_groups")
|
||||
if len(allowedGroups) == 0 {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
|
||||
for _, group := range s.Groups {
|
||||
if _, ok := allowedGroups[group]; ok {
|
||||
return true
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return false, true
|
||||
}
|
||||
|
||||
// checkAllowedUsers allow user restrictions based on the `allowed_users`
|
||||
// querystring parameter
|
||||
func checkAllowedUsers(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) {
|
||||
allowedUsers := extractAllowedEntities(req, "allowed_users")
|
||||
if len(allowedUsers) == 0 {
|
||||
return true, false
|
||||
}
|
||||
|
||||
for user := range allowedUsers {
|
||||
if user == s.User {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
// checkAllowedEmails allow email restrictions based on the `allowed_emails`
|
||||
// querystring parameter
|
||||
func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) bool {
|
||||
func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) (allowed, found bool) {
|
||||
allowedEmails := extractAllowedEntities(req, "allowed_emails")
|
||||
if len(allowedEmails) == 0 {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
|
||||
allowed := false
|
||||
|
||||
for email := range allowedEmails {
|
||||
if email == s.Email {
|
||||
allowed = true
|
||||
|
|
@ -1245,7 +1297,7 @@ func checkAllowedEmails(req *http.Request, s *sessionsapi.SessionState) bool {
|
|||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
return allowed, true
|
||||
}
|
||||
|
||||
// encodeState builds the OAuth state param out of our nonce and
|
||||
|
|
|
|||
|
|
@ -3445,6 +3445,182 @@ func TestAuthOnlyAllowedEmails(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestAuthOnlyAuthorize verifies the authorization logic for the AuthOnly endpoint,
|
||||
// covering individual constraints (User, Email, Group, Domain), boolean logic
|
||||
// (AND vs OR modes), and comma-separated value parsing.
|
||||
func TestAuthOnlyAuthorize(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
user string
|
||||
email string
|
||||
groups []string
|
||||
querystring string
|
||||
expectedStatusCode int
|
||||
}{
|
||||
// 1. User Constraints
|
||||
{
|
||||
name: "User_NoRestriction",
|
||||
user: "toto",
|
||||
querystring: "",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "User_Match_Single",
|
||||
user: "toto",
|
||||
querystring: "?allowed_users=toto",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "User_NoMatch_Single",
|
||||
user: "toto",
|
||||
querystring: "?allowed_users=tete",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "User_Match_CSV",
|
||||
user: "toto",
|
||||
querystring: "?allowed_users=tete,toto",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "User_NoMatch_CSV",
|
||||
user: "toto",
|
||||
querystring: "?allowed_users=tete,tutu",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
// 2. Email & Domain Constraints
|
||||
{
|
||||
name: "Email_Match",
|
||||
user: "user",
|
||||
email: "user@example.com",
|
||||
querystring: "?allowed_emails=user@example.com",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "Email_NoMatch",
|
||||
user: "user",
|
||||
email: "user@example.com",
|
||||
querystring: "?allowed_emails=admin@example.com",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Domain_Match",
|
||||
user: "user",
|
||||
email: "user@example.com",
|
||||
querystring: "?allowed_email_domains=example.com",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
// 3. Group Constraints
|
||||
{
|
||||
name: "Group_Match",
|
||||
user: "user",
|
||||
groups: []string{"admins", "devs"},
|
||||
querystring: "?allowed_groups=admins",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "Group_NoMatch",
|
||||
user: "user",
|
||||
groups: []string{"devs"},
|
||||
querystring: "?allowed_groups=admins",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
// 4. Default Logic (Implicit AND)
|
||||
// All defined constraints must pass.
|
||||
{
|
||||
name: "DefaultAND_BothMatch",
|
||||
user: "toto",
|
||||
email: "toto@example.com",
|
||||
querystring: "?allowed_users=toto&allowed_emails=toto@example.com",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "DefaultAND_OneFail",
|
||||
user: "toto",
|
||||
email: "toto@example.com",
|
||||
// User matches, Email fails -> Forbidden
|
||||
querystring: "?allowed_users=toto&allowed_emails=tete@example.com",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "DefaultAND_BothFail",
|
||||
user: "toto",
|
||||
email: "toto@example.com",
|
||||
querystring: "?allowed_users=tete&allowed_emails=tete@example.com",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
// 5. Explicit OR Logic (require_all_matches=false)
|
||||
// Only one defined constraint needs to pass.
|
||||
{
|
||||
name: "ORMode_OneMatch_OneFail",
|
||||
user: "toto",
|
||||
email: "toto@example.com",
|
||||
// Email matches, User fails -> Accepted
|
||||
querystring: "?allowed_emails=toto@example.com&allowed_users=tete&require_all_matches=false",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "ORMode_BothFail",
|
||||
user: "toto",
|
||||
email: "toto@example.com",
|
||||
querystring: "?allowed_users=tete&allowed_emails=tete@example.com&require_all_matches=false",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "ORMode_GroupFail_DomainPass",
|
||||
user: "user",
|
||||
email: "user@company.com",
|
||||
groups: []string{"interns"},
|
||||
// User not in 'admins', but email is 'company.com' -> Accepted
|
||||
querystring: "?allowed_groups=admins&allowed_email_domains=company.com&require_all_matches=false",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
// 6. Empty State Logic (constraints_required)
|
||||
{
|
||||
name: "NoConstraints_Required_True",
|
||||
user: "user",
|
||||
querystring: "?constraints_required=true",
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "NoConstraints_Required_False",
|
||||
user: "user",
|
||||
querystring: "?constraints_required=false",
|
||||
expectedStatusCode: http.StatusAccepted,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
groups := tc.groups
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
|
||||
created := time.Now()
|
||||
session := &sessions.SessionState{
|
||||
Groups: groups,
|
||||
User: tc.user,
|
||||
Email: tc.email,
|
||||
AccessToken: "oauth_token",
|
||||
CreatedAt: &created,
|
||||
}
|
||||
|
||||
test, err := NewAuthOnlyEndpointTest(tc.querystring, func(opts *options.Options) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = test.SaveSession(session)
|
||||
assert.NoError(t, err)
|
||||
|
||||
test.proxy.ServeHTTP(test.rw, test.req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatusCode, test.rw.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOAuthRedirectURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
Loading…
Reference in New Issue