mirror of
https://github.com/portainer/portainer.git
synced 2025-08-06 14:25:31 +02:00
feat(extensions): introduce RBAC extension (#2900)
This commit is contained in:
parent
27a0188949
commit
8057aa45c4
196 changed files with 3321 additions and 1316 deletions
|
@ -79,7 +79,7 @@ func AuthorizedResourceControlUpdate(resourceControl *portainer.ResourceControl,
|
|||
// * the Public flag is set false
|
||||
// * he wants to create a resource control without any user/team accesses
|
||||
// * he wants to add more than one user in the user accesses
|
||||
// * he wants tp add a user in the user accesses that is not corresponding to its id
|
||||
// * he wants to add a user in the user accesses that is not corresponding to its id
|
||||
// * he wants to add a team he is not a member of
|
||||
func AuthorizedResourceControlCreation(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin || resourceControl.Public {
|
||||
|
@ -146,9 +146,9 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
|
|||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams of the endpoint and the associated group.
|
||||
func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
|
||||
groupAccess := authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
if !groupAccess {
|
||||
return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams)
|
||||
return authorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
@ -157,28 +157,28 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta
|
|||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
|
||||
return authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
}
|
||||
|
||||
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams)
|
||||
return authorizedAccess(userID, memberships, registry.UserAccessPolicies, registry.TeamAccessPolicies)
|
||||
}
|
||||
|
||||
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool {
|
||||
for _, authorizedUserID := range authorizedUsers {
|
||||
if authorizedUserID == userID {
|
||||
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool {
|
||||
_, userAccess := userAccessPolicies[userID]
|
||||
if userAccess {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, membership := range memberships {
|
||||
_, teamAccess := teamAccessPolicies[membership.TeamID]
|
||||
if teamAccess {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, membership := range memberships {
|
||||
for _, authorizedTeamID := range authorizedTeams {
|
||||
if membership.TeamID == authorizedTeamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -14,7 +14,10 @@ type (
|
|||
jwtService portainer.JWTService
|
||||
userService portainer.UserService
|
||||
teamMembershipService portainer.TeamMembershipService
|
||||
endpointService portainer.EndpointService
|
||||
endpointGroupService portainer.EndpointGroupService
|
||||
extensionService portainer.ExtensionService
|
||||
rbacExtensionClient *rbacExtensionClient
|
||||
authDisabled bool
|
||||
}
|
||||
|
||||
|
@ -23,7 +26,10 @@ type (
|
|||
JWTService portainer.JWTService
|
||||
UserService portainer.UserService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
ExtensionService portainer.ExtensionService
|
||||
RBACExtensionURL string
|
||||
AuthDisabled bool
|
||||
}
|
||||
|
||||
|
@ -43,48 +49,49 @@ func NewRequestBouncer(parameters *RequestBouncerParams) *RequestBouncer {
|
|||
jwtService: parameters.JWTService,
|
||||
userService: parameters.UserService,
|
||||
teamMembershipService: parameters.TeamMembershipService,
|
||||
endpointService: parameters.EndpointService,
|
||||
endpointGroupService: parameters.EndpointGroupService,
|
||||
extensionService: parameters.ExtensionService,
|
||||
rbacExtensionClient: newRBACExtensionClient(parameters.RBACExtensionURL),
|
||||
authDisabled: parameters.AuthDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
// PublicAccess defines a security check for public endpoints.
|
||||
// PublicAccess defines a security check for public API endpoints.
|
||||
// No authentication is required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
||||
h = mwSecureHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// AuthenticatedAccess defines a security check for private endpoints.
|
||||
// AuthorizedAccess defines a security check for API endpoints that require an authorization check.
|
||||
// Authentication is required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwCheckAuthentication(h)
|
||||
h = mwSecureHeaders(h)
|
||||
// If the RBAC extension is enabled, authorizations are required to use these endpoints.
|
||||
// If the RBAC extension is not enabled, the administrator role is required to use these endpoints.
|
||||
func (bouncer *RequestBouncer) AuthorizedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.mwCheckPortainerAuthorizations(h)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// RestrictedAccess defines a security check for restricted endpoints.
|
||||
// RestrictedAccess defines a security check for restricted API endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The request context will be enhanced with a RestrictedRequestContext object
|
||||
// that might be used later to authorize/filter access to resources.
|
||||
// that might be used later to authorize/filter access to resources inside an endpoint.
|
||||
func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
|
||||
h = bouncer.mwUpgradeToRestrictedRequest(h)
|
||||
h = bouncer.AuthenticatedAccess(h)
|
||||
h = bouncer.mwAuthenticatedUser(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// AdministratorAccess defines a chain of middleware for restricted endpoints.
|
||||
// Authentication as well as administrator role are required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) AdministratorAccess(h http.Handler) http.Handler {
|
||||
h = mwCheckAdministratorRole(h)
|
||||
h = bouncer.AuthenticatedAccess(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// EndpointAccess retrieves the JWT token from the request context and verifies
|
||||
// AuthorizedEndpointOperation retrieves the JWT token from the request context and verifies
|
||||
// that the user can access the specified endpoint.
|
||||
// An error is returned when access is denied.
|
||||
func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portainer.Endpoint) error {
|
||||
// If the RBAC extension is enabled and the authorizationCheck flag is set,
|
||||
// it will also validate that the user can execute the specified operation.
|
||||
// An error is returned when access to the endpoint is denied or if the user do not have the required
|
||||
// authorization to execute the operation.
|
||||
func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint, authorizationCheck bool) error {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -108,9 +115,43 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain
|
|||
return portainer.ErrEndpointAccessDenied
|
||||
}
|
||||
|
||||
if authorizationCheck {
|
||||
err = bouncer.checkEndpointOperationAuthorization(r, endpoint)
|
||||
if err != nil {
|
||||
return portainer.ErrAuthorizationRequired
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bouncer *RequestBouncer) checkEndpointOperationAuthorization(r *http.Request, endpoint *portainer.Endpoint) error {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return nil
|
||||
}
|
||||
|
||||
extension, err := bouncer.extensionService.Extension(portainer.RBACExtension)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiOperation := &portainer.APIOperationAuthorizationRequest{
|
||||
Path: r.URL.String(),
|
||||
Method: r.Method,
|
||||
Authorizations: tokenData.EndpointAuthorizations[endpoint.ID],
|
||||
}
|
||||
|
||||
bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey)
|
||||
return bouncer.rbacExtensionClient.checkAuthorization(apiOperation)
|
||||
}
|
||||
|
||||
// RegistryAccess retrieves the JWT token from the request context and verifies
|
||||
// that the user can access the specified registry.
|
||||
// An error is returned when access is denied.
|
||||
|
@ -136,11 +177,50 @@ func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portain
|
|||
return nil
|
||||
}
|
||||
|
||||
// mwSecureHeaders provides secure headers middleware for handlers.
|
||||
func mwSecureHeaders(next http.Handler) http.Handler {
|
||||
func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler {
|
||||
h = bouncer.mwCheckAuthentication(h)
|
||||
h = mwSecureHeaders(h)
|
||||
return h
|
||||
}
|
||||
|
||||
// mwCheckPortainerAuthorizations will verify that the user has the required authorization to access
|
||||
// a specific API endpoint. It will leverage the RBAC extension authorization validation if the extension
|
||||
// is enabled.
|
||||
func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Add("X-Content-Type-Options", "nosniff")
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
extension, err := bouncer.extensionService.Extension(portainer.RBACExtension)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err)
|
||||
return
|
||||
}
|
||||
|
||||
apiOperation := &portainer.APIOperationAuthorizationRequest{
|
||||
Path: r.URL.String(),
|
||||
Method: r.Method,
|
||||
Authorizations: tokenData.PortainerAuthorizations,
|
||||
}
|
||||
|
||||
bouncer.rbacExtensionClient.setLicenseKey(extension.License.LicenseKey)
|
||||
err = bouncer.rbacExtensionClient.checkAuthorization(apiOperation)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrAuthorizationRequired)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
@ -166,19 +246,6 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
|
|||
})
|
||||
}
|
||||
|
||||
// mwCheckAdministratorRole check the role of the user associated to the request
|
||||
func mwCheckAdministratorRole(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil || tokenData.Role != portainer.AdministratorRole {
|
||||
httperror.WriteError(w, http.StatusForbidden, "Access denied", portainer.ErrResourceAccessDenied)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// mwCheckAuthentication provides Authentication middleware for handlers
|
||||
func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -229,6 +296,15 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
|
|||
})
|
||||
}
|
||||
|
||||
// mwSecureHeaders provides secure headers middleware for handlers.
|
||||
func mwSecureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Add("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) {
|
||||
requestContext := &RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package security
|
||||
|
||||
import "github.com/portainer/portainer/api"
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// FilterUserTeams filters teams based on user role.
|
||||
// non-administrator users only have access to team they are member of.
|
||||
|
@ -78,7 +80,7 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
|||
}
|
||||
|
||||
// FilterTemplates filters templates based on the user role.
|
||||
// Non-administrato template do not have access to templates where the AdministratorOnly flag is set to true.
|
||||
// Non-administrator template do not have access to templates where the AdministratorOnly flag is set to true.
|
||||
func FilterTemplates(templates []portainer.Template, context *RestrictedRequestContext) []portainer.Template {
|
||||
filteredTemplates := templates
|
||||
|
||||
|
|
59
api/http/security/rbac.go
Normal file
59
api/http/security/rbac.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPTimeout = 5
|
||||
)
|
||||
|
||||
type rbacExtensionClient struct {
|
||||
httpClient *http.Client
|
||||
extensionURL string
|
||||
licenseKey string
|
||||
}
|
||||
|
||||
func newRBACExtensionClient(extensionURL string) *rbacExtensionClient {
|
||||
return &rbacExtensionClient{
|
||||
extensionURL: extensionURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Second * time.Duration(defaultHTTPTimeout),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (client *rbacExtensionClient) setLicenseKey(licenseKey string) {
|
||||
client.licenseKey = licenseKey
|
||||
}
|
||||
|
||||
func (client *rbacExtensionClient) checkAuthorization(authRequest *portainer.APIOperationAuthorizationRequest) error {
|
||||
encodedAuthRequest, err := json.Marshal(authRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", client.extensionURL+"/authorized_operation", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("X-RBAC-AuthorizationRequest", string(encodedAuthRequest))
|
||||
req.Header.Set("X-PortainerExtension-License", client.licenseKey)
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return portainer.ErrAuthorizationRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue