From a57866bcab37acbb30eee112b91d688382e1edce Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Wed, 12 Aug 2020 18:39:28 +0200 Subject: [PATCH] Add basic impersonation support for admins --- api/system/spec.json | 16 +++++ api/system/spec/auth.json | 22 +++++++ docs/compose/README.md | 1 + docs/system/README.md | 16 +++++ system/rest/auth.go | 12 ++++ system/rest/handlers/auth.go | 23 +++++++ system/rest/request/auth.go | 100 +++++++++++++++++++++++++++++ system/service/auth.go | 32 +++++++++ system/service/auth_actions.gen.go | 50 +++++++++++++++ system/service/auth_actions.yaml | 7 ++ 10 files changed, 279 insertions(+) diff --git a/api/system/spec.json b/api/system/spec.json index 8bdfc4b8b..74dd611af 100644 --- a/api/system/spec.json +++ b/api/system/spec.json @@ -19,6 +19,22 @@ "path": "/check", "parameters": {} }, + { + "name": "impersonate", + "method": "POST", + "title": "Impersonate a user", + "path": "/impersonate", + "parameters": { + "post": [ + { + "name": "userID", + "type": "uint64", + "required": true, + "title": "ID of the impersonated user" + } + ] + } + }, { "name": "exchangeAuthToken", "method": "POST", diff --git a/api/system/spec/auth.json b/api/system/spec/auth.json index 6da64262a..9b8540c86 100644 --- a/api/system/spec/auth.json +++ b/api/system/spec/auth.json @@ -21,6 +21,28 @@ "Path": "/check", "Parameters": {} }, + { + "Name": "impersonate", + "Method": "POST", + "Title": "Impersonate a user", + "Path": "/impersonate", + "Parameters": { + "post": [ + { + "name": "userID", + "required": true, + "title": "ID of the impersonated user", + "type": "uint64" + }, + { + "name": "expire", + "required": false, + "title": "expiration time in seconds, must be shorter than 24h", + "type": "int" + } + ] + } + }, { "Name": "exchangeAuthToken", "Method": "POST", diff --git a/docs/compose/README.md b/docs/compose/README.md index 38dc957d8..1ad88b539 100644 --- a/docs/compose/README.md +++ b/docs/compose/README.md @@ -946,6 +946,7 @@ Compose records | --------- | ---- | ------ | ----------- | ------- | --------- | | filter | string | GET | Filtering condition | N/A | NO | | fields | []string | GET | Fields to export | N/A | YES | +| timezone | string | GET | Convert times to this timezone | N/A | NO | | filename | string | PATH | Filename to use | N/A | NO | | ext | string | PATH | Export format | N/A | YES | | namespaceID | uint64 | PATH | Namespace ID | N/A | YES | diff --git a/docs/system/README.md b/docs/system/README.md index a349162be..e52cdf5f5 100644 --- a/docs/system/README.md +++ b/docs/system/README.md @@ -254,6 +254,7 @@ Warning: implode(): Invalid arguments passed in /private/tmp/Users/darh/Work.cru | ------ | -------- | ------- | | `GET` | `/auth/` | Returns auth settings | | `GET` | `/auth/check` | Check JWT token | +| `POST` | `/auth/impersonate` | Impersonate a user | | `POST` | `/auth/exchange` | Exchange auth token for JWT | | `GET` | `/auth/logout` | Logout | @@ -283,6 +284,21 @@ Warning: implode(): Invalid arguments passed in /private/tmp/Users/darh/Work.cru | Parameter | Type | Method | Description | Default | Required? | | --------- | ---- | ------ | ----------- | ------- | --------- | +## Impersonate a user + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/auth/impersonate` | HTTP/S | POST | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| userID | uint64 | POST | ID of the impersonated user | N/A | YES | +| expire | int | POST | expiration time in seconds, must be shorter than 24h | N/A | NO | + ## Exchange auth token for JWT #### Method diff --git a/system/rest/auth.go b/system/rest/auth.go index a123e28ce..fcedc3e3d 100644 --- a/system/rest/auth.go +++ b/system/rest/auth.go @@ -68,6 +68,18 @@ func (ctrl *Auth) Logout(ctx context.Context, r *request.AuthLogout) (interface{ return true, nil } +// Impersonate implements impersonation functionality +// +// This is experimental and internals will most likely change in the future: +func (ctrl *Auth) Impersonate(ctx context.Context, r *request.AuthImpersonate) (interface{}, error) { + u, err := ctrl.authSvc.With(ctx).Impersonate(r.UserID) + if err != nil { + return nil, err + } + + return ctrl.makePayload(ctx, u) +} + func (ctrl *Auth) Settings(ctx context.Context, r *request.AuthSettings) (interface{}, error) { var ( int = ctrl.settings.Auth.Internal diff --git a/system/rest/handlers/auth.go b/system/rest/handlers/auth.go index b550f954b..5aecaafcf 100644 --- a/system/rest/handlers/auth.go +++ b/system/rest/handlers/auth.go @@ -31,6 +31,7 @@ import ( type AuthAPI interface { Settings(context.Context, *request.AuthSettings) (interface{}, error) Check(context.Context, *request.AuthCheck) (interface{}, error) + Impersonate(context.Context, *request.AuthImpersonate) (interface{}, error) ExchangeAuthToken(context.Context, *request.AuthExchangeAuthToken) (interface{}, error) Logout(context.Context, *request.AuthLogout) (interface{}, error) } @@ -39,6 +40,7 @@ type AuthAPI interface { type Auth struct { Settings func(http.ResponseWriter, *http.Request) Check func(http.ResponseWriter, *http.Request) + Impersonate func(http.ResponseWriter, *http.Request) ExchangeAuthToken func(http.ResponseWriter, *http.Request) Logout func(http.ResponseWriter, *http.Request) } @@ -85,6 +87,26 @@ func NewAuth(h AuthAPI) *Auth { resputil.JSON(w, value) } }, + Impersonate: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAuthImpersonate() + if err := params.Fill(r); err != nil { + logger.LogParamError("Auth.Impersonate", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Impersonate(r.Context(), params) + if err != nil { + logger.LogControllerError("Auth.Impersonate", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Auth.Impersonate", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, ExchangeAuthToken: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewAuthExchangeAuthToken() @@ -133,6 +155,7 @@ func (h Auth) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.H r.Use(middlewares...) r.Get("/auth/", h.Settings) r.Get("/auth/check", h.Check) + r.Post("/auth/impersonate", h.Impersonate) r.Post("/auth/exchange", h.ExchangeAuthToken) r.Get("/auth/logout", h.Logout) }) diff --git a/system/rest/request/auth.go b/system/rest/request/auth.go index fe907ad62..004269c24 100644 --- a/system/rest/request/auth.go +++ b/system/rest/request/auth.go @@ -128,6 +128,76 @@ func (r *AuthCheck) Fill(req *http.Request) (err error) { var _ RequestFiller = NewAuthCheck() +// AuthImpersonate request parameters +type AuthImpersonate struct { + hasUserID bool + rawUserID string + UserID uint64 `json:",string"` + + hasExpire bool + rawExpire string + Expire int +} + +// NewAuthImpersonate request +func NewAuthImpersonate() *AuthImpersonate { + return &AuthImpersonate{} +} + +// Auditable returns all auditable/loggable parameters +func (r AuthImpersonate) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["userID"] = r.UserID + out["expire"] = r.Expire + + return out +} + +// Fill processes request and fills internal variables +func (r *AuthImpersonate) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := post["userID"]; ok { + r.hasUserID = true + r.rawUserID = val + r.UserID = parseUInt64(val) + } + if val, ok := post["expire"]; ok { + r.hasExpire = true + r.rawExpire = val + r.Expire = parseInt(val) + } + + return err +} + +var _ RequestFiller = NewAuthImpersonate() + // AuthExchangeAuthToken request parameters type AuthExchangeAuthToken struct { hasToken bool @@ -237,6 +307,36 @@ func (r *AuthLogout) Fill(req *http.Request) (err error) { var _ RequestFiller = NewAuthLogout() +// HasUserID returns true if userID was set +func (r *AuthImpersonate) HasUserID() bool { + return r.hasUserID +} + +// RawUserID returns raw value of userID parameter +func (r *AuthImpersonate) RawUserID() string { + return r.rawUserID +} + +// GetUserID returns casted value of userID parameter +func (r *AuthImpersonate) GetUserID() uint64 { + return r.UserID +} + +// HasExpire returns true if expire was set +func (r *AuthImpersonate) HasExpire() bool { + return r.hasExpire +} + +// RawExpire returns raw value of expire parameter +func (r *AuthImpersonate) RawExpire() string { + return r.rawExpire +} + +// GetExpire returns casted value of expire parameter +func (r *AuthImpersonate) GetExpire() int { + return r.Expire +} + // HasToken returns true if token was set func (r *AuthExchangeAuthToken) HasToken() bool { return r.hasToken diff --git a/system/service/auth.go b/system/service/auth.go index 5998b1fe3..a3fc17773 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "github.com/cortezaproject/corteza-server/pkg/slice" "regexp" "strconv" "time" @@ -50,6 +51,7 @@ type ( InternalLogin(email string, password string) (*types.User, error) SetPassword(userID uint64, AuthActionPassword string) error ChangePassword(userID uint64, oldPassword, AuthActionPassword string) error + Impersonate(userID uint64) (*types.User, error) IssueAuthRequestToken(user *types.User) (token string, err error) ValidateAuthRequestToken(token string) (user *types.User, err error) @@ -595,6 +597,36 @@ func (svc auth) SetPassword(userID uint64, password string) (err error) { return svc.recordAction(svc.ctx, aam, AuthActionChangePassword, err) } +// Impersonate verifies if user can impersonate another user and returns that user +// +// For now, it's the caller's responsibility to generate the auth token +func (svc auth) Impersonate(userID uint64) (u *types.User, err error) { + var ( + aam = &authActionProps{user: u} + + identity = internalAuth.GetIdentityFromContext(svc.ctx) + ) + + err = func() error { + if !slice.HasUint64(identity.Roles(), permissions.AdminsRoleID) { + // A primitive access control + // + // For now, we do not want to add or change RBAC operation-set + // and we just check if user that wants to impersonate someone + // is member of administrators + return AuthErrNotAllowedToImpersonate() + } + + if u, err = svc.users.FindByID(userID); err != nil { + return err + } + + return err + }() + + return u, svc.recordAction(svc.ctx, aam, AuthActionImpersonate, err) +} + // ChangePassword validates old password and changes it with new func (svc auth) ChangePassword(userID uint64, oldPassword, AuthActionPassword string) (err error) { var ( diff --git a/system/service/auth_actions.gen.go b/system/service/auth_actions.gen.go index aa23af2f4..7cb810b0f 100644 --- a/system/service/auth_actions.gen.go +++ b/system/service/auth_actions.gen.go @@ -615,6 +615,26 @@ func AuthActionCreateCredentials(props ...*authActionProps) *authAction { return a } +// AuthActionImpersonate returns "system:auth.impersonate" error +// +// This function is auto-generated. +// +func AuthActionImpersonate(props ...*authActionProps) *authAction { + a := &authAction{ + timestamp: time.Now(), + resource: "system:auth", + action: "impersonate", + log: "impersonating {user}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + // ********************************************************************************************************************* // ********************************************************************************************************************* // Error constructors @@ -1163,6 +1183,36 @@ func AuthErrInvalidToken(props ...*authActionProps) *authError { } +// AuthErrNotAllowedToImpersonate returns "system:auth.notAllowedToImpersonate" audit event as actionlog.Warning +// +// +// This function is auto-generated. +// +func AuthErrNotAllowedToImpersonate(props ...*authActionProps) *authError { + var e = &authError{ + timestamp: time.Now(), + resource: "system:auth", + error: "notAllowedToImpersonate", + action: "error", + message: "not allowed to impersonate this user", + log: "not allowed to impersonate this user", + severity: actionlog.Warning, + props: func() *authActionProps { + if len(props) > 0 { + return props[0] + } + return nil + }(), + } + + if len(props) > 0 { + e.props = props[0] + } + + return e + +} + // ********************************************************************************************************************* // ********************************************************************************************************************* diff --git a/system/service/auth_actions.yaml b/system/service/auth_actions.yaml index 7ef61991d..68e2cd23f 100644 --- a/system/service/auth_actions.yaml +++ b/system/service/auth_actions.yaml @@ -65,6 +65,9 @@ actions: - action: createCredentials log: "new credentials {credentials.kind} created" + - action: impersonate + log: "impersonating {user}" + errors: - error: subscription message: "{err}" @@ -131,3 +134,7 @@ errors: - error: invalidToken message: "invalid token" severity: warning + + - error: notAllowedToImpersonate + message: "not allowed to impersonate this user" + severity: warning