From fec4386cb598ad1c8a7bdc010fbd5ab5e4f9797d Mon Sep 17 00:00:00 2001 From: B&R Date: Sat, 28 Oct 2023 22:41:04 +0200 Subject: [PATCH] feat: (#299) Scoped JWT tokens --- README.md | 2 + docs/api/users/README.md | 65 +++++ .../v1alpha1/backupcollections/iwa-ait.yaml | 3 +- main.go | 2 +- pkg/collections/model.go | 41 +-- pkg/http/auth.go | 51 ++-- pkg/http/collection.go | 23 +- pkg/http/utils.go | 19 +- pkg/security/constants.go | 33 +++ pkg/security/decision.go | 127 ++++++++++ pkg/security/decision_test.go | 234 ++++++++++++++++++ pkg/security/model.go | 32 ++- pkg/security/model_test.go | 21 ++ pkg/security/service.go | 44 +++- pkg/users/model.go | 119 ++++++--- pkg/users/repository.go | 28 +-- pkg/users/service.go | 52 +++- 17 files changed, 758 insertions(+), 138 deletions(-) create mode 100644 pkg/security/decision.go create mode 100644 pkg/security/decision_test.go create mode 100644 pkg/security/model_test.go diff --git a/README.md b/README.md index 0f13dcc7..b4a605c7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ _TLDR; Primitive backup storage for E2E GPG-encrypted files, with multi-user, qu - Configuration via GitOps (Configuration as a Code) - Multi-tenancy with configurable Quotas - Multiple cloud providers as a backend storage (all supported by [GO Cloud](https://gocloud.dev/howto/blob/#services)) +- (Security) JWT tokens with restricted scope (login endpoint can apply additional restrictions to user session) +- (Security) Extra pairs of username & passwords with different restrictions applied - for single user **Notice:** - Project is more focusing on security than on performance diff --git a/docs/api/users/README.md b/docs/api/users/README.md index 5b734532..acd93ab9 100644 --- a/docs/api/users/README.md +++ b/docs/api/users/README.md @@ -25,6 +25,7 @@ curl -s -X POST -d '{"username":"admin","password":"admin"}' -H 'Content-Type: a { "data": { "expire": "2032-02-25T00:32:56+01:00", + "msg": "Use this sessionId to revoke this token anytime", "sessionId": "2d0aa5db61c02ea9a9d7fe6d768021aca98fff1fe75fe214a65ccd6926bb8b77", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5NjEyNzgzNzYsImxvZ2luIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTY0NTkxODM3Nn0.my0WXXMxKCetkomtzRDNIKLWUm4cJ2gxyUCkuAHT6M4" }, @@ -34,6 +35,70 @@ curl -s -X POST -d '{"username":"admin","password":"admin"}' -H 'Content-Type: a The `.data.token` from response is a session secret key, use it to authenticate next API requests using `Authorization: Bearer token-here` header. +### Feature: Generating a JWT token with limited permissions + +User can create a restricted session with `/api/stable/auth/login` endpoint, by using extra parameter `operationsScope`. + +`operationsScope` extra parameter is a working like an allowlist/whitelist - optional, when specified, then User permissions +are restricted to roles specified there. + +_Notice: Roles specified in `operationsScope` cannot be higher than specified in User's profile or in collection ACL._ + +**Example:** + +```bash +curl -s -X POST -d '{"username":"some-user","password":"test", "operationsScope": {"elements": [{"type": "collection", "name": "iwa-ait", "roles": ["collectionManager"]}]}}' -H 'Content-Type: application/json' 'http://localhost:8080/api/stable/auth/login' +``` + +_Notice `"operationsScope": {"elements": [{"type": "collection", "name": "iwa-ait", "roles": ["collectionManager"]}]}` in the request body._ + +### Feature: Access Tokens + +Second way to restrict user session is by creating **Access Keys** that are separate passwords associated with same User account, but with additional restrictions. + +**Example configuration:** + +```yaml +--- +apiVersion: backups.riotkit.org/v1alpha1 +kind: BackupUser +metadata: + name: some-user +spec: + # (...) + passwordFromRef: + name: backup-repository-passwords + entry: admin + collectionAccessKeys: + # + # login: some-user$uploader + # password: test + # + - name: uploader + collections: ["iwa-ait"] + roles: ["backupUploader"] + passwordFromRef: + name: backup-repository-passwords + entry: admin_access_key_1 + # (...) +``` + +_Notice: Roles specified in `collectionAccessKeys` cannot be higher than specified in User's profile or in collection ACL._ + +**Example JSON payload:** + +```json +{"username":"some-user$uploader","password":"test"} +``` + +When using login endpoint you need to specify **Access Key** name after `$` in username field, and use password specified for that Access Key - it's not a regular password associated with the account. + +**Example:** + +```bash +curl -s -X POST -d '{"username":"some-user$uploader","password":"test", "operationsScope": {"elements": []}}' -H 'Content-Type: application/json' 'http://localhost:8080/api/stable/auth/login' +``` + ## GET `/api/stable/auth/user/some-user` Displays information about user **of given username**. diff --git a/docs/examples-filesystem/backup-repository/backups.riotkit.org/v1alpha1/backupcollections/iwa-ait.yaml b/docs/examples-filesystem/backup-repository/backups.riotkit.org/v1alpha1/backupcollections/iwa-ait.yaml index c7f0beb9..e1cd9def 100644 --- a/docs/examples-filesystem/backup-repository/backups.riotkit.org/v1alpha1/backupcollections/iwa-ait.yaml +++ b/docs/examples-filesystem/backup-repository/backups.riotkit.org/v1alpha1/backupcollections/iwa-ait.yaml @@ -27,6 +27,7 @@ spec: entry: iwa-ait accessControl: - - userName: admin + - name: admin + type: user roles: - collectionManager diff --git a/main.go b/main.go index 48465fd4..415465a7 100644 --- a/main.go +++ b/main.go @@ -104,7 +104,7 @@ func main() { ctx := core.ApplicationContainer{ Db: dbDriver, Config: &configProvider, - Users: &usersService, + Users: usersService, GrantedAccesses: &gaService, JwtSecretKey: opts.JwtSecretKey, HealthCheckKey: opts.HealthCheckKey, diff --git a/pkg/collections/model.go b/pkg/collections/model.go index dfa361b3..a340ad29 100644 --- a/pkg/collections/model.go +++ b/pkg/collections/model.go @@ -7,7 +7,6 @@ import ( "github.com/labstack/gommon/bytes" "github.com/riotkit-org/backup-repository/pkg/config" "github.com/riotkit-org/backup-repository/pkg/security" - "github.com/riotkit-org/backup-repository/pkg/users" "github.com/robfig/cron/v3" "strings" "time" @@ -143,6 +142,14 @@ type Collection struct { SecretFromSecret string } +func (c *Collection) GetTypeName() string { + return "collection" +} + +func (c *Collection) GetAccessControlList() *security.AccessControlList { + return &c.Spec.AccessControl +} + func (c *Collection) IsHealthCheckSecretValid(secret string) bool { // secret is optional if c.SecretFromSecret == "" { @@ -152,31 +159,25 @@ func (c *Collection) IsHealthCheckSecretValid(secret string) bool { } // CanUploadToMe answers if user can add new versions to the collection -func (c *Collection) CanUploadToMe(user *users.User) bool { - if user.IsInAccessKeyContext() { - if !c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupUploader) { - return false - } - scopedRoles := user.GetAccessKeyRolesInCollectionContext(c.GetId()) - return scopedRoles.HasRole(security.RoleBackupUploader) || scopedRoles.HasRole(security.RoleCollectionManager) - } - return user.GetRoles().HasRole(security.RoleCollectionManager) || user.GetRoles().HasRole(security.RoleBackupUploader) || c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupUploader) +func (c *Collection) CanUploadToMe(user security.Actor) bool { + return security.DecideCanDo(&security.DecisionRequest{ + Actor: user, + Subject: c, + Action: security.ActionUpload, + }) } // CanDownloadFromMe answers if user can download versions to this collection -func (c *Collection) CanDownloadFromMe(user *users.User) bool { - if user.IsInAccessKeyContext() { - if !c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupDownloader) { - return false - } - scopedRoles := user.GetAccessKeyRolesInCollectionContext(c.GetId()) - return scopedRoles.HasRole(security.RoleBackupDownloader) || scopedRoles.HasRole(security.RoleCollectionManager) - } - return user.GetRoles().HasRole(security.RoleCollectionManager) || user.GetRoles().HasRole(security.RoleBackupDownloader) || c.Spec.AccessControl.IsPermitted(user.Metadata.Name, security.RoleBackupDownloader) +func (c *Collection) CanDownloadFromMe(user security.Actor) bool { + return security.DecideCanDo(&security.DecisionRequest{ + Actor: user, + Subject: c, + Action: security.ActionDownload, + }) } // CanListMyVersions answers if user can list versions -func (c *Collection) CanListMyVersions(user *users.User) bool { +func (c *Collection) CanListMyVersions(user security.Actor) bool { return c.CanUploadToMe(user) || c.CanDownloadFromMe(user) } diff --git a/pkg/http/auth.go b/pkg/http/auth.go index fad6c2a8..72db193d 100644 --- a/pkg/http/auth.go +++ b/pkg/http/auth.go @@ -1,32 +1,31 @@ package http import ( + "encoding/json" "errors" "fmt" jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" "github.com/riotkit-org/backup-repository/pkg/core" "github.com/riotkit-org/backup-repository/pkg/security" - "github.com/riotkit-org/backup-repository/pkg/users" "github.com/sirupsen/logrus" "math/rand" "time" ) -const ( - IdentityKeyClaimIndex = "login" - AccessKeyClaimIndex = "accessKeyName" -) - type loginForm struct { Username string `form:"username" json:"username" binding:"required"` Password string `form:"password" json:"password" binding:"required"` + + // optional + OperationsScope security.SessionLimitedOperationsScope `form:"operationsScope" json:"operationsScope"` } type AuthUser struct { UserName string AccessKeyName string - subject users.User + + OperationsScope security.SessionLimitedOperationsScope } // Authentication middleware is used in almost every endpoint to prevalidate user credentials @@ -35,14 +34,19 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{ Realm: "backup-repository", Key: []byte(di.JwtSecretKey), - Timeout: time.Hour * 6, + Timeout: time.Hour * 6, // todo: configurable globally MaxRefresh: time.Hour * 6, - IdentityKey: IdentityKeyClaimIndex, + IdentityKey: security.IdentityKeyClaimIndex, PayloadFunc: func(data interface{}) jwt.MapClaims { if v, ok := data.(*AuthUser); ok { + scope, scopeErr := json.Marshal(v.OperationsScope) + if scopeErr != nil { + return jwt.MapClaims{} + } claims := jwt.MapClaims{ - IdentityKeyClaimIndex: v.UserName, - AccessKeyClaimIndex: v.AccessKeyName, + security.IdentityKeyClaimIndex: v.UserName, + security.AccessKeyClaimIndex: v.AccessKeyName, + security.ScopeClaimIndex: scope, } rand.Seed(time.Now().UnixNano()) claims["rand"] = fmt.Sprintf("%v", rand.Intn(64)) @@ -61,9 +65,21 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer // login user claims := jwt.ExtractClaims(c) + username := claims[security.IdentityKeyClaimIndex].(string) + + var opScope security.SessionLimitedOperationsScope + err := json.Unmarshal([]byte(claims[security.ScopeClaimIndex].(string)), &opScope) + if err != nil { + logrus.Warnf("Failed to unpack operations scope for %s", username) + return nil + } + return &AuthUser{ - UserName: claims[IdentityKeyClaimIndex].(string), - AccessKeyName: claims[AccessKeyClaimIndex].(string), + UserName: username, + AccessKeyName: claims[security.AccessKeyClaimIndex].(string), + + // optional + OperationsScope: opScope, } }, Authenticator: func(c *gin.Context) (interface{}, error) { @@ -77,7 +93,7 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer password := loginValues.Password userIdentity := security.NewUserIdentityFromString(login) - user, err := di.Users.LookupUser(login) + user, err := di.Users.LookupSessionUser(userIdentity, &loginValues.OperationsScope) logrus.Infof("Looking up user '%s' (%s)", userIdentity.Username, login) if err != nil { logrus.Errorf("User lookup error: %v", err) @@ -92,6 +108,9 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer return &AuthUser{ UserName: userIdentity.Username, AccessKeyName: userIdentity.AccessKeyName, + + // optional values + OperationsScope: loginValues.OperationsScope, }, nil }, Authorizator: func(data interface{}, c *gin.Context) bool { @@ -123,6 +142,7 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer "token": token, "sessionId": hashedShortcut, "expire": expire.Format(time.RFC3339), + "msg": "Use this sessionId to revoke this token anytime", }) }, }) @@ -139,7 +159,8 @@ func createAuthenticationMiddleware(r *gin.Engine, di *core.ApplicationContainer func addLookupUserRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, rateLimiter gin.HandlerFunc) { r.GET("/api/stable/auth/user/:userName", rateLimiter, func(c *gin.Context) { // subject - user, err := ctx.Users.LookupUser(c.Param("userName")) + identity := security.NewUserIdentityFromString(c.Param("userName")) + user, err := ctx.Users.LookupUser(identity) if err != nil { NotFoundResponse(c, err) return diff --git a/pkg/http/collection.go b/pkg/http/collection.go index 7472003b..db826e71 100644 --- a/pkg/http/collection.go +++ b/pkg/http/collection.go @@ -19,8 +19,6 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT timeoutMiddleware := timeout.New( timeout.WithTimeout(requestTimeout), timeout.WithHandler(func(c *gin.Context) { - // todo: deactivate token if temporary token is used - ctxUser, _ := GetContextUser(ctx, c) // Check if Collection exists @@ -37,12 +35,17 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT } // [SECURITY] Backup Windows support - if !ctx.Collections.ValidateIsBackupWindowAllowingToUpload(collection, time.Now()) && - !ctxUser.GetRoles().HasRole(security.RoleUploadsAnytime) { - - UnauthorizedResponse(c, errors.New("backup window does not allow you to send a backup at this time. "+ - "You need a token from a user that has a special permission 'uploadsAnytime'")) - return + if !ctx.Collections.ValidateIsBackupWindowAllowingToUpload(collection, time.Now()) { + uploadAnyTimeRequest := security.DecisionRequest{ + Actor: ctxUser, + Subject: collection, + Action: security.ActionUploadAnytime, + } + if !security.DecideCanDo(&uploadAnyTimeRequest) { + UnauthorizedResponse(c, errors.New("backup window does not allow you to send a backup at this time. "+ + "You need a token that has a special permission 'uploadsAnytime'")) + return + } } // [SECURITY] Do not allow parallel uploads to the same collection @@ -84,14 +87,15 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT return } stream, openErr = fh.Open() + defer stream.Close() if openErr != nil { ServerErrorResponse(c, errors.New(fmt.Sprintf("cannot open file from multipart/urlencoded form: %v", openErr))) } - defer stream.Close() } else { // [HTTP] Support RAW sent data via body stream = c.Request.Body + defer stream.Close() } // [VALIDATION] Middlewares @@ -106,7 +110,6 @@ func addUploadRoute(r *gin.RouterGroup, ctx *core.ApplicationContainer, requestT wroteLen, uploadError := ctx.Storage.UploadFile(c.Request.Context(), stream, &version, &middlewares) if uploadError != nil { _ = ctx.Storage.Delete(&version) - ServerErrorResponse(c, errors.New(fmt.Sprintf("cannot upload version. %v", uploadError))) return } diff --git a/pkg/http/utils.go b/pkg/http/utils.go index 413c6622..125f8bba 100644 --- a/pkg/http/utils.go +++ b/pkg/http/utils.go @@ -1,21 +1,32 @@ package http import ( + "errors" + "fmt" "github.com/gin-gonic/gin" "github.com/riotkit-org/backup-repository/pkg/core" "github.com/riotkit-org/backup-repository/pkg/security" "github.com/riotkit-org/backup-repository/pkg/users" + "strings" ) // GetContextUser returns a User{} that is authenticated in current request -func GetContextUser(ctx *core.ApplicationContainer, c *gin.Context) (*users.User, error) { +func GetContextUser(ctx *core.ApplicationContainer, c *gin.Context) (*users.SessionAwareUser, error) { username, accessKeyName := security.ExtractLoginFromJWT(c.GetHeader("Authorization")) + scope, scopeErr := security.ExtractSessionLimitedOperationsScopeFromJWT(c.GetHeader("Authorization")) + if scopeErr != nil { + return nil, errors.New(fmt.Sprintf("cannot create context user: %s", scopeErr.Error())) + } + + identity := security.NewUserIdentityFromString(username) + identity.AccessKeyName = accessKeyName + if accessKeyName != "" { - return ctx.Users.LookupUser(username + "$" + accessKeyName) + return ctx.Users.LookupSessionUser(identity, scope) } - return ctx.Users.LookupUser(username) + return ctx.Users.LookupSessionUser(identity, scope) } func GetCurrentSessionId(c *gin.Context) string { - return security.HashJWT(c.GetHeader("Authorization")[7:]) + return security.HashJWT(strings.Trim(c.GetHeader("Authorization"), " ")[7:]) } diff --git a/pkg/security/constants.go b/pkg/security/constants.go index aec9f920..dc7d33e4 100644 --- a/pkg/security/constants.go +++ b/pkg/security/constants.go @@ -10,3 +10,36 @@ const ( RoleUploadsAnytime = "uploadsAnytime" RoleSysAdmin = "systemAdmin" ) + +const ( + ActionDownload = "file.download" + ActionUpload = "file.upload" + ActionUploadAnytime = "file.upload-anytime" + ActionViewProfile = "profile.view" +) + +var AllActions = []string{ + ActionDownload, ActionUpload, ActionUploadAnytime, ActionViewProfile, +} + +func GetRolesInheritance() map[string][]string { + return map[string][]string{ + RoleSysAdmin: {RoleUserManager, RoleCollectionManager}, + RoleCollectionManager: {RoleBackupDownloader, RoleBackupDownloader, RoleUploadsAnytime}, + } +} + +func GetRolesActions() map[string][]string { + return map[string][]string{ + RoleBackupDownloader: {ActionDownload}, + RoleBackupUploader: {ActionUpload}, + RoleUploadsAnytime: {ActionUpload, ActionUploadAnytime}, + RoleUserManager: {ActionViewProfile}, + } +} + +const ( + IdentityKeyClaimIndex = "login" + AccessKeyClaimIndex = "accessKeyName" + ScopeClaimIndex = "operationsScope" +) diff --git a/pkg/security/decision.go b/pkg/security/decision.go new file mode 100644 index 00000000..b62e40a7 --- /dev/null +++ b/pkg/security/decision.go @@ -0,0 +1,127 @@ +package security + +import "k8s.io/utils/strings/slices" + +type DecisionRequest struct { + Actor Actor + Subject Subject + Action string +} + +// DecideCanDo is taking a decision if specific Action can be made on by Actor on a Subject +// +// Logic: +// +// 1. Subject defines who can access it and how +// +// 2. There are SYSTEM-WIDE roles defined on the user that allows globally do everything +// +// 3. User can generate a LIMITED SCOPE JWT token with /auth/login endpoint. This kind of token can +// define that in context of given Subject the roles should be limited to specific ones +// Important! Those roles cannot be higher than defined on the Subject or on the Actor in its profile +// +// Cases: +// +// Has limited token: User generates JWT with "backupDownloader" role in context of "iwa-ait" collection. +// So even if that User is a "collectionManager" for this collection, with that specific JWT token +// its possible to only download backups. +func DecideCanDo(dr *DecisionRequest) bool { + // CASE: Decision about global action, not in context of a collection + // For example - to see a system health check endpoint + if dr.Subject == nil { + return CanThoseRolesPerformAction(dr.Actor.GetRoles(), dr.Action) + } + + // CASE: If we are in a context of an Access Key, then it has its own limited scope + if dr.Actor.IsInAccessKeyContext() { + scopedRoles := dr.Actor.GetAccessKeyRolesInContextOf(dr.Subject) + if !CanThoseRolesPerformAction(scopedRoles, dr.Action) { + return false + } + } + + if hasCurrentTokenLimitations(dr.Actor) { + limitations := dr.Actor.GetSessionLimitedOperationsScope() + foundAllowing := false + + for _, object := range limitations.Elements { + if object.Type == dr.Subject.GetTypeName() && object.Name == dr.Subject.GetId() { + foundAllowing = CanThoseRolesPerformAction(object.Roles, dr.Action) + } + } + + // CASE: User has a limited token generated, and no any entry in `operationsScope` field is + // matching Subject for given Action + if !foundAllowing { + return false + } + } + + // CASE: User is explicitly listed in object's ACL, that it owns this object + objectSpecificDecision := dr.Subject.GetAccessControlList().IsPermitted(dr.Actor.GetName(), dr.Actor.GetTypeName(), dr.Action) + + // CASE: e.g. is a system administrator + systemWideRoleDecision := CanThoseRolesPerformAction(dr.Actor.GetRoles(), dr.Action) + + return objectSpecificDecision || systemWideRoleDecision +} + +// CanThoseRolesPerformAction checks if any listed role is allowing to perform action +func CanThoseRolesPerformAction(roles []string, action string) bool { + return slices.Contains(expandActions(expandRoles(roles)), action) +} + +func expandRoles(roles []string) []string { + inheritance := GetRolesInheritance() + expanded := make([]string, 0) + expanded = append(expanded, roles...) + + // level: 0 + for _, role := range roles { + children, expandable := inheritance[role] + + if expandable { + for _, element := range children { + if !slices.Contains(roles, element) { + expanded = append(expanded, element) + } + expanded = append(expanded, expandRoles([]string{element})...) + } + } + } + return expanded +} + +func expandActions(roles []string) []string { + mapping := GetRolesActions() + actions := make([]string, 0) + for _, role := range roles { + if roleActions, exists := mapping[role]; exists { + actions = append(actions, roleActions...) + } + } + return actions +} + +func hasCurrentTokenLimitations(a Actor) bool { + if a.GetSessionLimitedOperationsScope() == nil || a.GetSessionLimitedOperationsScope().Elements == nil { + return false + } + return len(a.GetSessionLimitedOperationsScope().Elements) > 0 +} + +type Actor interface { + IsInAccessKeyContext() bool + GetAccessKeyRolesInContextOf(Subject) Roles + GetRoles() Roles + GetEmail() string + GetName() string + GetTypeName() string + GetSessionLimitedOperationsScope() *SessionLimitedOperationsScope +} + +type Subject interface { + GetId() string + GetTypeName() string + GetAccessControlList() *AccessControlList +} diff --git a/pkg/security/decision_test.go b/pkg/security/decision_test.go new file mode 100644 index 00000000..8163f390 --- /dev/null +++ b/pkg/security/decision_test.go @@ -0,0 +1,234 @@ +package security_test + +import ( + "github.com/riotkit-org/backup-repository/pkg/security" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestCanThoseRolesPerformAction(t *testing.T) { + // Multiple levels - a role is expanded two times + // RoleSysAdmin -> RoleCollectionManager -> RoleBackupDownloader -> [ActionDownload] + assert.Equal(t, true, security.CanThoseRolesPerformAction([]string{ + security.RoleSysAdmin, + }, security.ActionDownload)) + + // Two levels - expanded one time + assert.Equal(t, true, security.CanThoseRolesPerformAction([]string{ + security.RoleCollectionManager, + }, security.ActionDownload)) + + // Direct role + assert.Equal(t, true, security.CanThoseRolesPerformAction([]string{ + security.RoleBackupDownloader, + }, security.ActionDownload)) + + // RoleBackupUploader ! -> ActionDownload + assert.Equal(t, false, security.CanThoseRolesPerformAction([]string{ + security.RoleBackupUploader, + }, security.ActionDownload)) +} + +type TestActor struct { + name string + isInAccessKeyContext bool + roles security.Roles + accessTokenContextualRoles security.Roles + jwtScopeLimitations *security.SessionLimitedOperationsScope +} + +func (a *TestActor) IsInAccessKeyContext() bool { + return a.isInAccessKeyContext +} + +func (a *TestActor) GetAccessKeyRolesInContextOf(subject security.Subject) security.Roles { + return a.accessTokenContextualRoles +} + +func (a *TestActor) GetRoles() security.Roles { + return a.roles +} + +func (a *TestActor) GetEmail() string { + return "" +} + +func (a *TestActor) GetName() string { + return a.name +} + +func (a *TestActor) GetTypeName() string { + return "user" +} + +func (a *TestActor) GetSessionLimitedOperationsScope() *security.SessionLimitedOperationsScope { + return a.jwtScopeLimitations +} + +type FakeSubject struct { + id string + typeName string + acl *security.AccessControlList +} + +func (fs *FakeSubject) GetId() string { + return fs.id +} + +func (fs *FakeSubject) GetTypeName() string { + return fs.typeName +} + +func (fs *FakeSubject) GetAccessControlList() *security.AccessControlList { + return fs.acl +} + +func TestDecideCanDo_AsSysAdminCanDoEverything(t *testing.T) { + actor := TestActor{ + name: "bakunin", + isInAccessKeyContext: false, + roles: security.Roles{security.RoleSysAdmin}, + jwtScopeLimitations: &security.SessionLimitedOperationsScope{Elements: []security.ScopedElement{}}, + } + + for _, action := range security.AllActions { + assert.True(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: &actor, + Subject: nil, + Action: action, + })) + } +} + +func TestDecideCanDo_AsSysAdminCantDoActionWhenJWTForbids(t *testing.T) { + actor := &TestActor{ + name: "bakunin", + isInAccessKeyContext: false, + + // the user is ADMIN. Can do everything + roles: security.Roles{security.RoleSysAdmin}, + + // no access token limitations are applied + accessTokenContextualRoles: security.Roles{}, + + // JWT token is limited to only DOWNLOAD + jwtScopeLimitations: &security.SessionLimitedOperationsScope{Elements: []security.ScopedElement{ + {Type: "collection", Name: "iwa-ait", Roles: security.Roles{security.RoleBackupDownloader}}, + }}, + } + subject := &FakeSubject{ + id: "iwa-ait", + typeName: "collection", + acl: &security.AccessControlList{ + security.AccessControlObject{ + Name: "bakunin", + Type: "user", + + // the collection allows user to UPLOAD & DOWNLOAD explicitly + Roles: security.Roles{ + security.RoleBackupDownloader, + security.RoleBackupUploader, + }, + }, + }, + } + + // CAN'T do: /auth/login generated a JWT that allows only to DOWNLOAD + assert.False(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: actor, + Subject: subject, + Action: security.ActionUpload, + })) + + // CAN DO: download is allowed by JWT limitations + assert.True(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: actor, + Subject: subject, + Action: security.ActionDownload, + })) +} + +func TestDecideCanDo_AsSysAdminCantDoActionWhenAccessTokenForbids(t *testing.T) { + actor := &TestActor{ + name: "bakunin", + isInAccessKeyContext: true, + + // the user is ADMIN. Can do everything + roles: security.Roles{security.RoleSysAdmin}, + + // ACCESS TOKEN is limiting roles + accessTokenContextualRoles: security.Roles{ + security.RoleBackupDownloader, + }, + + // JWT token is NOT limiting anything + jwtScopeLimitations: &security.SessionLimitedOperationsScope{Elements: []security.ScopedElement{}}, + } + subject := &FakeSubject{ + id: "iwa-ait", + typeName: "collection", + acl: &security.AccessControlList{ + security.AccessControlObject{ + Name: "bakunin", + Type: "user", + + // the collection allows user to UPLOAD & DOWNLOAD explicitly + Roles: security.Roles{ + security.RoleBackupDownloader, + security.RoleBackupUploader, + }, + }, + }, + } + + // CAN'T do: user logged in with ACCESS TOKEN that limits action to DOWNLOAD only + assert.False(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: actor, + Subject: subject, + Action: security.ActionUpload, + })) + + // CAN DO: download is allowed + assert.True(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: actor, + Subject: subject, + Action: security.ActionDownload, + })) +} + +func TestDecideCanDo_AsCollectionManagerICanManageCollection(t *testing.T) { + actor := &TestActor{ + name: "bakunin", + isInAccessKeyContext: false, + + // No global roles at all + roles: security.Roles{}, + + // No contextual limitations at all + accessTokenContextualRoles: security.Roles{}, + jwtScopeLimitations: &security.SessionLimitedOperationsScope{Elements: []security.ScopedElement{}}, + } + subject := &FakeSubject{ + id: "iwa-ait", + typeName: "collection", + acl: &security.AccessControlList{ + security.AccessControlObject{ + Name: "bakunin", + Type: "user", + + // User is a Collection Manager in this collection + Roles: security.Roles{ + security.RoleCollectionManager, + }, + }, + }, + } + + for _, role := range []string{security.ActionUpload, security.ActionUploadAnytime, security.ActionDownload} { + assert.True(t, security.DecideCanDo(&security.DecisionRequest{ + Actor: actor, + Subject: subject, + Action: role, + })) + } +} diff --git a/pkg/security/model.go b/pkg/security/model.go index f014dcd1..c223cbf4 100644 --- a/pkg/security/model.go +++ b/pkg/security/model.go @@ -7,21 +7,32 @@ import ( "time" ) +type ScopedElement struct { + Type string `form:"type" json:"type" binding:"required"` + Name string `form:"name" json:"name" binding:"required"` + Roles []string `form:"roles" json:"roles" binding:"required"` +} + +// SessionLimitedOperationsScope allows to define additional limitations on the user's JWT token, so even if user has higher permissions we can limit those permissions per JWT token +type SessionLimitedOperationsScope struct { + Elements []ScopedElement `form:"elements" json:"elements"` +} + // -// User permissions +// User permissions - roles // -type Permissions []string +type Roles []string -func (p Permissions) IsEmpty() bool { +func (p Roles) IsEmpty() bool { return len(p) == 0 } -func (p Permissions) HasRole(name string) bool { +func (p Roles) HasRole(name string) bool { return p.has(name) || p.has(RoleSysAdmin) } -func (p Permissions) has(name string) bool { +func (p Roles) has(name string) bool { for _, cursor := range p { if cursor == name { return true @@ -32,20 +43,21 @@ func (p Permissions) has(name string) bool { } // -// Permissions for objects +// Roles for objects // type AccessControlObject struct { - UserName string `json:"userName"` - Roles Permissions `json:"roles"` + Name string `json:"name"` + Type string `json:"type"` + Roles Roles `json:"roles"` } type AccessControlList []AccessControlObject // IsPermitted checks if given user is granted a role in this list -func (acl AccessControlList) IsPermitted(username string, role string) bool { +func (acl AccessControlList) IsPermitted(name string, objType string, action string) bool { for _, permitted := range acl { - if permitted.UserName == username && permitted.Roles.HasRole(role) { + if permitted.Name == name && permitted.Type == objType && CanThoseRolesPerformAction(permitted.Roles, action) { return true } } diff --git a/pkg/security/model_test.go b/pkg/security/model_test.go new file mode 100644 index 00000000..939217b7 --- /dev/null +++ b/pkg/security/model_test.go @@ -0,0 +1,21 @@ +package security_test + +import ( + "github.com/riotkit-org/backup-repository/pkg/security" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewUserIdentityFromString_WithAccessKey(t *testing.T) { + identity := security.NewUserIdentityFromString("hello$161") + + assert.Equal(t, "hello", identity.Username) + assert.Equal(t, "161", identity.AccessKeyName) +} + +func TestNewUserIdentityFromString(t *testing.T) { + identity := security.NewUserIdentityFromString("my-name-is-borat") + + assert.Equal(t, "my-name-is-borat", identity.Username) + assert.Equal(t, "", identity.AccessKeyName) +} diff --git a/pkg/security/service.go b/pkg/security/service.go index 11a6d759..5d4b227a 100644 --- a/pkg/security/service.go +++ b/pkg/security/service.go @@ -2,6 +2,7 @@ package security import ( "encoding/base64" + "encoding/json" "errors" "fmt" "github.com/riotkit-org/backup-repository/pkg/config" @@ -66,26 +67,55 @@ func NewService(db *gorm.DB) Service { } } -// ExtractLoginFromJWT returns username of a user that owns this token -func ExtractLoginFromJWT(jwt string) (string, string) { +func extractJsonFromJWT(jwt string) (string, error) { // optionally extract token from Authorization header if strings.HasPrefix(jwt, "Bearer") { jwt = jwt[7:] } - split := strings.SplitN(jwt, ".", 3) json, err := base64.RawStdEncoding.DecodeString(split[1]) if err != nil { - logrus.Errorf("Cannot extract login from JWT, %v", err) - return "", "" + return "", errors.New(fmt.Sprintf("cannot extract JSON from JWT: %s", err.Error())) + } + return string(json), nil +} + +// ExtractLoginFromJWT returns username of a user that owns this token +func ExtractLoginFromJWT(jwt string) (string, string) { + json, err := extractJsonFromJWT(jwt) + if err != nil { + logrus.Warnf("invalid JWT format: %s", err.Error()) } - username := gjson.Get(string(json), "login") - accessKeyName := gjson.Get(string(json), "accessKeyName") + username := gjson.Get(json, "login") + accessKeyName := gjson.Get(json, "accessKeyName") return username.String(), accessKeyName.String() } +func ExtractSessionLimitedOperationsScopeFromJWT(jwt string) (*SessionLimitedOperationsScope, error) { + asJson, err := extractJsonFromJWT(jwt) + if err != nil { + logrus.Warnf("invalid JWT format: %s", err.Error()) + } + + scope := &SessionLimitedOperationsScope{} + scopeJson := gjson.Get(asJson, ScopeClaimIndex) + + if scopeJson.Exists() { + scopeAsTxtJson, decodeErr := base64.StdEncoding.DecodeString(scopeJson.String()) + if decodeErr != nil { + return nil, errors.New(fmt.Sprintf("cannot base64 decode operations scope from JWT: %s", decodeErr.Error())) + } + + scopeErr := json.Unmarshal([]byte(scopeAsTxtJson), &scope) + if scopeErr != nil { + return nil, errors.New(fmt.Sprintf("cannot unpack operations scope from JWT: %s", scopeErr.Error())) + } + } + return scope, nil +} + // FillPasswordFromKindSecret is able to fill up object from a data retrieved from `kind: Secret` in Kubernetes func FillPasswordFromKindSecret(r config.ConfigurationProvider, ref *PasswordFromSecretRef, setterCallback func(secret string)) error { if ref.Name != "" { diff --git a/pkg/users/model.go b/pkg/users/model.go index a414ca27..dafddcfd 100644 --- a/pkg/users/model.go +++ b/pkg/users/model.go @@ -4,21 +4,19 @@ import ( "github.com/riotkit-org/backup-repository/pkg/config" "github.com/riotkit-org/backup-repository/pkg/security" "github.com/sirupsen/logrus" - "k8s.io/utils/strings/slices" ) type CollectionAccessKey struct { Name string `json:"name"` Password string `json:"password"` // master password to this account, allows access limited by PasswordFromRef security.PasswordFromSecretRef `json:"passwordFromRef"` - Collections []string `json:"collections"` - Roles security.Permissions `json:"roles"` // roles allowed in context of all listed collections. Limited by User global roles and Collection roles + Objects []security.AccessControlObject `json:"objects"` } type Spec struct { Id string `json:"id"` Email string `json:"email"` - Roles security.Permissions `json:"roles"` + Roles security.Roles `json:"roles"` Password string `json:"password"` // master password to this account, allows access limited by PasswordFromRef security.PasswordFromSecretRef `json:"passwordFromRef"` CollectionAccessKeys []*CollectionAccessKey `json:"collectionAccessKeys"` @@ -30,44 +28,96 @@ type User struct { // Dynamic property: User's password hash passwordFromSecret string +} + +func (u User) GetRoles() security.Roles { + return u.Spec.Roles +} + +// getPasswordHash is returning a hashed password (Argon2 hash for comparison) +func (u User) getPasswordHash() string { + if u.passwordFromSecret != "" { + return u.passwordFromSecret + } + return u.Spec.Password +} + +// IsPasswordValid is checking if User supplied password matches User's main password +func (u User) isPasswordValid(password string) bool { + result, err := security.ComparePassword(password, u.getPasswordHash()) + if err != nil { + logrus.Errorf("Cannot decode password: '%v'", err) + } + return result +} + +// +// Security / RBAC +// + +// CanViewMyProfile RBAC method +func (u User) CanViewMyProfile(actor security.Actor) bool { + // rbac + if actor.GetRoles().HasRole(security.RoleUserManager) { + return true + } + + // user can view self info + return u.Spec.Email == actor.GetEmail() +} + +func NewSessionAwareUser(u *User, scope *security.SessionLimitedOperationsScope) *SessionAwareUser { + return &SessionAwareUser{ + User: u, + sessionScope: scope, + } +} + +type SessionAwareUser struct { + *User // Dynamic property: Copy of .spec.CollectionAccessKeys with password field filled up accessKeysFromSecret []*CollectionAccessKey // Dynamic property: Access key used in current session currentAccessKey *CollectionAccessKey + + // Dynamic property: Read from JWT token - operations scope, limited per session/token + sessionScope *security.SessionLimitedOperationsScope } -func (u User) GetRoles() security.Permissions { - return u.Spec.Roles +func (sau *SessionAwareUser) GetSessionLimitedOperationsScope() *security.SessionLimitedOperationsScope { + return sau.sessionScope } -// IsInAccessKeyContext returns true, when User is authenticated using CollectionAccessKey in current context -func (u User) IsInAccessKeyContext() bool { - return u.currentAccessKey != nil +func (sau *SessionAwareUser) GetEmail() string { + return sau.Spec.Email } -// GetAccessKeyRolesInCollectionContext is returning User permissions in context of a Collection, limited by CollectionAccessKey roles -func (u User) GetAccessKeyRolesInCollectionContext(collectionId string) security.Permissions { - if u.currentAccessKey != nil && slices.Contains(u.currentAccessKey.Collections, collectionId) { - return u.currentAccessKey.Roles - } - return security.Permissions{} +func (sau *SessionAwareUser) GetTypeName() string { + return "user" } -// getPasswordHash is returning a hashed password (Argon2 hash for comparison) -func (u User) getPasswordHash() string { - if u.passwordFromSecret != "" { - return u.passwordFromSecret +func (sau *SessionAwareUser) IsInAccessKeyContext() bool { + return sau.currentAccessKey != nil +} + +func (sau *SessionAwareUser) GetAccessKeyRolesInContextOf(subject security.Subject) security.Roles { + if sau.currentAccessKey != nil { + for _, object := range sau.currentAccessKey.Objects { + if object.Type == subject.GetTypeName() && object.Name == subject.GetId() { + return object.Roles + } + } } - return u.Spec.Password + return security.Roles{} } // IsPasswordValid is checking if User supplied password matches User's main password, // or CollectionAccessKey password - depending on accessKeyName parameter -func (u User) IsPasswordValid(password string, accessKeyName string) bool { +func (sau *SessionAwareUser) IsPasswordValid(password string, accessKeyName string) bool { if accessKeyName != "" { - for _, accessKey := range u.accessKeysFromSecret { + for _, accessKey := range sau.accessKeysFromSecret { if accessKey.Name == accessKeyName { result, err := security.ComparePassword(password, accessKey.Password) if err != nil { @@ -76,28 +126,17 @@ func (u User) IsPasswordValid(password string, accessKeyName string) bool { return result } } - logrus.Warnf("Invalid access key '%s' requested for user '%s'", accessKeyName, u.Metadata.Name) + logrus.Warnf("Invalid access key '%s' requested for user '%s'", accessKeyName, sau.Metadata.Name) return false } - result, err := security.ComparePassword(password, u.getPasswordHash()) - if err != nil { - logrus.Errorf("Cannot decode password: '%v'", err) - } - return result + return sau.isPasswordValid(password) } -// -// Security / RBAC -// - -// CanViewMyProfile RBAC method -func (u User) CanViewMyProfile(actor *User) bool { - // rbac - if actor.GetRoles().HasRole(security.RoleUserManager) { - return true - } +func (sau *SessionAwareUser) GetRoles() security.Roles { + return sau.User.GetRoles() +} - // user can view self info - return u.Spec.Email == actor.Spec.Email +func (sau *SessionAwareUser) GetName() string { + return sau.User.Metadata.Name } diff --git a/pkg/users/repository.go b/pkg/users/repository.go index 2258d0b7..20295953 100644 --- a/pkg/users/repository.go +++ b/pkg/users/repository.go @@ -14,8 +14,8 @@ type userRepository struct { config.ConfigurationProvider } -func (r userRepository) findUserByLogin(identity security.UserIdentity) (*User, error) { - doc, retrieveErr := r.GetSingleDocument(KindBackupUser, identity.Username) +func (r userRepository) findUserByLogin(login string) (*User, error) { + doc, retrieveErr := r.GetSingleDocument(KindBackupUser, login) user := User{} if retrieveErr != nil { return &user, errors.New(fmt.Sprintf("IsError retrieving user: %v", retrieveErr)) @@ -24,14 +24,14 @@ func (r userRepository) findUserByLogin(identity security.UserIdentity) (*User, if err := json.Unmarshal([]byte(doc), &user); err != nil { return &User{}, err } - if hydrateErr := r.hydrate(&user, identity.AccessKeyName); hydrateErr != nil { + if hydrateErr := r.hydrate(&user); hydrateErr != nil { return &User{}, hydrateErr } return &user, nil } -func (r userRepository) hydrate(user *User, currentlyUsedAccessKeyName string) error { +func (r userRepository) hydrate(user *User) error { // password passwordSetter := func(password string) { user.passwordFromSecret = password @@ -39,25 +39,5 @@ func (r userRepository) hydrate(user *User, currentlyUsedAccessKeyName string) e if fillErr := security.FillPasswordFromKindSecret(r, &user.Spec.PasswordFromRef, passwordSetter); fillErr != nil { return errors.Wrap(fillErr, "cannot fetch password") } - - // access keys - accessKeys := make([]*CollectionAccessKey, 0) - user.currentAccessKey = nil - for _, accessKey := range user.Spec.CollectionAccessKeys { - ak := *accessKey - if ak.Password == "" && ak.PasswordFromRef.Name != "" { - hashSetter := func(password string) { - ak.Password = password - } - if hashFillErr := security.FillPasswordFromKindSecret(r, &ak.PasswordFromRef, hashSetter); hashFillErr != nil { - return errors.Wrap(hashFillErr, "cannot fetch access key") - } - } - if accessKey.Name == currentlyUsedAccessKeyName { - user.currentAccessKey = &ak - } - accessKeys = append(accessKeys, &ak) - } - user.accessKeysFromSecret = accessKeys return nil } diff --git a/pkg/users/service.go b/pkg/users/service.go index 41b56617..ea187717 100644 --- a/pkg/users/service.go +++ b/pkg/users/service.go @@ -1,21 +1,61 @@ package users import ( + "github.com/pkg/errors" "github.com/riotkit-org/backup-repository/pkg/config" "github.com/riotkit-org/backup-repository/pkg/security" ) type Service struct { - userRepository + repository userRepository + config config.ConfigurationProvider } // NewUsersService is a factory method -func NewUsersService(provider config.ConfigurationProvider) Service { - return Service{ - userRepository{provider}, +func NewUsersService(provider config.ConfigurationProvider) *Service { + return &Service{ + repository: userRepository{provider}, + config: provider, } } -func (a Service) LookupUser(login string) (*User, error) { - return a.findUserByLogin(security.NewUserIdentityFromString(login)) +func (a *Service) LookupUser(identity security.UserIdentity) (*User, error) { + return a.repository.findUserByLogin(identity.Username) +} + +func (a *Service) LookupSessionUser(identity security.UserIdentity, scope *security.SessionLimitedOperationsScope) (*SessionAwareUser, error) { + user, findErr := a.repository.findUserByLogin(identity.Username) + if findErr != nil { + return nil, errors.Wrap(findErr, "LookupSessionUser error, cannot find user") + } + + saUser := NewSessionAwareUser(user, scope) + if err := a.fillUpAccessToken(saUser, identity.AccessKeyName); err != nil { + return nil, errors.Wrap(err, "LookupSessionUser error") + } + + return saUser, nil +} + +func (a *Service) fillUpAccessToken(saUser *SessionAwareUser, currentlyUsedAccessKeyName string) error { + // access keys + accessKeys := make([]*CollectionAccessKey, 0) + saUser.currentAccessKey = nil + for _, accessKey := range saUser.Spec.CollectionAccessKeys { + ak := *accessKey + if ak.Password == "" && ak.PasswordFromRef.Name != "" { + hashSetter := func(password string) { + ak.Password = password + } + if hashFillErr := security.FillPasswordFromKindSecret(a.config, &ak.PasswordFromRef, hashSetter); hashFillErr != nil { + return errors.Wrap(hashFillErr, "cannot fetch access key") + } + } + if accessKey.Name == currentlyUsedAccessKeyName { + saUser.currentAccessKey = &ak + } + accessKeys = append(accessKeys, &ak) + } + saUser.accessKeysFromSecret = accessKeys + return nil }