diff --git a/go.mod b/go.mod index 649fff9..f3216d4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/greenpau/go-authcrunch -go 1.20 +go 1.21 require ( github.com/crewjam/saml v0.4.14 diff --git a/go.sum b/go.sum index 3ba9d66..de90cee 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -79,6 +80,7 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRT github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -116,6 +118,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -138,3 +141,4 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/pkg/authn/enums/role/role.go b/pkg/authn/enums/role/role.go new file mode 100644 index 0000000..7663953 --- /dev/null +++ b/pkg/authn/enums/role/role.go @@ -0,0 +1,52 @@ +// Copyright 2024 Paul Greenberg greenpau@outlook.com +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package role + +import ( + "fmt" +) + +// Kind is the type of a role. +type Kind int + +const ( + // Unknown operator signals invalid role type. + Unknown Kind = iota + // Anonymous indicates anonymous. + Anonymous + // Admin indicates role with administrative privileges. + Admin + // User indicates role with user privileges. + User + // Guest indicates role with guest privileges. + Guest +) + +// String returns string representation of a role type. +func (e Kind) String() string { + switch e { + case Unknown: + return "Unknown" + case Anonymous: + return "Anonymous" + case Admin: + return "Admin" + case User: + return "User" + case Guest: + return "Guest" + } + return fmt.Sprintf("RoleKind(%d)", int(e)) +} diff --git a/pkg/authn/gatekeeper.go b/pkg/authn/gatekeeper.go new file mode 100644 index 0000000..775e025 --- /dev/null +++ b/pkg/authn/gatekeeper.go @@ -0,0 +1,60 @@ +// Copyright 2024 Paul Greenberg greenpau@outlook.com +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authn + +import ( + "fmt" + "slices" + + "github.com/greenpau/go-authcrunch/pkg/authn/enums/role" + "github.com/greenpau/go-authcrunch/pkg/user" +) + +func (p *Portal) authorizedRole(usr *user.User, authorizedRoles []role.Kind, authenticated bool) error { + if !authenticated { + if slices.Contains(authorizedRoles, role.Anonymous) { + return nil + } + return fmt.Errorf("user is not authenticated") + } + + if slices.Contains(authorizedRoles, role.User) { + for roleName := range p.config.PortalUserRoles { + if usr.HasRole(roleName) { + return nil + } + } + for _, roleNamePattern := range p.config.userRolePatterns { + if usr.HasRolePattern(roleNamePattern) { + return nil + } + } + } + + if slices.Contains(authorizedRoles, role.Admin) { + for roleName := range p.config.PortalAdminRoles { + if usr.HasRole(roleName) { + return nil + } + } + for _, roleNamePattern := range p.config.adminRolePatterns { + if usr.HasRolePattern(roleNamePattern) { + return nil + } + } + } + + return fmt.Errorf("user is not authorized") +} diff --git a/pkg/authn/portal.go b/pkg/authn/portal.go index 16b129b..c30f5e1 100644 --- a/pkg/authn/portal.go +++ b/pkg/authn/portal.go @@ -45,8 +45,7 @@ import ( ) const ( - defaultPortalACLCondition = "match roles authp/admin authp/user authp/guest superuser superadmin" - defaultPortalACLAction = "allow stop" + defaultPortalACLAction = "allow stop" ) // Portal is an authentication portal. @@ -225,18 +224,78 @@ func (p *Portal) configureEssentials() error { return err } p.cookie = c + + p.logger.Debug( + "Configuring defaul portal user roles", + zap.String("portal_name", p.config.Name), + zap.Any("portal_admin_roles", p.config.PortalAdminRoles), + zap.Any("portal_user_roles", p.config.PortalUserRoles), + zap.Any("portal_guest_roles", p.config.PortalGuestRoles), + zap.Any("portal_admin_role_patterns", p.config.PortalAdminRolePatterns), + zap.Any("portal_user_role_patterns", p.config.PortalUserRolePatterns), + zap.Any("portal_guest_role_patterns", p.config.PortalGuestRolePatterns), + ) + return nil } func (p *Portal) configureCryptoKeyStore() error { if len(p.config.AccessListConfigs) == 0 { - p.config.AccessListConfigs = []*acl.RuleConfiguration{ - { - // Admin users can access everything. - Conditions: []string{defaultPortalACLCondition}, + defaultACLConfig := []*acl.RuleConfiguration{} + + // Configure ACL by role names + for roleName := range p.config.PortalAdminRoles { + aclConfig := &acl.RuleConfiguration{ + Comment: "admin role name match", + Conditions: []string{"match role " + roleName}, + Action: defaultPortalACLAction, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) + } + for roleName := range p.config.PortalUserRoles { + aclConfig := &acl.RuleConfiguration{ + Comment: "user role name match", + Conditions: []string{"match role " + roleName}, + Action: defaultPortalACLAction, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) + } + for roleName := range p.config.PortalGuestRoles { + aclConfig := &acl.RuleConfiguration{ + Comment: "guest role name match", + Conditions: []string{"match role " + roleName}, Action: defaultPortalACLAction, - }, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) + } + + // Configure ACL by role patterns + for _, roleNameRegex := range p.config.adminRolePatterns { + aclConfig := &acl.RuleConfiguration{ + Comment: "admin role name pattern match", + Conditions: []string{"regex match role " + roleNameRegex.String()}, + Action: defaultPortalACLAction, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) + } + for _, roleNameRegex := range p.config.userRolePatterns { + aclConfig := &acl.RuleConfiguration{ + Comment: "user role name pattern match", + Conditions: []string{"regex match role " + roleNameRegex.String()}, + Action: defaultPortalACLAction, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) + } + for _, roleNameRegex := range p.config.guestRolePatterns { + aclConfig := &acl.RuleConfiguration{ + Comment: "guest role name pattern match", + Conditions: []string{"regex match role " + roleNameRegex.String()}, + Action: defaultPortalACLAction, + } + defaultACLConfig = append(defaultACLConfig, aclConfig) } + + p.config.AccessListConfigs = defaultACLConfig } p.logger.Debug( diff --git a/pkg/authn/portal_test.go b/pkg/authn/portal_test.go index bb8965f..251bf59 100644 --- a/pkg/authn/portal_test.go +++ b/pkg/authn/portal_test.go @@ -147,10 +147,21 @@ func TestNewPortal(t *testing.T) { "validate_bearer_header": true }, "access_list_configs": [ - { - "action": "` + defaultPortalACLAction + `", - "conditions": ["` + defaultPortalACLCondition + `"] - } + { + "action": "allow stop", + "comment": "admin role name match", + "conditions": ["match role authp/admin"] + }, + { + "action": "allow stop", + "comment": "user role name match", + "conditions": ["match role authp/user"] + }, + { + "action": "allow stop", + "comment": "guest role name match", + "conditions": ["match role authp/guest"] + } ], "identity_stores": ["local_backend"] } diff --git a/pkg/authn/respond_api.go b/pkg/authn/respond_api.go index 9416686..0202742 100644 --- a/pkg/authn/respond_api.go +++ b/pkg/authn/respond_api.go @@ -16,11 +16,13 @@ package authn import ( "context" + "net/http" + "strings" + + "github.com/greenpau/go-authcrunch/pkg/authn/enums/role" "github.com/greenpau/go-authcrunch/pkg/requests" addrutil "github.com/greenpau/go-authcrunch/pkg/util/addr" "go.uber.org/zap" - "net/http" - "strings" ) func (p *Portal) handleAPI(ctx context.Context, w http.ResponseWriter, r *http.Request, rr *requests.Request) error { @@ -47,48 +49,36 @@ func (p *Portal) handleAPI(ctx context.Context, w http.ResponseWriter, r *http.R return p.handleJSONErrorWithLog(ctx, w, r, rr, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) } - if !rr.Response.Authenticated { - p.logger.Debug( - "User is not authorized to use API", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - ) - return p.handleJSONErrorWithLog(ctx, w, r, rr, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) - } - - if !usr.HasRole("authp/admin") { - p.logger.Debug( - "API is not available because user has no admin privileges", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - ) - return p.handleJSONErrorWithLog(ctx, w, r, rr, http.StatusForbidden, http.StatusText(http.StatusForbidden)) - } - - if p.config.API == nil || (p.config.API != nil && !p.config.API.ProfileEnabled && !p.config.API.AdminEnabled) { - p.logger.Debug( - "API is not available", - zap.String("session_id", rr.Upstream.SessionID), - zap.String("request_id", rr.ID), - zap.Any("api_config", p.config.API), - ) - return p.handleJSONError(ctx, w, http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented)) - } - switch { - case p.config.API.AdminEnabled && strings.HasSuffix(r.URL.Path, "/api/metadata"): - return p.handleAPIMetadata(ctx, w, r, rr, usr) - case p.config.API.AdminEnabled && strings.Contains(r.URL.Path, "/api/orgs"): - return p.handleJSONError(ctx, w, http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented)) - case p.config.API.AdminEnabled && strings.Contains(r.URL.Path, "/api/teams"): + case p.config.API.AdminEnabled && r.Method == "POST" && strings.Contains(r.URL.Path, "/api/manager"): + if err := p.authorizedRole(usr, []role.Kind{role.Admin}, rr.Response.Authenticated); err != nil { + p.logger.Debug( + "User is not authorized accessing API", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.String("reason", err.Error()), + ) + return p.handleJSONError(ctx, w, http.StatusForbidden, http.StatusText(http.StatusForbidden)) + } + // case p.config.API.AdminEnabled && strings.HasSuffix(r.URL.Path, "/api/metadata"): + // return p.handleAPIMetadata(ctx, w, r, rr, usr) + // case p.config.API.AdminEnabled && strings.Contains(r.URL.Path, "/api/users"): + // return p.handleAPIListUsers(ctx, w, r, rr, usr) return p.handleJSONError(ctx, w, http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented)) - case p.config.API.AdminEnabled && strings.Contains(r.URL.Path, "/api/users"): - return p.handleAPIListUsers(ctx, w, r, rr, usr) case p.config.API.ProfileEnabled && r.Method == "POST" && strings.Contains(r.URL.Path, "/api/profile"): + if err := p.authorizedRole(usr, []role.Kind{role.Admin, role.User}, rr.Response.Authenticated); err != nil { + p.logger.Debug( + "User is not authorized accessing API", + zap.String("session_id", rr.Upstream.SessionID), + zap.String("request_id", rr.ID), + zap.String("reason", err.Error()), + ) + return p.handleJSONError(ctx, w, http.StatusForbidden, http.StatusText(http.StatusForbidden)) + } return p.handleAPIProfile(ctx, w, r, rr, usr) default: p.logger.Debug( - "API endpoint is not supported", + "API endpoint is not available", zap.String("session_id", rr.Upstream.SessionID), zap.String("request_id", rr.ID), zap.Any("api_config", p.config.API), diff --git a/pkg/user/user.go b/pkg/user/user.go index b45b5d0..c54c1e2 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -17,11 +17,13 @@ package user import ( "encoding/json" "fmt" + "regexp" + "strings" + "time" + "github.com/greenpau/go-authcrunch/pkg/errors" cfgutil "github.com/greenpau/go-authcrunch/pkg/util/cfg" datautil "github.com/greenpau/go-authcrunch/pkg/util/data" - "strings" - "time" ) /* @@ -296,6 +298,16 @@ func (u *User) HasRole(roles ...string) bool { return false } +// HasRolePattern checks whether a user has a role matching the provided pattern. +func (u *User) HasRolePattern(ptrn *regexp.Regexp) bool { + for roleName := range u.rkv { + if ptrn.MatchString(roleName) { + return true + } + } + return false +} + // HasRoles checks whether a user has all of the provided roles. func (u *User) HasRoles(roles ...string) bool { for _, role := range roles { diff --git a/server_test.go b/server_test.go index 7bd9a78..6abd8e3 100644 --- a/server_test.go +++ b/server_test.go @@ -146,12 +146,21 @@ func TestNewServer(t *testing.T) { "authentication_portals": [ { "access_list_configs": [ - { - "action": "allow stop", - "conditions": [ - "match roles authp/admin authp/user authp/guest superuser superadmin" - ] - } + { + "action": "allow stop", + "comment": "admin role name match", + "conditions": ["match role authp/admin"] + }, + { + "action": "allow stop", + "comment": "user role name match", + "conditions": ["match role authp/user"] + }, + { + "action": "allow stop", + "comment": "guest role name match", + "conditions": ["match role authp/guest"] + } ], "identity_stores": [ "localdb"