From 1e94a37a8a9a45696b4948e62099d96754b5f75b Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Mon, 6 Oct 2025 14:22:08 +0300 Subject: [PATCH 01/14] feat: itialimplementation for rbac service with store interface --- .github/workflows/go.yml | 27 ++++++++ go.mod | 3 + pkg/errors.go | 10 +++ pkg/rbac.go | 134 +++++++++++++++++++++++++++++++++++++++ pkg/store.go | 28 ++++++++ pkg/types.go | 27 ++++++++ 6 files changed, 229 insertions(+) create mode 100644 .github/workflows/go.yml create mode 100644 go.mod create mode 100644 pkg/errors.go create mode 100644 pkg/rbac.go create mode 100644 pkg/store.go create mode 100644 pkg/types.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..305a689 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,27 @@ +name: Go + +on: + pull_request: + branches: + - main + push: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 1.25 + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + args: run + - name: Format + uses: Jerome1337/gofmt-action@v1.0.5 + + - name: Run tests + run: go test -v ./... \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0170a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module rbac + +go 1.24.6 diff --git a/pkg/errors.go b/pkg/errors.go new file mode 100644 index 0000000..43900a1 --- /dev/null +++ b/pkg/errors.go @@ -0,0 +1,10 @@ +package rbac + +var ( + ErrNotFound = errorString("not found") + ErrAlreadyExists = errorString("already exists") +) + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/pkg/rbac.go b/pkg/rbac.go new file mode 100644 index 0000000..0efa66e --- /dev/null +++ b/pkg/rbac.go @@ -0,0 +1,134 @@ +package rbac + +import ( + "context" + "strings" +) + +type RBAC struct { + store Store +} + +func New(s Store) *RBAC { + return &RBAC{store: s} +} + +func (r *RBAC) CreateRole(ctx context.Context, role Role) error { + role.Name = strings.ToLower(role.Name) + return r.store.CreateRole(ctx, role) +} + +func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { + return r.store.RemoveRole(ctx, roleID) +} + +func (r *RBAC) CreatePermission(ctx context.Context, p Permission) error { + p.Resource = strings.ToLower(p.Resource) + p.Action = strings.ToLower(p.Action) + return r.store.CreatePermission(ctx, p) +} + +func (r *RBAC) RemovePermission(ctx context.Context, id string) error { + return r.store.RemovePermission(ctx, id) +} + +func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { + return r.store.AssignRole(ctx, subjectID, roleID) +} + +func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { + return r.store.RevokeRole(ctx, subjectID, roleID) +} + +func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID string, permID string) error { + role, err := r.store.GetRole(ctx, roleID) + if err != nil { + return err + } + for _, p := range role.Permissions { + if p.ID == permID { + return ErrAlreadyExists + } + } + p, err := r.store.GetPermission(ctx, permID) + if err != nil { + return err + } + role.Permissions = append(role.Permissions, p) + return r.store.UpdateRole(ctx, role) +} + +func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID string, permID string) error { + role, err := r.store.GetRole(ctx, roleID) + if err != nil { + return err + } + filtered := role.Permissions[:0] + for _, p := range role.Permissions { + if p.ID != permID { + filtered = append(filtered, p) + } + + } + role.Permissions = filtered + return r.store.UpdateRole(ctx, role) +} + +// Direct grants +func (r *RBAC) GrantSubject(ctx context.Context, subjectID string, grant Grant) error { + + grant.Resource = strings.ToLower(grant.Resource) + grant.Action = strings.ToLower(grant.Action) + return r.store.GrantSubject(ctx, subjectID, grant) +} + +func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string) error { + return r.store.RevokeSubjectGrant(ctx, subjectID, grantID) +} + +func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, resourceID ...string) (bool, error) { + grants, err := r.store.ListSubjectGrants(ctx, subjectID) + if err != nil { + return false, err + } + roles, err := r.store.ListSubjectRoles(ctx, subjectID) + if err != nil { + return false, err + } + + id := "" + if len(resourceID) > 0 { + id = resourceID[0] + } + res := strings.ToLower(resource) + act := strings.ToLower(action) + + //direct grants + for _, g := range grants { + if strings.ToLower(g.Resource) != res { + continue + } + if strings.ToLower(g.Action) != act { + continue + } + if g.ResourceID != id && g.ResourceID != "*" { + continue + } + return true, nil + } + + //role permissions + for _, role := range roles { + for _, p := range role.Permissions { + if strings.ToLower(p.Resource) != res { + continue + } + if strings.ToLower(p.Action) != act { + continue + } + return true, nil + } + } + + return false, nil +} diff --git a/pkg/store.go b/pkg/store.go new file mode 100644 index 0000000..acbb278 --- /dev/null +++ b/pkg/store.go @@ -0,0 +1,28 @@ +package rbac + +import "context" + +type Store interface { + // Roles + CreateRole(ctx context.Context, role Role) error + GetRole(ctx context.Context, roleID string) (Role, error) + UpdateRole(ctx context.Context, role Role) error + RemoveRole(ctx context.Context, roleID string) error + ListRoles(ctx context.Context) ([]Role, error) + + // Permissions + CreatePermission(ctx context.Context, p Permission) error + GetPermission(ctx context.Context, id string) (Permission, error) + ListPermissions(ctx context.Context) ([]Permission, error) + RemovePermission(ctx context.Context, id string) error + + // Subject role bindings + AssignRole(ctx context.Context, subjectID, roleID string) error + RevokeRole(ctx context.Context, subjectID, roleID string) error + ListSubjectRoles(ctx context.Context, subjectID string) ([]Role, error) + + // Subject direct grants + GrantSubject(ctx context.Context, subjectID string, g Grant) error + RevokeSubjectGrant(ctx context.Context, subjectID string, grantID string) error + ListSubjectGrants(ctx context.Context, subjectID string) ([]Grant, error) +} diff --git a/pkg/types.go b/pkg/types.go new file mode 100644 index 0000000..6e9e14e --- /dev/null +++ b/pkg/types.go @@ -0,0 +1,27 @@ +package rbac + +type Permission struct { + ID string `json:"id"` + Resource string `json:"resource"` + Action string `json:"action"` +} + +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []Permission `json:"permissions"` +} + +type Grant struct { + ID string `json:"id"` + Resource string `json:"resource"` + ResourceID string `json:"resource_id"` + Action string `json:"action"` +} + +type User struct { + ID string `json:"id"` + Roles []Role `json:"roles"` + Grants []Grant `json:"grants"` +} From a3b0ebbe7b19a293bf46d3b10f34f93ef657aa38 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Mon, 6 Oct 2025 14:27:17 +0300 Subject: [PATCH 02/14] chore: update module path in go.mod --- .github/workflows/go.yml | 3 +-- go.mod | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 305a689..814b51d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,8 +18,7 @@ jobs: go-version: 1.25 - name: Run golangci-lint uses: golangci/golangci-lint-action@v8 - with: - args: run + - name: Format uses: Jerome1337/gofmt-action@v1.0.5 diff --git a/go.mod b/go.mod index c0170a6..2290b5d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module rbac +module github.com/codescalers/rbac go 1.24.6 From 859a4a85c0297cff7082e1165bf47a8f76d2f7fc Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Mon, 6 Oct 2025 15:37:11 +0300 Subject: [PATCH 03/14] refactor: enhance RBAC with validation and error handling for role and permission creation --- go.mod | 2 + go.sum | 2 + pkg/errors.go | 5 +- pkg/rbac.go | 148 ++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 go.sum diff --git a/go.mod b/go.mod index 2290b5d..6b1b798 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/codescalers/rbac go 1.24.6 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/pkg/errors.go b/pkg/errors.go index 43900a1..d9bb09d 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -1,8 +1,9 @@ package rbac var ( - ErrNotFound = errorString("not found") - ErrAlreadyExists = errorString("already exists") + ErrAlreadyExists = errorString("already exists") + ErrInvalidResourceOrAction = errorString("invalid resource or action") + ErrInvalidName = errorString("invalid name") ) type errorString string diff --git a/pkg/rbac.go b/pkg/rbac.go index 0efa66e..169f1d4 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -3,52 +3,98 @@ package rbac import ( "context" "strings" + + "github.com/google/uuid" ) type RBAC struct { store Store } -func New(s Store) *RBAC { - return &RBAC{store: s} +func New(ctx context.Context, s Store, opts ...Option) (*RBAC, error) { + r := &RBAC{store: s} + for _, opt := range opts { + if err := opt(ctx, r); err != nil { + return nil, err + } + } + return r, nil +} + +type Option func(ctx context.Context, r *RBAC) error + +func WithSeed(roles []Role) Option { + return func(ctx context.Context, r *RBAC) error { + return r.initFromSeed(ctx, roles) + } +} + +func (r *RBAC) initFromSeed(ctx context.Context, roles []Role) error { + for _, role := range roles { + if err := r.store.CreateRole(ctx, role); err != nil { + return err + } + } + return nil } -func (r *RBAC) CreateRole(ctx context.Context, role Role) error { - role.Name = strings.ToLower(role.Name) +func (r *RBAC) CreateRole(ctx context.Context, name, description string) error { + n := strings.ToLower(strings.TrimSpace(name)) + if n == "" { + return ErrInvalidName + } + role := Role{ID: uuid.New().String(), Name: n, Description: description} return r.store.CreateRole(ctx, role) } func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { + if err := validateUUIDs(roleID); err != nil { + return err + } return r.store.RemoveRole(ctx, roleID) } -func (r *RBAC) CreatePermission(ctx context.Context, p Permission) error { - p.Resource = strings.ToLower(p.Resource) - p.Action = strings.ToLower(p.Action) +func (r *RBAC) CreatePermission(ctx context.Context, resource, action string) error { + res := strings.ToLower(strings.TrimSpace(resource)) + a := strings.ToLower(strings.TrimSpace(action)) + if res == "" || a == "" { + return ErrInvalidResourceOrAction + } + p := Permission{ID: uuid.New().String(), Resource: res, Action: a} return r.store.CreatePermission(ctx, p) } -func (r *RBAC) RemovePermission(ctx context.Context, id string) error { - return r.store.RemovePermission(ctx, id) +func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { + if err := validateUUIDs(permID); err != nil { + return err + } + return r.store.RemovePermission(ctx, permID) } func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { + if err := validateUUIDs(roleID); err != nil { + return err + } return r.store.AssignRole(ctx, subjectID, roleID) } func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { + if err := validateUUIDs(roleID); err != nil { + return err + } return r.store.RevokeRole(ctx, subjectID, roleID) } -func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID string, permID string) error { +func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) error { + if err := validateUUIDs(roleID, permID); err != nil { + return err + } role, err := r.store.GetRole(ctx, roleID) if err != nil { return err } - for _, p := range role.Permissions { - if p.ID == permID { - return ErrAlreadyExists - } + if r.roleHasPermission(&role, permID) { + return ErrAlreadyExists } p, err := r.store.GetPermission(ctx, permID) if err != nil { @@ -58,31 +104,34 @@ func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID string, permID st return r.store.UpdateRole(ctx, role) } -func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID string, permID string) error { +func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID string) error { + if err := validateUUIDs(roleID, permID); err != nil { + return err + } role, err := r.store.GetRole(ctx, roleID) if err != nil { return err } - filtered := role.Permissions[:0] - for _, p := range role.Permissions { - if p.ID != permID { - filtered = append(filtered, p) - } - - } - role.Permissions = filtered + role.Permissions = r.filterOutPermission(role.Permissions, permID) return r.store.UpdateRole(ctx, role) } // Direct grants -func (r *RBAC) GrantSubject(ctx context.Context, subjectID string, grant Grant) error { - - grant.Resource = strings.ToLower(grant.Resource) - grant.Action = strings.ToLower(grant.Action) +func (r *RBAC) GrantSubject(ctx context.Context, subjectID string, resource, action, resourceID string) error { + res := strings.ToLower(strings.TrimSpace(resource)) + act := strings.ToLower(strings.TrimSpace(action)) + if res == "" || act == "" { + return ErrInvalidResourceOrAction + } + rid := strings.ToLower(strings.TrimSpace(resourceID)) + grant := Grant{ID: uuid.New().String(), Resource: res, Action: act, ResourceID: rid} return r.store.GrantSubject(ctx, subjectID, grant) } func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string) error { + if err := validateUUIDs(grantID); err != nil { + return err + } return r.store.RevokeSubjectGrant(ctx, subjectID, grantID) } @@ -97,18 +146,21 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso } id := "" - if len(resourceID) > 0 { + if len(resourceID) > 0 && resourceID[0] != "" { id = resourceID[0] } - res := strings.ToLower(resource) - act := strings.ToLower(action) + res := strings.ToLower(strings.TrimSpace(resource)) + act := strings.ToLower(strings.TrimSpace(action)) + if res == "" || act == "" { + return false, ErrInvalidResourceOrAction + } //direct grants for _, g := range grants { - if strings.ToLower(g.Resource) != res { + if g.Resource != res { continue } - if strings.ToLower(g.Action) != act { + if g.Action != act { continue } if g.ResourceID != id && g.ResourceID != "*" { @@ -120,10 +172,10 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso //role permissions for _, role := range roles { for _, p := range role.Permissions { - if strings.ToLower(p.Resource) != res { + if p.Resource != res { continue } - if strings.ToLower(p.Action) != act { + if p.Action != act { continue } return true, nil @@ -132,3 +184,31 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso return false, nil } + +func (r *RBAC) roleHasPermission(role *Role, permID string) bool { + for _, p := range role.Permissions { + if p.ID == permID { + return true + } + } + return false +} + +func (r *RBAC) filterOutPermission(perms []Permission, permID string) []Permission { + filtered := perms[:0] + for _, p := range perms { + if p.ID != permID { + filtered = append(filtered, p) + } + } + return filtered +} + +func validateUUIDs(ids ...string) error { + for _, id := range ids { + if _, err := uuid.Parse(id); err != nil { + return err + } + } + return nil +} From df183ade275c77bcbfb3d1291d84ace01781dd09 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 10:47:43 +0300 Subject: [PATCH 04/14] refactor: add validation logic before removing role or permission --- pkg/errors.go | 5 ++ pkg/rbac.go | 166 +++++++++++++++++++++++++++++++++++++++++++++++++- pkg/store.go | 1 + 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/pkg/errors.go b/pkg/errors.go index d9bb09d..74d7bc9 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -4,6 +4,11 @@ var ( ErrAlreadyExists = errorString("already exists") ErrInvalidResourceOrAction = errorString("invalid resource or action") ErrInvalidName = errorString("invalid name") + ErrNotFound = errorString("not found") + ErrRoleInUse = errorString("role is in use and cannot be removed") + ErrPermissionInUse = errorString("permission is in use and cannot be removed") + ErrDuplicateRole = errorString("role with this name already exists") + ErrDuplicatePermission = errorString("permission with this resource and action already exists") ) type errorString string diff --git a/pkg/rbac.go b/pkg/rbac.go index 169f1d4..9bafb8a 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -43,6 +43,15 @@ func (r *RBAC) CreateRole(ctx context.Context, name, description string) error { if n == "" { return ErrInvalidName } + + exists, err := r.roleNameExists(ctx, n) + if err != nil { + return err + } + if exists { + return ErrDuplicateRole + } + role := Role{ID: uuid.New().String(), Name: n, Description: description} return r.store.CreateRole(ctx, role) } @@ -51,6 +60,19 @@ func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err } + + if _, err := r.store.GetRole(ctx, roleID); err != nil { + return ErrNotFound + } + + inUse, err := r.isRoleInUse(ctx, roleID) + if err != nil { + return err + } + if inUse { + return ErrRoleInUse + } + return r.store.RemoveRole(ctx, roleID) } @@ -60,6 +82,15 @@ func (r *RBAC) CreatePermission(ctx context.Context, resource, action string) er if res == "" || a == "" { return ErrInvalidResourceOrAction } + + exists, err := r.permissionExists(ctx, res, a) + if err != nil { + return err + } + if exists { + return ErrDuplicatePermission + } + p := Permission{ID: uuid.New().String(), Resource: res, Action: a} return r.store.CreatePermission(ctx, p) } @@ -68,6 +99,19 @@ func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { if err := validateUUIDs(permID); err != nil { return err } + + if _, err := r.store.GetPermission(ctx, permID); err != nil { + return ErrNotFound + } + + inUse, err := r.isPermissionInUse(ctx, permID) + if err != nil { + return err + } + if inUse { + return ErrPermissionInUse + } + return r.store.RemovePermission(ctx, permID) } @@ -75,6 +119,21 @@ func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err } + + if _, err := r.store.GetRole(ctx, roleID); err != nil { + return ErrNotFound + } + + roles, err := r.store.ListSubjectRoles(ctx, subjectID) + if err != nil { + return err + } + for _, role := range roles { + if role.ID == roleID { + return ErrAlreadyExists + } + } + return r.store.AssignRole(ctx, subjectID, roleID) } @@ -82,6 +141,22 @@ func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err } + + roles, err := r.store.ListSubjectRoles(ctx, subjectID) + if err != nil { + return err + } + found := false + for _, role := range roles { + if role.ID == roleID { + found = true + break + } + } + if !found { + return ErrNotFound + } + return r.store.RevokeRole(ctx, subjectID, roleID) } @@ -91,14 +166,14 @@ func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) e } role, err := r.store.GetRole(ctx, roleID) if err != nil { - return err + return ErrNotFound } if r.roleHasPermission(&role, permID) { return ErrAlreadyExists } p, err := r.store.GetPermission(ctx, permID) if err != nil { - return err + return ErrNotFound } role.Permissions = append(role.Permissions, p) return r.store.UpdateRole(ctx, role) @@ -110,8 +185,13 @@ func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID stri } role, err := r.store.GetRole(ctx, roleID) if err != nil { - return err + return ErrNotFound + } + + if !r.roleHasPermission(&role, permID) { + return ErrNotFound } + role.Permissions = r.filterOutPermission(role.Permissions, permID) return r.store.UpdateRole(ctx, role) } @@ -132,6 +212,22 @@ func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string if err := validateUUIDs(grantID); err != nil { return err } + + grants, err := r.store.ListSubjectGrants(ctx, subjectID) + if err != nil { + return err + } + found := false + for _, grant := range grants { + if grant.ID == grantID { + found = true + break + } + } + if !found { + return ErrNotFound + } + return r.store.RevokeSubjectGrant(ctx, subjectID, grantID) } @@ -212,3 +308,67 @@ func validateUUIDs(ids ...string) error { } return nil } + +func (r *RBAC) roleNameExists(ctx context.Context, name string) (bool, error) { + roles, err := r.store.ListRoles(ctx) + if err != nil { + return false, err + } + for _, role := range roles { + if role.Name == name { + return true, nil + } + } + return false, nil +} + +func (r *RBAC) permissionExists(ctx context.Context, resource, action string) (bool, error) { + perms, err := r.store.ListPermissions(ctx) + if err != nil { + return false, err + } + for _, p := range perms { + if p.Resource != resource { + continue + } + if p.Action != action { + continue + } + return true, nil + } + return false, nil +} + +func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { + subjects, err := r.store.ListSubjects(ctx) + if err != nil { + return false, err + } + for _, subjectID := range subjects { + roles, err := r.store.ListSubjectRoles(ctx, subjectID) + if err != nil { + return false, err + } + for _, role := range roles { + if role.ID == roleID { + return true, nil + } + } + } + return false, nil +} + +func (r *RBAC) isPermissionInUse(ctx context.Context, permID string) (bool, error) { + roles, err := r.store.ListRoles(ctx) + if err != nil { + return false, err + } + for _, role := range roles { + for _, p := range role.Permissions { + if p.ID == permID { + return true, nil + } + } + } + return false, nil +} diff --git a/pkg/store.go b/pkg/store.go index acbb278..8b3fb17 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -19,6 +19,7 @@ type Store interface { // Subject role bindings AssignRole(ctx context.Context, subjectID, roleID string) error RevokeRole(ctx context.Context, subjectID, roleID string) error + ListSubjects(ctx context.Context) ([]string, error) ListSubjectRoles(ctx context.Context, subjectID string) ([]Role, error) // Subject direct grants From da1037be5fda9cb6959e0acb71a7db51d559e7c3 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 11:17:01 +0300 Subject: [PATCH 05/14] refactor: streamline role and grant by updating subject directly --- pkg/rbac.go | 75 ++++++++++++++++++++++++++++++++++++++-------------- pkg/store.go | 10 +++---- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/pkg/rbac.go b/pkg/rbac.go index 9bafb8a..0319d2c 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -124,17 +124,24 @@ func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { return ErrNotFound } - roles, err := r.store.ListSubjectRoles(ctx, subjectID) + user, err := r.store.GetSubject(ctx, subjectID) if err != nil { return err } - for _, role := range roles { + + for _, role := range user.Roles { if role.ID == roleID { return ErrAlreadyExists } } - return r.store.AssignRole(ctx, subjectID, roleID) + role, err := r.store.GetRole(ctx, roleID) + if err != nil { + return err + } + + user.Roles = append(user.Roles, role) + return r.store.UpdateSubject(ctx, user) } func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { @@ -142,12 +149,13 @@ func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { return err } - roles, err := r.store.ListSubjectRoles(ctx, subjectID) + user, err := r.store.GetSubject(ctx, subjectID) if err != nil { return err } + found := false - for _, role := range roles { + for _, role := range user.Roles { if role.ID == roleID { found = true break @@ -157,7 +165,8 @@ func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { return ErrNotFound } - return r.store.RevokeRole(ctx, subjectID, roleID) + user.Roles = r.filterOutRole(user.Roles, roleID) + return r.store.UpdateSubject(ctx, user) } func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) error { @@ -203,9 +212,17 @@ func (r *RBAC) GrantSubject(ctx context.Context, subjectID string, resource, act if res == "" || act == "" { return ErrInvalidResourceOrAction } + + user, err := r.store.GetSubject(ctx, subjectID) + if err != nil { + return err + } + rid := strings.ToLower(strings.TrimSpace(resourceID)) grant := Grant{ID: uuid.New().String(), Resource: res, Action: act, ResourceID: rid} - return r.store.GrantSubject(ctx, subjectID, grant) + user.Grants = append(user.Grants, grant) + + return r.store.UpdateSubject(ctx, user) } func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string) error { @@ -213,12 +230,13 @@ func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string return err } - grants, err := r.store.ListSubjectGrants(ctx, subjectID) + user, err := r.store.GetSubject(ctx, subjectID) if err != nil { return err } + found := false - for _, grant := range grants { + for _, grant := range user.Grants { if grant.ID == grantID { found = true break @@ -228,15 +246,12 @@ func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string return ErrNotFound } - return r.store.RevokeSubjectGrant(ctx, subjectID, grantID) + user.Grants = r.filterOutGrant(user.Grants, grantID) + return r.store.UpdateSubject(ctx, user) } func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, resourceID ...string) (bool, error) { - grants, err := r.store.ListSubjectGrants(ctx, subjectID) - if err != nil { - return false, err - } - roles, err := r.store.ListSubjectRoles(ctx, subjectID) + user, err := r.store.GetSubject(ctx, subjectID) if err != nil { return false, err } @@ -252,7 +267,7 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso } //direct grants - for _, g := range grants { + for _, g := range user.Grants { if g.Resource != res { continue } @@ -266,7 +281,7 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso } //role permissions - for _, role := range roles { + for _, role := range user.Roles { for _, p := range role.Permissions { if p.Resource != res { continue @@ -345,11 +360,11 @@ func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { return false, err } for _, subjectID := range subjects { - roles, err := r.store.ListSubjectRoles(ctx, subjectID) + user, err := r.store.GetSubject(ctx, subjectID) if err != nil { - return false, err + continue } - for _, role := range roles { + for _, role := range user.Roles { if role.ID == roleID { return true, nil } @@ -358,6 +373,26 @@ func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { return false, nil } +func (r *RBAC) filterOutRole(roles []Role, roleID string) []Role { + filtered := roles[:0] + for _, role := range roles { + if role.ID != roleID { + filtered = append(filtered, role) + } + } + return filtered +} + +func (r *RBAC) filterOutGrant(grants []Grant, grantID string) []Grant { + filtered := grants[:0] + for _, grant := range grants { + if grant.ID != grantID { + filtered = append(filtered, grant) + } + } + return filtered +} + func (r *RBAC) isPermissionInUse(ctx context.Context, permID string) (bool, error) { roles, err := r.store.ListRoles(ctx) if err != nil { diff --git a/pkg/store.go b/pkg/store.go index 8b3fb17..da2c113 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -16,14 +16,10 @@ type Store interface { ListPermissions(ctx context.Context) ([]Permission, error) RemovePermission(ctx context.Context, id string) error - // Subject role bindings - AssignRole(ctx context.Context, subjectID, roleID string) error - RevokeRole(ctx context.Context, subjectID, roleID string) error + // Subjects + GetSubject(ctx context.Context, subjectID string) (User, error) + UpdateSubject(ctx context.Context, user User) error ListSubjects(ctx context.Context) ([]string, error) ListSubjectRoles(ctx context.Context, subjectID string) ([]Role, error) - - // Subject direct grants - GrantSubject(ctx context.Context, subjectID string, g Grant) error - RevokeSubjectGrant(ctx context.Context, subjectID string, grantID string) error ListSubjectGrants(ctx context.Context, subjectID string) ([]Grant, error) } From b5842f044e1e829054237d257bd72836f3d5ad54 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 12:46:54 +0300 Subject: [PATCH 06/14] feat: implement gorm store type --- pkg/store/gorm.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 pkg/store/gorm.go diff --git a/pkg/store/gorm.go b/pkg/store/gorm.go new file mode 100644 index 0000000..23cc6e9 --- /dev/null +++ b/pkg/store/gorm.go @@ -0,0 +1,55 @@ +package store + +import ( + "gorm.io/gorm" +) + +type GormStore struct { + db *gorm.DB +} + +func NewGormStore(db *gorm.DB) (*GormStore, error) { + store := &GormStore{db: db} + if err := store.migrate(); err != nil { + return nil, err + } + return store, nil +} + +func (s *GormStore) migrate() error { + return s.db.AutoMigrate( + &Role{}, + &Permission{}, + &User{}, + &Grant{}, + ) +} + +type Role struct { + ID string `gorm:"primaryKey"` + Name string `gorm:"uniqueIndex;not null"` + Description string `gorm:"type:text"` + Permissions []Permission `gorm:"many2many:role_permissions;"` + Users []User `gorm:"many2many:user_roles;"` +} + +type Permission struct { + ID string `gorm:"primaryKey"` + Resource string `gorm:"not null;index:idx_resource_action"` + Action string `gorm:"not null;index:idx_resource_action"` + Roles []Role `gorm:"many2many:role_permissions;"` +} + +type User struct { + ID string `gorm:"primaryKey"` + Roles []Role `gorm:"many2many:user_roles;"` + Grants []Grant `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` +} + +type Grant struct { + ID string `gorm:"primaryKey"` + UserID string `gorm:"not null;index"` + Resource string `gorm:"not null"` + Action string `gorm:"not null"` + ResourceID string `gorm:"not null"` +} From 83aa79592db59794eb12fd982b6542193a77ed33 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 13:21:29 +0300 Subject: [PATCH 07/14] refactor: bizrules instead of grants --- go.mod | 11 ++++- go.sum | 8 ++++ pkg/biz_rules.go | 48 ++++++++++++++++++++++ pkg/rbac.go | 105 +++++++++++++---------------------------------- pkg/store.go | 1 - pkg/types.go | 13 ++---- 6 files changed, 97 insertions(+), 89 deletions(-) create mode 100644 pkg/biz_rules.go diff --git a/go.mod b/go.mod index 6b1b798..a9292b3 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module github.com/codescalers/rbac go 1.24.6 -require github.com/google/uuid v1.6.0 +require ( + github.com/google/uuid v1.6.0 + gorm.io/gorm v1.31.0 +) + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum index 7790d7c..543b679 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/pkg/biz_rules.go b/pkg/biz_rules.go new file mode 100644 index 0000000..b6534e6 --- /dev/null +++ b/pkg/biz_rules.go @@ -0,0 +1,48 @@ +package rbac + +import ( + "context" + "fmt" +) + +type Resource interface { + Name() string +} + +type BizRule interface { + Name() string + Evaluate(ctx context.Context, subjectID string, resource Resource) (bool, error) +} + +// RegisterBizRule registers a custom business rule +func (r *RBAC) RegisterBizRule(rule BizRule) error { + if rule == nil { + return fmt.Errorf("business rule cannot be nil") + } + + name := rule.Name() + if name == "" { + return fmt.Errorf("business rule name cannot be empty") + } + + if r.bizRules == nil { + r.bizRules = make(map[string]BizRule) + } + + if _, exists := r.bizRules[name]; exists { + return fmt.Errorf("business rule %q already registered", name) + } + + r.bizRules[name] = rule + return nil +} + +// GetBizRule retrieves a registered business rule by name +func (r *RBAC) GetBizRule(name string) (BizRule, bool) { + if r.bizRules == nil { + return nil, false + } + + rule, exists := r.bizRules[name] + return rule, exists +} diff --git a/pkg/rbac.go b/pkg/rbac.go index 0319d2c..42035c7 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -8,11 +8,15 @@ import ( ) type RBAC struct { - store Store + store Store + bizRules map[string]BizRule } func New(ctx context.Context, s Store, opts ...Option) (*RBAC, error) { - r := &RBAC{store: s} + r := &RBAC{ + store: s, + bizRules: make(map[string]BizRule), + } for _, opt := range opts { if err := opt(ctx, r); err != nil { return nil, err @@ -205,81 +209,22 @@ func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID stri return r.store.UpdateRole(ctx, role) } -// Direct grants -func (r *RBAC) GrantSubject(ctx context.Context, subjectID string, resource, action, resourceID string) error { - res := strings.ToLower(strings.TrimSpace(resource)) - act := strings.ToLower(strings.TrimSpace(action)) - if res == "" || act == "" { - return ErrInvalidResourceOrAction - } - - user, err := r.store.GetSubject(ctx, subjectID) - if err != nil { - return err - } - - rid := strings.ToLower(strings.TrimSpace(resourceID)) - grant := Grant{ID: uuid.New().String(), Resource: res, Action: act, ResourceID: rid} - user.Grants = append(user.Grants, grant) - - return r.store.UpdateSubject(ctx, user) -} - -func (r *RBAC) RevokeSubjectGrant(ctx context.Context, subjectID, grantID string) error { - if err := validateUUIDs(grantID); err != nil { - return err - } - - user, err := r.store.GetSubject(ctx, subjectID) - if err != nil { - return err - } - - found := false - for _, grant := range user.Grants { - if grant.ID == grantID { - found = true - break - } - } - if !found { - return ErrNotFound - } - - user.Grants = r.filterOutGrant(user.Grants, grantID) - return r.store.UpdateSubject(ctx, user) -} - -func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, resourceID ...string) (bool, error) { +func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resource) (bool, error) { user, err := r.store.GetSubject(ctx, subjectID) if err != nil { return false, err } - id := "" - if len(resourceID) > 0 && resourceID[0] != "" { - id = resourceID[0] + if resource == nil { + return false, ErrInvalidResourceOrAction } - res := strings.ToLower(strings.TrimSpace(resource)) + + res := strings.ToLower(strings.TrimSpace(resource.Name())) act := strings.ToLower(strings.TrimSpace(action)) if res == "" || act == "" { return false, ErrInvalidResourceOrAction } - //direct grants - for _, g := range user.Grants { - if g.Resource != res { - continue - } - if g.Action != act { - continue - } - if g.ResourceID != id && g.ResourceID != "*" { - continue - } - return true, nil - } - //role permissions for _, role := range user.Roles { for _, p := range role.Permissions { @@ -289,7 +234,23 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action, resource string, reso if p.Action != act { continue } - return true, nil + + if p.BizRule == "" { + return true, nil + } + + rule, exists := r.GetBizRule(p.BizRule) + if !exists { + continue + } + + allowed, err := rule.Evaluate(ctx, subjectID, resource) + if err != nil { + return false, err + } + if allowed { + return true, nil + } } } @@ -383,16 +344,6 @@ func (r *RBAC) filterOutRole(roles []Role, roleID string) []Role { return filtered } -func (r *RBAC) filterOutGrant(grants []Grant, grantID string) []Grant { - filtered := grants[:0] - for _, grant := range grants { - if grant.ID != grantID { - filtered = append(filtered, grant) - } - } - return filtered -} - func (r *RBAC) isPermissionInUse(ctx context.Context, permID string) (bool, error) { roles, err := r.store.ListRoles(ctx) if err != nil { diff --git a/pkg/store.go b/pkg/store.go index da2c113..c5f2aad 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -21,5 +21,4 @@ type Store interface { UpdateSubject(ctx context.Context, user User) error ListSubjects(ctx context.Context) ([]string, error) ListSubjectRoles(ctx context.Context, subjectID string) ([]Role, error) - ListSubjectGrants(ctx context.Context, subjectID string) ([]Grant, error) } diff --git a/pkg/types.go b/pkg/types.go index 6e9e14e..536e947 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -4,6 +4,7 @@ type Permission struct { ID string `json:"id"` Resource string `json:"resource"` Action string `json:"action"` + BizRule string `json:"biz_rule,omitempty"` } type Role struct { @@ -13,15 +14,7 @@ type Role struct { Permissions []Permission `json:"permissions"` } -type Grant struct { - ID string `json:"id"` - Resource string `json:"resource"` - ResourceID string `json:"resource_id"` - Action string `json:"action"` -} - type User struct { - ID string `json:"id"` - Roles []Role `json:"roles"` - Grants []Grant `json:"grants"` + ID string `json:"id"` + Roles []Role `json:"roles"` } From dcef10bbb8745238f158d1a897d6cca0a2984208 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 14:55:33 +0300 Subject: [PATCH 08/14] feat: implement role hierarchy and validation logic for role and permission management --- pkg/biz_rules.go | 2 + pkg/errors.go | 2 + pkg/helpers.go | 39 +++++++ pkg/hierarchy.go | 53 +++++++++ pkg/rbac.go | 266 +++++++++++++++++----------------------------- pkg/store.go | 2 +- pkg/types.go | 8 +- pkg/validation.go | 68 ++++++++++++ 8 files changed, 270 insertions(+), 170 deletions(-) create mode 100644 pkg/helpers.go create mode 100644 pkg/hierarchy.go create mode 100644 pkg/validation.go diff --git a/pkg/biz_rules.go b/pkg/biz_rules.go index b6534e6..fe0719d 100644 --- a/pkg/biz_rules.go +++ b/pkg/biz_rules.go @@ -5,10 +5,12 @@ import ( "fmt" ) +// Resource represents any entity that can be accessed or modified type Resource interface { Name() string } +// BizRule defines a custom business rule for fine-grained authorization type BizRule interface { Name() string Evaluate(ctx context.Context, subjectID string, resource Resource) (bool, error) diff --git a/pkg/errors.go b/pkg/errors.go index 74d7bc9..fed079a 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -1,5 +1,6 @@ package rbac +// Common RBAC errors var ( ErrAlreadyExists = errorString("already exists") ErrInvalidResourceOrAction = errorString("invalid resource or action") @@ -9,6 +10,7 @@ var ( ErrPermissionInUse = errorString("permission is in use and cannot be removed") ErrDuplicateRole = errorString("role with this name already exists") ErrDuplicatePermission = errorString("permission with this resource and action already exists") + ErrRoleCycle = errorString("role hierarchy cycle detected") ) type errorString string diff --git a/pkg/helpers.go b/pkg/helpers.go new file mode 100644 index 0000000..b81b2be --- /dev/null +++ b/pkg/helpers.go @@ -0,0 +1,39 @@ +package rbac + +import ( + "strings" + + "github.com/google/uuid" +) + +func normalizeString(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func validateUUIDs(ids ...string) error { + for _, id := range ids { + if _, err := uuid.Parse(id); err != nil { + return err + } + } + return nil +} + +func roleHasPermission(role *Role, permID string) bool { + for _, p := range role.Permissions { + if p.ID == permID { + return true + } + } + return false +} + +func filterOutPermission(perms []Permission, permID string) []Permission { + filtered := perms[:0] + for _, p := range perms { + if p.ID != permID { + filtered = append(filtered, p) + } + } + return filtered +} diff --git a/pkg/hierarchy.go b/pkg/hierarchy.go new file mode 100644 index 0000000..208daa7 --- /dev/null +++ b/pkg/hierarchy.go @@ -0,0 +1,53 @@ +package rbac + +import "context" + +// traverseRoleHierarchy walks up the role hierarchy from a given role to its ancestors, +// calling the callback function for each role encountered. +// The traversal stops when there are no more parent roles or when the callback returns an error. +func (r *RBAC) traverseRoleHierarchy(ctx context.Context, roleID string, callback func(role Role) error) error { + currentID := roleID + + for currentID != "" { + role, err := r.store.GetRole(ctx, currentID) + if err != nil { + return err + } + + if err := callback(role); err != nil { + return err + } + + currentID = role.ParentID + } + + return nil +} + +// checkRoleHierarchyCycle detects if adding a parent-child relationship would create a cycle. +// It walks up from the proposed parent and checks if we encounter the proposed child, +// which would indicate a cycle. +func (r *RBAC) checkRoleHierarchyCycle(ctx context.Context, parentID, childID string) error { + visited := make(map[string]bool) + currentID := parentID + + for currentID != "" { + if currentID == childID { + return ErrRoleCycle + } + + if visited[currentID] { + return ErrRoleCycle + } + visited[currentID] = true + + role, err := r.store.GetRole(ctx, currentID) + if err != nil { + return err + } + + currentID = role.ParentID + } + + return nil +} diff --git a/pkg/rbac.go b/pkg/rbac.go index 42035c7..f1a85a4 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -2,17 +2,18 @@ package rbac import ( "context" - "strings" "github.com/google/uuid" ) +// RBAC is the main role-based access control manager type RBAC struct { store Store bizRules map[string]BizRule } -func New(ctx context.Context, s Store, opts ...Option) (*RBAC, error) { +// NewRBAC creates a new RBAC instance with the given store and optional configuration +func NewRBAC(ctx context.Context, s Store, opts ...Option) (*RBAC, error) { r := &RBAC{ store: s, bizRules: make(map[string]BizRule), @@ -25,8 +26,10 @@ func New(ctx context.Context, s Store, opts ...Option) (*RBAC, error) { return r, nil } +// Option is a function that configures the RBAC instance during initialization type Option func(ctx context.Context, r *RBAC) error +// WithSeed seeds the RBAC system with predefined roles func WithSeed(roles []Role) Option { return func(ctx context.Context, r *RBAC) error { return r.initFromSeed(ctx, roles) @@ -42,24 +45,42 @@ func (r *RBAC) initFromSeed(ctx context.Context, roles []Role) error { return nil } -func (r *RBAC) CreateRole(ctx context.Context, name, description string) error { - n := strings.ToLower(strings.TrimSpace(name)) +// CreateRole creates a new role with optional parent for hierarchy +func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentID ...string) (Role, error) { + n := normalizeString(name) if n == "" { - return ErrInvalidName + return Role{}, ErrInvalidName } exists, err := r.roleNameExists(ctx, n) if err != nil { - return err + return Role{}, err } if exists { - return ErrDuplicateRole + return Role{}, ErrDuplicateRole } role := Role{ID: uuid.New().String(), Name: n, Description: description} - return r.store.CreateRole(ctx, role) + + if len(parentID) > 0 && parentID[0] != "" { + if err := validateUUIDs(parentID[0]); err != nil { + return Role{}, err + } + + if err := r.checkRoleHierarchyCycle(ctx, parentID[0], role.ID); err != nil { + return Role{}, err + } + + role.ParentID = parentID[0] + } + + if err := r.store.CreateRole(ctx, role); err != nil { + return Role{}, err + } + return role, nil } +// RemoveRole deletes a role if it's not in use by any user func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err @@ -80,25 +101,36 @@ func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { return r.store.RemoveRole(ctx, roleID) } -func (r *RBAC) CreatePermission(ctx context.Context, resource, action string) error { - res := strings.ToLower(strings.TrimSpace(resource)) - a := strings.ToLower(strings.TrimSpace(action)) +// CreatePermission creates a new permission with optional business rule +func (r *RBAC) CreatePermission(ctx context.Context, resource, action string, bizRuleName ...string) (Permission, error) { + res := normalizeString(resource) + a := normalizeString(action) if res == "" || a == "" { - return ErrInvalidResourceOrAction + return Permission{}, ErrInvalidResourceOrAction + } + + bizRule := "" + if len(bizRuleName) > 0 && bizRuleName[0] != "" { + bizRule = bizRuleName[0] } - exists, err := r.permissionExists(ctx, res, a) + exists, err := r.permissionExists(ctx, res, a, bizRule) if err != nil { - return err + return Permission{}, err } if exists { - return ErrDuplicatePermission + return Permission{}, ErrDuplicatePermission } - p := Permission{ID: uuid.New().String(), Resource: res, Action: a} - return r.store.CreatePermission(ctx, p) + p := Permission{ID: uuid.New().String(), Resource: res, Action: a, BizRule: bizRule} + + if err := r.store.CreatePermission(ctx, p); err != nil { + return Permission{}, err + } + return p, nil } +// RemovePermission deletes a permission if it's not assigned to any role func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { if err := validateUUIDs(permID); err != nil { return err @@ -119,6 +151,7 @@ func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { return r.store.RemovePermission(ctx, permID) } +// AssignRole assigns a role to a user func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err @@ -133,46 +166,11 @@ func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { return err } - for _, role := range user.Roles { - if role.ID == roleID { - return ErrAlreadyExists - } - } - - role, err := r.store.GetRole(ctx, roleID) - if err != nil { - return err - } - - user.Roles = append(user.Roles, role) - return r.store.UpdateSubject(ctx, user) -} - -func (r *RBAC) RevokeRole(ctx context.Context, subjectID, roleID string) error { - if err := validateUUIDs(roleID); err != nil { - return err - } - - user, err := r.store.GetSubject(ctx, subjectID) - if err != nil { - return err - } - - found := false - for _, role := range user.Roles { - if role.ID == roleID { - found = true - break - } - } - if !found { - return ErrNotFound - } - - user.Roles = r.filterOutRole(user.Roles, roleID) + user.RoleID = roleID return r.store.UpdateSubject(ctx, user) } +// AddPermissionToRole adds a permission to a role func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) error { if err := validateUUIDs(roleID, permID); err != nil { return err @@ -181,7 +179,7 @@ func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) e if err != nil { return ErrNotFound } - if r.roleHasPermission(&role, permID) { + if roleHasPermission(&role, permID) { return ErrAlreadyExists } p, err := r.store.GetPermission(ctx, permID) @@ -192,6 +190,7 @@ func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) e return r.store.UpdateRole(ctx, role) } +// RemovePermissionFromRole removes a permission from a role func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID string) error { if err := validateUUIDs(roleID, permID); err != nil { return err @@ -201,14 +200,15 @@ func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID stri return ErrNotFound } - if !r.roleHasPermission(&role, permID) { + if !roleHasPermission(&role, permID) { return ErrNotFound } - role.Permissions = r.filterOutPermission(role.Permissions, permID) + role.Permissions = filterOutPermission(role.Permissions, permID) return r.store.UpdateRole(ctx, role) } +// Can checks if a user has permission to perform an action on a resource func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resource) (bool, error) { user, err := r.store.GetSubject(ctx, subjectID) if err != nil { @@ -219,24 +219,45 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resou return false, ErrInvalidResourceOrAction } - res := strings.ToLower(strings.TrimSpace(resource.Name())) - act := strings.ToLower(strings.TrimSpace(action)) + res := normalizeString(resource.Name()) + act := normalizeString(action) if res == "" || act == "" { return false, ErrInvalidResourceOrAction } - //role permissions - for _, role := range user.Roles { - for _, p := range role.Permissions { - if p.Resource != res { + if user.RoleID == "" { + return false, nil + } + + // Validate roleID + if err := validateUUIDs(user.RoleID); err != nil { + return false, err + } + + role, err := r.store.GetRole(ctx, user.RoleID) + if err != nil { + return false, err + } + + return r.checkRolePermission(ctx, role, subjectID, act, resource) +} + +func (r *RBAC) checkRolePermission(ctx context.Context, role Role, subjectID, action string, resourceObj Resource) (bool, error) { + var hasPermission bool + var permErr error + + err := r.traverseRoleHierarchy(ctx, role.ID, func(currentRole Role) error { + for _, p := range currentRole.Permissions { + if p.Resource != resourceObj.Name() { continue } - if p.Action != act { + if p.Action != action { continue } if p.BizRule == "" { - return true, nil + hasPermission = true + return nil } rule, exists := r.GetBizRule(p.BizRule) @@ -244,117 +265,28 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resou continue } - allowed, err := rule.Evaluate(ctx, subjectID, resource) + allowed, err := rule.Evaluate(ctx, subjectID, resourceObj) if err != nil { - return false, err + permErr = err + return err } - if allowed { - return true, nil - } - } - } - - return false, nil -} - -func (r *RBAC) roleHasPermission(role *Role, permID string) bool { - for _, p := range role.Permissions { - if p.ID == permID { - return true - } - } - return false -} - -func (r *RBAC) filterOutPermission(perms []Permission, permID string) []Permission { - filtered := perms[:0] - for _, p := range perms { - if p.ID != permID { - filtered = append(filtered, p) - } - } - return filtered -} -func validateUUIDs(ids ...string) error { - for _, id := range ids { - if _, err := uuid.Parse(id); err != nil { - return err - } - } - return nil -} + if !allowed { + continue + } -func (r *RBAC) roleNameExists(ctx context.Context, name string) (bool, error) { - roles, err := r.store.ListRoles(ctx) - if err != nil { - return false, err - } - for _, role := range roles { - if role.Name == name { - return true, nil + hasPermission = true + return nil } - } - return false, nil -} + return nil + }) -func (r *RBAC) permissionExists(ctx context.Context, resource, action string) (bool, error) { - perms, err := r.store.ListPermissions(ctx) if err != nil { - return false, err - } - for _, p := range perms { - if p.Resource != resource { - continue - } - if p.Action != action { - continue + if permErr != nil { + return false, permErr } - return true, nil - } - return false, nil -} - -func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { - subjects, err := r.store.ListSubjects(ctx) - if err != nil { return false, err } - for _, subjectID := range subjects { - user, err := r.store.GetSubject(ctx, subjectID) - if err != nil { - continue - } - for _, role := range user.Roles { - if role.ID == roleID { - return true, nil - } - } - } - return false, nil -} -func (r *RBAC) filterOutRole(roles []Role, roleID string) []Role { - filtered := roles[:0] - for _, role := range roles { - if role.ID != roleID { - filtered = append(filtered, role) - } - } - return filtered -} - -func (r *RBAC) isPermissionInUse(ctx context.Context, permID string) (bool, error) { - roles, err := r.store.ListRoles(ctx) - if err != nil { - return false, err - } - for _, role := range roles { - for _, p := range role.Permissions { - if p.ID == permID { - return true, nil - } - } - } - return false, nil + return hasPermission, nil } diff --git a/pkg/store.go b/pkg/store.go index c5f2aad..a443b55 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -2,6 +2,7 @@ package rbac import "context" +// Store defines the interface for persisting RBAC entities type Store interface { // Roles CreateRole(ctx context.Context, role Role) error @@ -20,5 +21,4 @@ type Store interface { GetSubject(ctx context.Context, subjectID string) (User, error) UpdateSubject(ctx context.Context, user User) error ListSubjects(ctx context.Context) ([]string, error) - ListSubjectRoles(ctx context.Context, subjectID string) ([]Role, error) } diff --git a/pkg/types.go b/pkg/types.go index 536e947..9147598 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -1,5 +1,6 @@ package rbac +// Permission represents an action that can be performed on a resource type Permission struct { ID string `json:"id"` Resource string `json:"resource"` @@ -7,14 +8,17 @@ type Permission struct { BizRule string `json:"biz_rule,omitempty"` } +// Role represents a collection of permissions with optional parent hierarchy type Role struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` + ParentID string `json:"parent_id,omitempty"` Permissions []Permission `json:"permissions"` } +// User represents a subject with a single assigned role type User struct { - ID string `json:"id"` - Roles []Role `json:"roles"` + ID string `json:"id"` + RoleID string `json:"role_id"` } diff --git a/pkg/validation.go b/pkg/validation.go new file mode 100644 index 0000000..d409cfc --- /dev/null +++ b/pkg/validation.go @@ -0,0 +1,68 @@ +package rbac + +import "context" + +func (r *RBAC) roleNameExists(ctx context.Context, name string) (bool, error) { + roles, err := r.store.ListRoles(ctx) + if err != nil { + return false, err + } + for _, role := range roles { + if role.Name == name { + return true, nil + } + } + return false, nil +} + +func (r *RBAC) permissionExists(ctx context.Context, resource, action, bizRule string) (bool, error) { + perms, err := r.store.ListPermissions(ctx) + if err != nil { + return false, err + } + for _, p := range perms { + if p.Resource != resource { + continue + } + if p.Action != action { + continue + } + if p.BizRule != bizRule { + continue + } + return true, nil + } + return false, nil +} + +func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { + subjects, err := r.store.ListSubjects(ctx) + if err != nil { + return false, err + } + for _, subjectID := range subjects { + user, err := r.store.GetSubject(ctx, subjectID) + if err != nil { + continue + } + if user.RoleID == roleID { + return true, nil + } + } + return false, nil +} + +func (r *RBAC) isPermissionInUse(ctx context.Context, permID string) (bool, error) { + roles, err := r.store.ListRoles(ctx) + if err != nil { + return false, err + } + for _, role := range roles { + for _, p := range role.Permissions { + if p.ID == permID { + return true, nil + } + } + } + return false, nil +} From 66e03a20fde68187c7c5790d40e2370295349336 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 15:17:36 +0300 Subject: [PATCH 09/14] feat: add usage example --- example/main.go | 172 ++++++++++++++++++++++++++++++++++++++++++++++ pkg/hierarchy.go | 25 ++++--- pkg/rbac.go | 2 +- pkg/store/gorm.go | 18 ++--- 4 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 example/main.go diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..d44f55d --- /dev/null +++ b/example/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "fmt" + "log" + + rbac "github.com/codescalers/rbac/pkg" + "github.com/codescalers/rbac/pkg/store" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// Blog represents a blog post resource +type Blog struct { + ID string + Title string + Content string + OwnerID string +} + +// Name implements the rbac.Resource interface +func (b Blog) Name() string { + return "blog" +} + +// BlogOwnershipRule ensures users can only access their own blogs +type BlogOwnershipRule struct{} + +func (r BlogOwnershipRule) Name() string { + return "blog_ownership" +} + +func (r BlogOwnershipRule) Evaluate(ctx context.Context, subjectID string, resource rbac.Resource) (bool, error) { + blog, ok := resource.(Blog) + if !ok { + return false, fmt.Errorf("expected Blog resource, got %T", resource) + } + + // Allow access if the user is the owner + return blog.OwnerID == subjectID, nil +} + +func main() { + ctx := context.Background() + + // Initialize SQLite database + db, err := gorm.Open(sqlite.Open("rbac.db"), &gorm.Config{}) + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + + // Create GORM store + gormStore, err := store.NewGormStore(db) + if err != nil { + log.Fatal("Failed to create store:", err) + } + + // Initialize RBAC + r, err := rbac.NewRBAC(ctx, gormStore) + if err != nil { + log.Fatal(err) + } + + // Register business rule for blog ownership + bizRole := rbac.BizRule(BlogOwnershipRule{}) + if err := r.RegisterBizRule(bizRole); err != nil { + log.Fatal(err) + } + + // Create permissions + readPerm, err := r.CreatePermission(ctx, "blog", "read") + if err != nil { + log.Fatal(err) + } + updateOwnPerm, err := r.CreatePermission(ctx, "blog", "update", bizRole.Name()) + if err != nil { + log.Fatal(err) + } + deleteOwnPerm, err := r.CreatePermission(ctx, "blog", "delete", bizRole.Name()) + if err != nil { + log.Fatal(err) + } + createPerm, err := r.CreatePermission(ctx, "blog", "create") + if err != nil { + log.Fatal(err) + } + + updateAll, err := r.CreatePermission(ctx, "blog", "update") + if err != nil { + log.Fatal(err) + } + deleteAll, err := r.CreatePermission(ctx, "blog", "delete") + if err != nil { + log.Fatal(err) + } + + // Create roles + userRole, err := r.CreateRole(ctx, "user", "Regular user with blog access") + if err != nil { + log.Fatal(err) + } + adminRole, err := r.CreateRole(ctx, "admin", "Administrator with full access", userRole.ID) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Created roles: user=%s, admin=%s\n", userRole.ID, adminRole.ID) + + //Add user permissions + if err := r.AddPermissionToRole(ctx, userRole.ID, readPerm.ID); err != nil { + log.Fatal(err) + } + if err := r.AddPermissionToRole(ctx, userRole.ID, createPerm.ID); err != nil { + log.Fatal(err) + } + if err := r.AddPermissionToRole(ctx, userRole.ID, updateOwnPerm.ID); err != nil { + log.Fatal(err) + } + if err := r.AddPermissionToRole(ctx, userRole.ID, deleteOwnPerm.ID); err != nil { + log.Fatal(err) + } + + //Add admin permissions + if err := r.AddPermissionToRole(ctx, adminRole.ID, updateAll.ID); err != nil { + log.Fatal(err) + } + if err := r.AddPermissionToRole(ctx, adminRole.ID, deleteAll.ID); err != nil { + log.Fatal(err) + } + + // Create test users + adminUserID := "admin-user-123" + regularUserID := "regular-user-456" + + // Assign roles + if err := r.AssignRole(ctx, adminUserID, adminRole.ID); err != nil { + log.Fatal(err) + } + if err := r.AssignRole(ctx, regularUserID, userRole.ID); err != nil { + log.Fatal(err) + } + + // Test scenarios + fmt.Println("\n=== Testing RBAC with Business Rules ===\n") + + // Test blogs + blog1 := Blog{ID: "blog-1", Title: "Admin's Blog", OwnerID: adminUserID} + blog2 := Blog{ID: "blog-2", Title: "User's Blog", OwnerID: regularUserID} + + hasPerm, err := r.Can(ctx, regularUserID, "update", blog2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("User has permission to update blog2: %v\n", hasPerm) + hasPerm, err = r.Can(ctx, regularUserID, "update", blog1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("User has permission to update blog1: %v\n", hasPerm) + + hasPerm, err = r.Can(ctx, adminUserID, "update", blog1) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Admin has permission to update blog1: %v\n", hasPerm) + hasPerm, err = r.Can(ctx, adminUserID, "update", blog2) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Admin has permission to update blog2: %v\n", hasPerm) +} diff --git a/pkg/hierarchy.go b/pkg/hierarchy.go index 208daa7..b9f715a 100644 --- a/pkg/hierarchy.go +++ b/pkg/hierarchy.go @@ -2,31 +2,30 @@ package rbac import "context" -// traverseRoleHierarchy walks up the role hierarchy from a given role to its ancestors, -// calling the callback function for each role encountered. -// The traversal stops when there are no more parent roles or when the callback returns an error. -func (r *RBAC) traverseRoleHierarchy(ctx context.Context, roleID string, callback func(role Role) error) error { - currentID := roleID +type roleVisitor func(role Role) error - for currentID != "" { - role, err := r.store.GetRole(ctx, currentID) - if err != nil { +func (r *RBAC) traverseRoleHierarchy(ctx context.Context, role Role, visitor roleVisitor) error { + currentRole := role + for { + if err := visitor(currentRole); err != nil { return err } - if err := callback(role); err != nil { + if currentRole.ParentID == "" { + break + } + + parentRole, err := r.store.GetRole(ctx, currentRole.ParentID) + if err != nil { return err } - currentID = role.ParentID + currentRole = parentRole } return nil } -// checkRoleHierarchyCycle detects if adding a parent-child relationship would create a cycle. -// It walks up from the proposed parent and checks if we encounter the proposed child, -// which would indicate a cycle. func (r *RBAC) checkRoleHierarchyCycle(ctx context.Context, parentID, childID string) error { visited := make(map[string]bool) currentID := parentID diff --git a/pkg/rbac.go b/pkg/rbac.go index f1a85a4..2e8516c 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -246,7 +246,7 @@ func (r *RBAC) checkRolePermission(ctx context.Context, role Role, subjectID, ac var hasPermission bool var permErr error - err := r.traverseRoleHierarchy(ctx, role.ID, func(currentRole Role) error { + err := r.traverseRoleHierarchy(ctx, role, func(currentRole Role) error { for _, p := range currentRole.Permissions { if p.Resource != resourceObj.Name() { continue diff --git a/pkg/store/gorm.go b/pkg/store/gorm.go index 23cc6e9..b9033c8 100644 --- a/pkg/store/gorm.go +++ b/pkg/store/gorm.go @@ -21,7 +21,6 @@ func (s *GormStore) migrate() error { &Role{}, &Permission{}, &User{}, - &Grant{}, ) } @@ -29,27 +28,20 @@ type Role struct { ID string `gorm:"primaryKey"` Name string `gorm:"uniqueIndex;not null"` Description string `gorm:"type:text"` + ParentID string `gorm:"index;constraint:OnDelete:RESTRICT"` + Parent *Role `gorm:"foreignKey:ParentID;references:ID"` Permissions []Permission `gorm:"many2many:role_permissions;"` - Users []User `gorm:"many2many:user_roles;"` } type Permission struct { ID string `gorm:"primaryKey"` Resource string `gorm:"not null;index:idx_resource_action"` Action string `gorm:"not null;index:idx_resource_action"` + BizRule string `gorm:"type:text"` Roles []Role `gorm:"many2many:role_permissions;"` } type User struct { - ID string `gorm:"primaryKey"` - Roles []Role `gorm:"many2many:user_roles;"` - Grants []Grant `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` -} - -type Grant struct { - ID string `gorm:"primaryKey"` - UserID string `gorm:"not null;index"` - Resource string `gorm:"not null"` - Action string `gorm:"not null"` - ResourceID string `gorm:"not null"` + ID string `gorm:"primaryKey"` + RoleID string `gorm:"index"` } From e38b2a11e7eddec4ae9510a0d388e2a7ed6f6820 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Tue, 7 Oct 2025 15:42:11 +0300 Subject: [PATCH 10/14] feat: add rbac test with mock store --- go.mod | 10 ++ go.sum | 15 +++ internal/mocks/store_mock.go | 216 +++++++++++++++++++++++++++++++++++ pkg/errors.go | 1 + pkg/rbac.go | 53 +++++++-- pkg/rbac_test.go | 198 ++++++++++++++++++++++++++++++++ pkg/store.go | 4 +- pkg/store/gorm.go | 4 +- pkg/types.go | 4 +- pkg/validation.go | 4 +- 10 files changed, 490 insertions(+), 19 deletions(-) create mode 100644 internal/mocks/store_mock.go create mode 100644 pkg/rbac_test.go diff --git a/go.mod b/go.mod index a9292b3..05d2714 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,21 @@ go 1.24.6 require ( github.com/google/uuid v1.6.0 + go.uber.org/mock v0.6.0 gorm.io/gorm v1.31.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect golang.org/x/text v0.20.0 // indirect + gorm.io/driver/sqlite v1.6.0 ) diff --git a/go.sum b/go.sum index 543b679..84568cb 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,25 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/mocks/store_mock.go b/internal/mocks/store_mock.go new file mode 100644 index 0000000..f79bc13 --- /dev/null +++ b/internal/mocks/store_mock.go @@ -0,0 +1,216 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: pkg/store.go +// +// Generated by this command: +// +// mockgen -source=pkg/store.go -destination=internal/mocks/store_mock.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + rbac "github.com/codescalers/rbac/pkg" + gomock "go.uber.org/mock/gomock" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder + isgomock struct{} +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// CreatePermission mocks base method. +func (m *MockStore) CreatePermission(ctx context.Context, p rbac.Permission) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePermission", ctx, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePermission indicates an expected call of CreatePermission. +func (mr *MockStoreMockRecorder) CreatePermission(ctx, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePermission", reflect.TypeOf((*MockStore)(nil).CreatePermission), ctx, p) +} + +// CreateRole mocks base method. +func (m *MockStore) CreateRole(ctx context.Context, role rbac.Role) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateRole", ctx, role) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateRole indicates an expected call of CreateRole. +func (mr *MockStoreMockRecorder) CreateRole(ctx, role any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRole", reflect.TypeOf((*MockStore)(nil).CreateRole), ctx, role) +} + +// GetPermission mocks base method. +func (m *MockStore) GetPermission(ctx context.Context, id string) (rbac.Permission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPermission", ctx, id) + ret0, _ := ret[0].(rbac.Permission) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPermission indicates an expected call of GetPermission. +func (mr *MockStoreMockRecorder) GetPermission(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPermission", reflect.TypeOf((*MockStore)(nil).GetPermission), ctx, id) +} + +// GetRole mocks base method. +func (m *MockStore) GetRole(ctx context.Context, roleID string) (rbac.Role, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRole", ctx, roleID) + ret0, _ := ret[0].(rbac.Role) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRole indicates an expected call of GetRole. +func (mr *MockStoreMockRecorder) GetRole(ctx, roleID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRole", reflect.TypeOf((*MockStore)(nil).GetRole), ctx, roleID) +} + +// GetSubject mocks base method. +func (m *MockStore) GetSubject(ctx context.Context, subjectID string) (rbac.Subject, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubject", ctx, subjectID) + ret0, _ := ret[0].(rbac.Subject) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubject indicates an expected call of GetSubject. +func (mr *MockStoreMockRecorder) GetSubject(ctx, subjectID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubject", reflect.TypeOf((*MockStore)(nil).GetSubject), ctx, subjectID) +} + +// ListPermissions mocks base method. +func (m *MockStore) ListPermissions(ctx context.Context) ([]rbac.Permission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPermissions", ctx) + ret0, _ := ret[0].([]rbac.Permission) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPermissions indicates an expected call of ListPermissions. +func (mr *MockStoreMockRecorder) ListPermissions(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPermissions", reflect.TypeOf((*MockStore)(nil).ListPermissions), ctx) +} + +// ListRoles mocks base method. +func (m *MockStore) ListRoles(ctx context.Context) ([]rbac.Role, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRoles", ctx) + ret0, _ := ret[0].([]rbac.Role) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRoles indicates an expected call of ListRoles. +func (mr *MockStoreMockRecorder) ListRoles(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRoles", reflect.TypeOf((*MockStore)(nil).ListRoles), ctx) +} + +// ListSubjects mocks base method. +func (m *MockStore) ListSubjects(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSubjects", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSubjects indicates an expected call of ListSubjects. +func (mr *MockStoreMockRecorder) ListSubjects(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSubjects", reflect.TypeOf((*MockStore)(nil).ListSubjects), ctx) +} + +// RemovePermission mocks base method. +func (m *MockStore) RemovePermission(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePermission", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePermission indicates an expected call of RemovePermission. +func (mr *MockStoreMockRecorder) RemovePermission(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePermission", reflect.TypeOf((*MockStore)(nil).RemovePermission), ctx, id) +} + +// RemoveRole mocks base method. +func (m *MockStore) RemoveRole(ctx context.Context, roleID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveRole", ctx, roleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveRole indicates an expected call of RemoveRole. +func (mr *MockStoreMockRecorder) RemoveRole(ctx, roleID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRole", reflect.TypeOf((*MockStore)(nil).RemoveRole), ctx, roleID) +} + +// UpdateRole mocks base method. +func (m *MockStore) UpdateRole(ctx context.Context, role rbac.Role) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateRole", ctx, role) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateRole indicates an expected call of UpdateRole. +func (mr *MockStoreMockRecorder) UpdateRole(ctx, role any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateRole", reflect.TypeOf((*MockStore)(nil).UpdateRole), ctx, role) +} + +// UpdateSubject mocks base method. +func (m *MockStore) UpdateSubject(ctx context.Context, subject rbac.Subject) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateSubject", ctx, subject) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateSubject indicates an expected call of UpdateSubject. +func (mr *MockStoreMockRecorder) UpdateSubject(ctx, subject any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSubject", reflect.TypeOf((*MockStore)(nil).UpdateSubject), ctx, subject) +} diff --git a/pkg/errors.go b/pkg/errors.go index fed079a..fc7e129 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -11,6 +11,7 @@ var ( ErrDuplicateRole = errorString("role with this name already exists") ErrDuplicatePermission = errorString("permission with this resource and action already exists") ErrRoleCycle = errorString("role hierarchy cycle detected") + ErrRoleHasChildren = errorString("role has child roles and cannot be removed") ) type errorString string diff --git a/pkg/rbac.go b/pkg/rbac.go index 2e8516c..990ed85 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -67,7 +67,8 @@ func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentI return Role{}, err } - if err := r.checkRoleHierarchyCycle(ctx, parentID[0], role.ID); err != nil { + // Verify parent exists + if _, err := r.store.GetRole(ctx, parentID[0]); err != nil { return Role{}, err } @@ -80,7 +81,37 @@ func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentI return role, nil } -// RemoveRole deletes a role if it's not in use by any user +// UpdateRole updates an existing role's parent, which can be used to reorganize the hierarchy +func (r *RBAC) UpdateRole(ctx context.Context, roleID, newParentID string) error { + if err := validateUUIDs(roleID); err != nil { + return err + } + + role, err := r.store.GetRole(ctx, roleID) + if err != nil { + return ErrNotFound + } + + if newParentID != "" { + if err := validateUUIDs(newParentID); err != nil { + return err + } + + _, err := r.store.GetRole(ctx, newParentID) + if err != nil { + return err + } + + if err := r.checkRoleHierarchyCycle(ctx, newParentID, roleID); err != nil { + return err + } + } + + role.ParentID = newParentID + return r.store.UpdateRole(ctx, role) +} + +// RemoveRole deletes a role if it's not in use by any subject func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err @@ -151,7 +182,7 @@ func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { return r.store.RemovePermission(ctx, permID) } -// AssignRole assigns a role to a user +// AssignRole assigns a role to a subject func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { if err := validateUUIDs(roleID); err != nil { return err @@ -161,13 +192,13 @@ func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { return ErrNotFound } - user, err := r.store.GetSubject(ctx, subjectID) + subject, err := r.store.GetSubject(ctx, subjectID) if err != nil { return err } - user.RoleID = roleID - return r.store.UpdateSubject(ctx, user) + subject.RoleID = roleID + return r.store.UpdateSubject(ctx, subject) } // AddPermissionToRole adds a permission to a role @@ -208,9 +239,9 @@ func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID stri return r.store.UpdateRole(ctx, role) } -// Can checks if a user has permission to perform an action on a resource +// Can checks if a subject has permission to perform an action on a resource func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resource) (bool, error) { - user, err := r.store.GetSubject(ctx, subjectID) + subject, err := r.store.GetSubject(ctx, subjectID) if err != nil { return false, err } @@ -225,16 +256,16 @@ func (r *RBAC) Can(ctx context.Context, subjectID, action string, resource Resou return false, ErrInvalidResourceOrAction } - if user.RoleID == "" { + if subject.RoleID == "" { return false, nil } // Validate roleID - if err := validateUUIDs(user.RoleID); err != nil { + if err := validateUUIDs(subject.RoleID); err != nil { return false, err } - role, err := r.store.GetRole(ctx, user.RoleID) + role, err := r.store.GetRole(ctx, subject.RoleID) if err != nil { return false, err } diff --git a/pkg/rbac_test.go b/pkg/rbac_test.go new file mode 100644 index 0000000..4f3dde2 --- /dev/null +++ b/pkg/rbac_test.go @@ -0,0 +1,198 @@ +package rbac_test + +import ( + "context" + "testing" + + "github.com/codescalers/rbac/internal/mocks" + rbac "github.com/codescalers/rbac/pkg" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestNewRBAC(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + t.Run("Create new RBAC instance", func(t *testing.T) { + r, err := rbac.NewRBAC(ctx, store) + + assert.NoError(t, err) + assert.NotNil(t, r) + }) + t.Run("Create new RBAC instance with seed", func(t *testing.T) { + roles := []rbac.Role{ + {ID: "1", Name: "admin", Permissions: []rbac.Permission{{ID: "p1", Resource: "blog", Action: "read"}}}, + {ID: "2", Name: "user", Permissions: []rbac.Permission{{ID: "p2", Resource: "blog", Action: "update"}}}, + } + store.EXPECT().CreateRole(ctx, roles[0]).Return(nil) + store.EXPECT().CreateRole(ctx, roles[1]).Return(nil) + + r, err := rbac.NewRBAC(ctx, store, rbac.WithSeed(roles)) + + assert.NoError(t, err) + assert.NotNil(t, r) + }) +} + +func TestCreateRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + adminID := "550e8400-e29b-41d4-a716-446655440000" + existingAdmin := rbac.Role{ID: adminID, Name: "admin"} + + store.EXPECT().ListRoles(ctx).Return([]rbac.Role{existingAdmin}, nil).Times(4) + store.EXPECT().GetRole(ctx, adminID).Return(rbac.Role{ID: adminID, Name: "admin"}, nil).Times(1) + store.EXPECT().CreateRole(ctx, gomock.Any()).Return(nil).Times(2) + + t.Run("Create role with empty name", func(t *testing.T) { + _, err := r.CreateRole(ctx, "", "description") + assert.ErrorIs(t, err, rbac.ErrInvalidName) + }) + + t.Run("Create role with duplicate name", func(t *testing.T) { + _, err := r.CreateRole(ctx, "admin", "description") + assert.ErrorIs(t, err, rbac.ErrDuplicateRole) + }) + + t.Run("Create role with non-existing parent", func(t *testing.T) { + nonExistingParentID := "660e8400-e29b-41d4-a716-446655440099" + store.EXPECT().GetRole(ctx, nonExistingParentID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + _, err := r.CreateRole(ctx, "user", "description", nonExistingParentID) + assert.Error(t, err) + }) + + t.Run("Create role successfully without parent", func(t *testing.T) { + role, err := r.CreateRole(ctx, "user", "User role") + assert.NoError(t, err) + assert.Equal(t, "user", role.Name) + assert.Equal(t, "User role", role.Description) + }) + + t.Run("Create role successfully with parent", func(t *testing.T) { + role, err := r.CreateRole(ctx, "editor", "Editor role", adminID) + assert.NoError(t, err) + assert.Equal(t, "editor", role.Name) + assert.Equal(t, "Editor role", role.Description) + assert.Equal(t, adminID, role.ParentID) + }) +} + +func TestUpdateRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + adminID := "550e8400-e29b-41d4-a716-446655440000" + editorID := "550e8400-e29b-41d4-a716-446655440001" + viewerID := "550e8400-e29b-41d4-a716-446655440002" + + existingAdmin := rbac.Role{ID: adminID, Name: "admin"} + existingEditor := rbac.Role{ID: editorID, Name: "editor", ParentID: adminID} + existingViewer := rbac.Role{ID: viewerID, Name: "viewer", ParentID: editorID} + + store.EXPECT().GetRole(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, id string) (rbac.Role, error) { + switch id { + case adminID: + return existingAdmin, nil + case editorID: + return existingEditor, nil + case viewerID: + return existingViewer, nil + default: + return rbac.Role{}, rbac.ErrNotFound + } + }).AnyTimes() + store.EXPECT().UpdateRole(ctx, gomock.Any()).Return(nil).Times(1) + + t.Run("Update role with invalid UUID", func(t *testing.T) { + err := r.UpdateRole(ctx, "invalid-uuid", adminID) + assert.NotNil(t, err) + }) + + t.Run("Update non-existing role", func(t *testing.T) { + err := r.UpdateRole(ctx, "550e8400-e29b-41d4-a716-446655440099", adminID) + assert.ErrorIs(t, err, rbac.ErrNotFound) + }) + + t.Run("Update role with invalid parent UUID", func(t *testing.T) { + err := r.UpdateRole(ctx, viewerID, "invalid-parent-uuid") + assert.NotNil(t, err) + }) + + t.Run("Update role with non-existing parent", func(t *testing.T) { + err := r.UpdateRole(ctx, viewerID, "550e8400-e29b-41d4-a716-446655440099") + assert.ErrorIs(t, err, rbac.ErrNotFound) + }) + + t.Run("Update role creates cycle", func(t *testing.T) { + err := r.UpdateRole(ctx, adminID, editorID) + assert.ErrorIs(t, err, rbac.ErrRoleCycle) + }) + + t.Run("Update role successfully", func(t *testing.T) { + err := r.UpdateRole(ctx, viewerID, adminID) + assert.NoError(t, err) + }) +} + +func TestRemoveRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + roleID := "550e8400-e29b-41d4-a716-446655440000" + + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ID: roleID, Name: "user"}, nil).Times(3) + store.EXPECT().ListSubjects(ctx).Return([]string{"user1", "user2"}, nil).Times(3) + store.EXPECT().GetSubject(ctx, "user1").Return(rbac.Subject{ID: "user1", RoleID: "some-other-role"}, nil).Times(3) + store.EXPECT().GetSubject(ctx, "user2").Return(rbac.Subject{ID: "user2", RoleID: roleID}, nil).Times(1) + store.EXPECT().GetSubject(ctx, "user2").Return(rbac.Subject{ID: "user2", RoleID: "another-role"}, nil).Times(2) + store.EXPECT().RemoveRole(ctx, roleID).Return(rbac.ErrRoleHasChildren).Times(1) + store.EXPECT().RemoveRole(ctx, roleID).Return(nil).Times(1) + + t.Run("Remove role with invalid UUID", func(t *testing.T) { + err := r.RemoveRole(ctx, "invalid-uuid") + assert.NotNil(t, err) + }) + + t.Run("Remove non-existing role", func(t *testing.T) { + err := r.RemoveRole(ctx, roleID) + assert.ErrorIs(t, err, rbac.ErrNotFound) + }) + + t.Run("Remove role that is in use", func(t *testing.T) { + err := r.RemoveRole(ctx, roleID) + assert.ErrorIs(t, err, rbac.ErrRoleInUse) + }) + + t.Run("Remove role that has children", func(t *testing.T) { + err := r.RemoveRole(ctx, roleID) + assert.ErrorIs(t, err, rbac.ErrRoleHasChildren) + }) + + t.Run("Remove role successfully", func(t *testing.T) { + err := r.RemoveRole(ctx, roleID) + assert.NoError(t, err) + }) +} diff --git a/pkg/store.go b/pkg/store.go index a443b55..b1cccdf 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -18,7 +18,7 @@ type Store interface { RemovePermission(ctx context.Context, id string) error // Subjects - GetSubject(ctx context.Context, subjectID string) (User, error) - UpdateSubject(ctx context.Context, user User) error + GetSubject(ctx context.Context, subjectID string) (Subject, error) + UpdateSubject(ctx context.Context, subject Subject) error ListSubjects(ctx context.Context) ([]string, error) } diff --git a/pkg/store/gorm.go b/pkg/store/gorm.go index b9033c8..52cd38f 100644 --- a/pkg/store/gorm.go +++ b/pkg/store/gorm.go @@ -20,7 +20,7 @@ func (s *GormStore) migrate() error { return s.db.AutoMigrate( &Role{}, &Permission{}, - &User{}, + &Subject{}, ) } @@ -41,7 +41,7 @@ type Permission struct { Roles []Role `gorm:"many2many:role_permissions;"` } -type User struct { +type Subject struct { ID string `gorm:"primaryKey"` RoleID string `gorm:"index"` } diff --git a/pkg/types.go b/pkg/types.go index 9147598..8622b63 100644 --- a/pkg/types.go +++ b/pkg/types.go @@ -17,8 +17,8 @@ type Role struct { Permissions []Permission `json:"permissions"` } -// User represents a subject with a single assigned role -type User struct { +// Subject represents a subject with a single assigned role +type Subject struct { ID string `json:"id"` RoleID string `json:"role_id"` } diff --git a/pkg/validation.go b/pkg/validation.go index d409cfc..5c17cca 100644 --- a/pkg/validation.go +++ b/pkg/validation.go @@ -41,11 +41,11 @@ func (r *RBAC) isRoleInUse(ctx context.Context, roleID string) (bool, error) { return false, err } for _, subjectID := range subjects { - user, err := r.store.GetSubject(ctx, subjectID) + subject, err := r.store.GetSubject(ctx, subjectID) if err != nil { continue } - if user.RoleID == roleID { + if subject.RoleID == roleID { return true, nil } } From 0873606773d91dbe6d6caa4a57bae6378af121f7 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Wed, 8 Oct 2025 10:22:01 +0300 Subject: [PATCH 11/14] test: radd test for all methods --- pkg/rbac_test.go | 867 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 782 insertions(+), 85 deletions(-) diff --git a/pkg/rbac_test.go b/pkg/rbac_test.go index 4f3dde2..1f32dec 100644 --- a/pkg/rbac_test.go +++ b/pkg/rbac_test.go @@ -54,37 +54,87 @@ func TestCreateRole(t *testing.T) { store.EXPECT().GetRole(ctx, adminID).Return(rbac.Role{ID: adminID, Name: "admin"}, nil).Times(1) store.EXPECT().CreateRole(ctx, gomock.Any()).Return(nil).Times(2) - t.Run("Create role with empty name", func(t *testing.T) { - _, err := r.CreateRole(ctx, "", "description") - assert.ErrorIs(t, err, rbac.ErrInvalidName) - }) - - t.Run("Create role with duplicate name", func(t *testing.T) { - _, err := r.CreateRole(ctx, "admin", "description") - assert.ErrorIs(t, err, rbac.ErrDuplicateRole) - }) - - t.Run("Create role with non-existing parent", func(t *testing.T) { - nonExistingParentID := "660e8400-e29b-41d4-a716-446655440099" - store.EXPECT().GetRole(ctx, nonExistingParentID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - _, err := r.CreateRole(ctx, "user", "description", nonExistingParentID) - assert.Error(t, err) - }) - - t.Run("Create role successfully without parent", func(t *testing.T) { - role, err := r.CreateRole(ctx, "user", "User role") - assert.NoError(t, err) - assert.Equal(t, "user", role.Name) - assert.Equal(t, "User role", role.Description) - }) - - t.Run("Create role successfully with parent", func(t *testing.T) { - role, err := r.CreateRole(ctx, "editor", "Editor role", adminID) - assert.NoError(t, err) - assert.Equal(t, "editor", role.Name) - assert.Equal(t, "Editor role", role.Description) - assert.Equal(t, adminID, role.ParentID) - }) + tests := []struct { + name string + roleName string + description string + parentID string + setupMock func() + expectedError error + validateRole func(role rbac.Role) + }{ + { + name: "Create role with empty name", + roleName: "", + description: "description", + expectedError: rbac.ErrInvalidName, + }, + { + name: "Create role with duplicate name", + roleName: "admin", + description: "description", + expectedError: rbac.ErrDuplicateRole, + }, + { + name: "Create role with non-existing parent", + roleName: "user", + description: "description", + parentID: "660e8400-e29b-41d4-a716-446655440099", + setupMock: func() { + store.EXPECT().GetRole(ctx, "660e8400-e29b-41d4-a716-446655440099").Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + }, + expectedError: rbac.ErrNotFound, + }, + { + name: "Create role successfully without parent", + roleName: "user", + description: "User role", + validateRole: func(role rbac.Role) { + assert.Equal(t, "user", role.Name) + assert.Equal(t, "User role", role.Description) + }, + }, + { + name: "Create role successfully with parent", + roleName: "editor", + description: "Editor role", + parentID: adminID, + validateRole: func(role rbac.Role) { + assert.Equal(t, "editor", role.Name) + assert.Equal(t, "Editor role", role.Description) + assert.Equal(t, adminID, role.ParentID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupMock != nil { + tt.setupMock() + } + + var role rbac.Role + var err error + if tt.parentID != "" { + role, err = r.CreateRole(ctx, tt.roleName, tt.description, tt.parentID) + } else { + role, err = r.CreateRole(ctx, tt.roleName, tt.description) + } + + if tt.expectedError == nil { + assert.NoError(t, err) + if tt.validateRole != nil { + tt.validateRole(role) + } + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } } func TestUpdateRole(t *testing.T) { @@ -93,9 +143,6 @@ func TestUpdateRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) - r, err := rbac.NewRBAC(ctx, store) - assert.NoError(t, err) - assert.NotNil(t, r) adminID := "550e8400-e29b-41d4-a716-446655440000" editorID := "550e8400-e29b-41d4-a716-446655440001" @@ -119,35 +166,69 @@ func TestUpdateRole(t *testing.T) { }).AnyTimes() store.EXPECT().UpdateRole(ctx, gomock.Any()).Return(nil).Times(1) - t.Run("Update role with invalid UUID", func(t *testing.T) { - err := r.UpdateRole(ctx, "invalid-uuid", adminID) - assert.NotNil(t, err) - }) - - t.Run("Update non-existing role", func(t *testing.T) { - err := r.UpdateRole(ctx, "550e8400-e29b-41d4-a716-446655440099", adminID) - assert.ErrorIs(t, err, rbac.ErrNotFound) - }) - - t.Run("Update role with invalid parent UUID", func(t *testing.T) { - err := r.UpdateRole(ctx, viewerID, "invalid-parent-uuid") - assert.NotNil(t, err) - }) - - t.Run("Update role with non-existing parent", func(t *testing.T) { - err := r.UpdateRole(ctx, viewerID, "550e8400-e29b-41d4-a716-446655440099") - assert.ErrorIs(t, err, rbac.ErrNotFound) - }) - - t.Run("Update role creates cycle", func(t *testing.T) { - err := r.UpdateRole(ctx, adminID, editorID) - assert.ErrorIs(t, err, rbac.ErrRoleCycle) - }) + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) - t.Run("Update role successfully", func(t *testing.T) { - err := r.UpdateRole(ctx, viewerID, adminID) - assert.NoError(t, err) - }) + tests := []struct { + name string + roleID string + newParentID string + expectedError error + expectAnyError bool + }{ + { + name: "Update role with invalid UUID", + roleID: "invalid-uuid", + newParentID: adminID, + expectAnyError: true, + }, + { + name: "Update non-existing role", + roleID: "550e8400-e29b-41d4-a716-446655440099", + newParentID: adminID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Update role with invalid parent UUID", + roleID: viewerID, + newParentID: "invalid-parent-uuid", + expectAnyError: true, + }, + { + name: "Update role with non-existing parent", + roleID: viewerID, + newParentID: "550e8400-e29b-41d4-a716-446655440099", + expectedError: rbac.ErrNotFound, + }, + { + name: "Update role creates cycle", + roleID: adminID, + newParentID: editorID, + expectedError: rbac.ErrRoleCycle, + }, + { + name: "Update role successfully", + roleID: viewerID, + newParentID: adminID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.UpdateRole(ctx, tt.roleID, tt.newParentID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } } func TestRemoveRole(t *testing.T) { @@ -156,9 +237,6 @@ func TestRemoveRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) - r, err := rbac.NewRBAC(ctx, store) - assert.NoError(t, err) - assert.NotNil(t, r) roleID := "550e8400-e29b-41d4-a716-446655440000" @@ -171,28 +249,647 @@ func TestRemoveRole(t *testing.T) { store.EXPECT().RemoveRole(ctx, roleID).Return(rbac.ErrRoleHasChildren).Times(1) store.EXPECT().RemoveRole(ctx, roleID).Return(nil).Times(1) - t.Run("Remove role with invalid UUID", func(t *testing.T) { - err := r.RemoveRole(ctx, "invalid-uuid") - assert.NotNil(t, err) - }) + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) - t.Run("Remove non-existing role", func(t *testing.T) { - err := r.RemoveRole(ctx, roleID) - assert.ErrorIs(t, err, rbac.ErrNotFound) - }) + tests := []struct { + name string + roleID string + expectedError error + expectAnyError bool + }{ + { + name: "Remove role with invalid UUID", + roleID: "invalid-uuid", + expectAnyError: true, + }, + { + name: "Remove non-existing role", + roleID: roleID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Remove role that is in use", + roleID: roleID, + expectedError: rbac.ErrRoleInUse, + }, + { + name: "Remove role that has children", + roleID: roleID, + expectedError: rbac.ErrRoleHasChildren, + }, + { + name: "Remove role successfully", + roleID: roleID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.RemoveRole(ctx, tt.roleID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } +} - t.Run("Remove role that is in use", func(t *testing.T) { - err := r.RemoveRole(ctx, roleID) - assert.ErrorIs(t, err, rbac.ErrRoleInUse) - }) +func TestCreatePermission(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() - t.Run("Remove role that has children", func(t *testing.T) { - err := r.RemoveRole(ctx, roleID) - assert.ErrorIs(t, err, rbac.ErrRoleHasChildren) - }) + ctx := context.Background() + store := mocks.NewMockStore(ctrl) - t.Run("Remove role successfully", func(t *testing.T) { - err := r.RemoveRole(ctx, roleID) - assert.NoError(t, err) - }) + store.EXPECT().ListPermissions(ctx).Return([]rbac.Permission{ + {ID: "p1", Resource: "blog", Action: "read", BizRule: ""}, + }, nil).AnyTimes() + store.EXPECT().CreatePermission(ctx, gomock.Any()).Return(nil).Times(2) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + tests := []struct { + name string + resource string + action string + bizRuleName []string + expectedError error + validatePerm func(*rbac.Permission) + }{ + { + name: "Create permission with empty resource", + resource: "", + action: "read", + expectedError: rbac.ErrInvalidResourceOrAction, + }, + { + name: "Create permission with empty action", + resource: "blog", + action: "", + expectedError: rbac.ErrInvalidResourceOrAction, + }, + { + name: "Create duplicate permission", + resource: "blog", + action: "read", + expectedError: rbac.ErrDuplicatePermission, + }, + { + name: "Create permission successfully without bizrule", + resource: "article", + action: "write", + validatePerm: func(p *rbac.Permission) { + assert.NotEmpty(t, p.ID) + assert.Equal(t, "article", p.Resource) + assert.Equal(t, "write", p.Action) + assert.Equal(t, "", p.BizRule) + }, + }, + { + name: "Create permission successfully with bizrule", + resource: "post", + action: "delete", + bizRuleName: []string{"is_owner"}, + validatePerm: func(p *rbac.Permission) { + assert.NotEmpty(t, p.ID) + assert.Equal(t, "post", p.Resource) + assert.Equal(t, "delete", p.Action) + assert.Equal(t, "is_owner", p.BizRule) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + perm, err := r.CreatePermission(ctx, tt.resource, tt.action, tt.bizRuleName...) + + if tt.expectedError == nil { + assert.NoError(t, err) + if tt.validatePerm != nil { + tt.validatePerm(&perm) + } + return + } + + assert.Error(t, err) + assert.ErrorIs(t, err, tt.expectedError) + }) + } +} + +func TestRemovePermission(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + + permID := "550e8400-e29b-41d4-a716-446655440000" + roleID := "550e8400-e29b-41d4-a716-446655440001" + + store.EXPECT().GetPermission(ctx, permID).Return(rbac.Permission{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetPermission(ctx, permID).Return(rbac.Permission{ID: permID}, nil).Times(2) + store.EXPECT().ListRoles(ctx).Return([]rbac.Role{ + { + ID: roleID, + Name: "admin", + Permissions: []rbac.Permission{ + {ID: permID, Resource: "blog", Action: "read"}, + }, + }, + }, nil).Times(1) + store.EXPECT().ListRoles(ctx).Return([]rbac.Role{ + { + ID: roleID, + Name: "admin", + Permissions: []rbac.Permission{}, + }, + }, nil).Times(1) + store.EXPECT().RemovePermission(ctx, permID).Return(nil).Times(1) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + tests := []struct { + name string + permID string + expectedError error + expectAnyError bool + }{ + { + name: "Remove permission with invalid UUID", + permID: "invalid-uuid", + expectAnyError: true, + }, + { + name: "Remove non-existing permission", + permID: permID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Remove permission that is in use", + permID: permID, + expectedError: rbac.ErrPermissionInUse, + }, + { + name: "Remove permission successfully", + permID: permID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.RemovePermission(ctx, tt.permID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } +} + +func TestAddPermissionToRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + + roleID := "550e8400-e29b-41d4-a716-446655440000" + permID := "550e8400-e29b-41d4-a716-446655440001" + existingPermID := "550e8400-e29b-41d4-a716-446655440002" + + existingPerm := rbac.Permission{ID: existingPermID, Resource: "blog", Action: "read"} + newPerm := rbac.Permission{ID: permID, Resource: "article", Action: "write"} + + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + ID: roleID, + Name: "editor", + Permissions: []rbac.Permission{existingPerm}, + }, nil).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + ID: roleID, + Name: "editor", + Permissions: []rbac.Permission{}, + }, nil).Times(2) + store.EXPECT().GetPermission(ctx, permID).Return(rbac.Permission{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetPermission(ctx, permID).Return(newPerm, nil).Times(1) + store.EXPECT().UpdateRole(ctx, gomock.Any()).Return(nil).Times(1) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + tests := []struct { + name string + roleID string + permID string + expectedError error + expectAnyError bool + }{ + { + name: "Add permission with invalid role UUID", + roleID: "invalid-uuid", + permID: permID, + expectAnyError: true, + }, + { + name: "Add permission with invalid permission UUID", + roleID: roleID, + permID: "invalid-uuid", + expectAnyError: true, + }, + { + name: "Add permission to non-existing role", + roleID: roleID, + permID: permID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Add duplicate permission to role", + roleID: roleID, + permID: existingPermID, + expectedError: rbac.ErrAlreadyExists, + }, + { + name: "Add non-existing permission to role", + roleID: roleID, + permID: permID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Add permission to role successfully", + roleID: roleID, + permID: permID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.AddPermissionToRole(ctx, tt.roleID, tt.permID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } +} + +func TestRemovePermissionFromRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + + roleID := "550e8400-e29b-41d4-a716-446655440000" + permID := "550e8400-e29b-41d4-a716-446655440001" + otherPermID := "550e8400-e29b-41d4-a716-446655440002" + + existingPerm := rbac.Permission{ID: permID, Resource: "blog", Action: "read"} + + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + ID: roleID, + Name: "editor", + Permissions: []rbac.Permission{}, + }, nil).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + ID: roleID, + Name: "editor", + Permissions: []rbac.Permission{existingPerm}, + }, nil).Times(1) + store.EXPECT().UpdateRole(ctx, gomock.Any()).Return(nil).Times(1) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + tests := []struct { + name string + roleID string + permID string + expectedError error + expectAnyError bool + }{ + { + name: "Remove permission with invalid role UUID", + roleID: "invalid-uuid", + permID: permID, + expectAnyError: true, + }, + { + name: "Remove permission with invalid permission UUID", + roleID: roleID, + permID: "invalid-uuid", + expectAnyError: true, + }, + { + name: "Remove permission from non-existing role", + roleID: roleID, + permID: permID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Remove non-existing permission from role", + roleID: roleID, + permID: otherPermID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Remove permission from role successfully", + roleID: roleID, + permID: permID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.RemovePermissionFromRole(ctx, tt.roleID, tt.permID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } +} + +func TestAssignRole(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + + roleID := "550e8400-e29b-41d4-a716-446655440000" + subjectID := "subject-123" + + existingSubject := rbac.Subject{ID: subjectID, RoleID: ""} + + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ID: roleID, Name: "admin"}, nil).Times(2) + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetSubject(ctx, subjectID).Return(existingSubject, nil).Times(1) + store.EXPECT().UpdateSubject(ctx, rbac.Subject{ID: subjectID, RoleID: roleID}).Return(nil).Times(1) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + tests := []struct { + name string + subjectID string + roleID string + expectedError error + expectAnyError bool + }{ + { + name: "Assign role with invalid role UUID", + subjectID: subjectID, + roleID: "invalid-uuid", + expectAnyError: true, + }, + { + name: "Assign non-existing role", + subjectID: subjectID, + roleID: roleID, + expectedError: rbac.ErrNotFound, + }, + { + name: "Assign role to non-existing subject", + subjectID: subjectID, + roleID: roleID, + expectAnyError: true, + }, + { + name: "Assign role successfully", + subjectID: subjectID, + roleID: roleID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := r.AssignRole(ctx, tt.subjectID, tt.roleID) + + if tt.expectedError == nil && !tt.expectAnyError { + assert.NoError(t, err) + return + } + + assert.Error(t, err) + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + } + }) + } +} + +type MockResource struct { + name string + ownerID string +} + +func (mr MockResource) Name() string { + return mr.name +} + +type MockOwnershipBizRule struct{} + +func (mbr MockOwnershipBizRule) Name() string { + return "test_ownership" +} + +func (mbr MockOwnershipBizRule) Evaluate(ctx context.Context, subjectID string, resource rbac.Resource) (bool, error) { + mockRes, ok := resource.(MockResource) + if !ok { + return false, nil + } + return mockRes.ownerID == subjectID, nil +} + +func TestCan(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := context.Background() + store := mocks.NewMockStore(ctrl) + + subjectID := "subject-123" + otherSubjectID := "subject-456" + adminRoleID := "550e8400-e29b-41d4-a716-446655440000" + userRoleID := "550e8400-e29b-41d4-a716-446655440001" + readPermID := "perm-read-123" + writePermID := "perm-write-123" + deleteWithRulePermID := "perm-delete-123" + + adminRole := rbac.Role{ + ID: adminRoleID, + Name: "admin", + Permissions: []rbac.Permission{ + {ID: readPermID, Resource: "blog", Action: "read"}, + {ID: writePermID, Resource: "blog", Action: "write"}, + }, + } + + userRole := rbac.Role{ + ID: userRoleID, + Name: "user", + ParentID: adminRoleID, + Permissions: []rbac.Permission{ + {ID: deleteWithRulePermID, Resource: "blog", Action: "delete", BizRule: "test_ownership"}, + }, + } + + resource := MockResource{name: "blog", ownerID: subjectID} + + store.EXPECT().GetRole(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, id string) (rbac.Role, error) { + switch id { + case adminRoleID: + return adminRole, nil + case userRoleID: + return userRole, nil + default: + return rbac.Role{}, rbac.ErrNotFound + } + }).AnyTimes() + + store.EXPECT().GetSubject(ctx, "non-existing").Return(rbac.Subject{}, rbac.ErrNotFound).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: ""}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: "invalid-uuid"}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: adminRoleID}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: adminRoleID}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: userRoleID}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{ID: subjectID, RoleID: userRoleID}, nil).Times(1) + + store.EXPECT().GetSubject(ctx, otherSubjectID).Return(rbac.Subject{ID: otherSubjectID, RoleID: userRoleID}, nil).Times(1) + + r, err := rbac.NewRBAC(ctx, store) + assert.NoError(t, err) + assert.NotNil(t, r) + + err = r.RegisterBizRule(MockOwnershipBizRule{}) + assert.NoError(t, err) + + tests := []struct { + name string + subjectID string + action string + resource rbac.Resource + expectedResult bool + expectError bool + }{ + { + name: "Subject not found", + subjectID: "non-existing", + action: "read", + resource: resource, + expectError: true, + }, + { + name: "Subject without role", + subjectID: subjectID, + action: "read", + resource: resource, + expectedResult: false, + expectError: false, + }, + { + name: "Subject with invalid role UUID", + subjectID: subjectID, + action: "read", + resource: resource, + expectError: true, + }, + { + name: "Subject does not have permission", + subjectID: subjectID, + action: "update", + resource: resource, + expectedResult: false, + expectError: false, + }, + { + name: "Subject has direct permission", + subjectID: subjectID, + action: "read", + resource: resource, + expectedResult: true, + expectError: false, + }, + { + name: "Subject has permission via hierarchy", + subjectID: subjectID, + action: "write", + resource: resource, + expectedResult: true, + expectError: false, + }, + { + name: "Subject has permission with business rule (passes)", + subjectID: subjectID, + action: "delete", + resource: resource, + expectedResult: true, + expectError: false, + }, + { + name: "Subject has permission with business rule (fails)", + subjectID: otherSubjectID, + action: "delete", + resource: resource, + expectedResult: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := r.Can(ctx, tt.subjectID, tt.action, tt.resource) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + }) + } } From c8d2791957e1ce57f2773d6becc071cc48773ab9 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Wed, 8 Oct 2025 12:18:14 +0300 Subject: [PATCH 12/14] feat: refactor RBAC methods to use role names instead of IDs and add store methods --- example/main.go | 21 ++-- internal/mocks/store_mock.go | 29 +++++ pkg/rbac.go | 73 +++++------ pkg/rbac_test.go | 166 +++++++++++------------- pkg/store.go | 2 + pkg/store/gorm.go | 236 +++++++++++++++++++++++++++++++++++ 6 files changed, 388 insertions(+), 139 deletions(-) diff --git a/example/main.go b/example/main.go index d44f55d..899af19 100644 --- a/example/main.go +++ b/example/main.go @@ -108,24 +108,24 @@ func main() { fmt.Printf("Created roles: user=%s, admin=%s\n", userRole.ID, adminRole.ID) //Add user permissions - if err := r.AddPermissionToRole(ctx, userRole.ID, readPerm.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "user", readPerm.ID); err != nil { log.Fatal(err) } - if err := r.AddPermissionToRole(ctx, userRole.ID, createPerm.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "user", createPerm.ID); err != nil { log.Fatal(err) } - if err := r.AddPermissionToRole(ctx, userRole.ID, updateOwnPerm.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "user", updateOwnPerm.ID); err != nil { log.Fatal(err) } - if err := r.AddPermissionToRole(ctx, userRole.ID, deleteOwnPerm.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "user", deleteOwnPerm.ID); err != nil { log.Fatal(err) } //Add admin permissions - if err := r.AddPermissionToRole(ctx, adminRole.ID, updateAll.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "admin", updateAll.ID); err != nil { log.Fatal(err) } - if err := r.AddPermissionToRole(ctx, adminRole.ID, deleteAll.ID); err != nil { + if err := r.AddPermissionToRole(ctx, "admin", deleteAll.ID); err != nil { log.Fatal(err) } @@ -133,16 +133,15 @@ func main() { adminUserID := "admin-user-123" regularUserID := "regular-user-456" - // Assign roles - if err := r.AssignRole(ctx, adminUserID, adminRole.ID); err != nil { + // Create subjects with roles using role names + if err := r.CreateSubjectWithRole(ctx, adminUserID, "admin"); err != nil { log.Fatal(err) } - if err := r.AssignRole(ctx, regularUserID, userRole.ID); err != nil { + if err := r.CreateSubjectWithRole(ctx, regularUserID, "user"); err != nil { log.Fatal(err) } - // Test scenarios - fmt.Println("\n=== Testing RBAC with Business Rules ===\n") + fmt.Println("Created subjects with roles") // Test blogs blog1 := Blog{ID: "blog-1", Title: "Admin's Blog", OwnerID: adminUserID} diff --git a/internal/mocks/store_mock.go b/internal/mocks/store_mock.go index f79bc13..a1c792c 100644 --- a/internal/mocks/store_mock.go +++ b/internal/mocks/store_mock.go @@ -69,6 +69,20 @@ func (mr *MockStoreMockRecorder) CreateRole(ctx, role any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRole", reflect.TypeOf((*MockStore)(nil).CreateRole), ctx, role) } +// CreateSubject mocks base method. +func (m *MockStore) CreateSubject(ctx context.Context, subject rbac.Subject) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSubject", ctx, subject) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateSubject indicates an expected call of CreateSubject. +func (mr *MockStoreMockRecorder) CreateSubject(ctx, subject any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSubject", reflect.TypeOf((*MockStore)(nil).CreateSubject), ctx, subject) +} + // GetPermission mocks base method. func (m *MockStore) GetPermission(ctx context.Context, id string) (rbac.Permission, error) { m.ctrl.T.Helper() @@ -99,6 +113,21 @@ func (mr *MockStoreMockRecorder) GetRole(ctx, roleID any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRole", reflect.TypeOf((*MockStore)(nil).GetRole), ctx, roleID) } +// GetRoleByName mocks base method. +func (m *MockStore) GetRoleByName(ctx context.Context, name string) (rbac.Role, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoleByName", ctx, name) + ret0, _ := ret[0].(rbac.Role) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRoleByName indicates an expected call of GetRoleByName. +func (mr *MockStoreMockRecorder) GetRoleByName(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoleByName", reflect.TypeOf((*MockStore)(nil).GetRoleByName), ctx, name) +} + // GetSubject mocks base method. func (m *MockStore) GetSubject(ctx context.Context, subjectID string) (rbac.Subject, error) { m.ctrl.T.Helper() diff --git a/pkg/rbac.go b/pkg/rbac.go index 990ed85..e9a7ae9 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -82,46 +82,38 @@ func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentI } // UpdateRole updates an existing role's parent, which can be used to reorganize the hierarchy -func (r *RBAC) UpdateRole(ctx context.Context, roleID, newParentID string) error { - if err := validateUUIDs(roleID); err != nil { - return err - } - - role, err := r.store.GetRole(ctx, roleID) +func (r *RBAC) UpdateRole(ctx context.Context, roleName, newParentName string) error { + role, err := r.store.GetRoleByName(ctx, roleName) if err != nil { return ErrNotFound } - if newParentID != "" { - if err := validateUUIDs(newParentID); err != nil { - return err - } - - _, err := r.store.GetRole(ctx, newParentID) + if newParentName != "" { + parent, err := r.store.GetRoleByName(ctx, newParentName) if err != nil { - return err + return ErrNotFound } - if err := r.checkRoleHierarchyCycle(ctx, newParentID, roleID); err != nil { + if err := r.checkRoleHierarchyCycle(ctx, parent.ID, role.ID); err != nil { return err } + + role.ParentID = parent.ID + } else { + role.ParentID = "" } - role.ParentID = newParentID return r.store.UpdateRole(ctx, role) } // RemoveRole deletes a role if it's not in use by any subject -func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { - if err := validateUUIDs(roleID); err != nil { - return err - } - - if _, err := r.store.GetRole(ctx, roleID); err != nil { +func (r *RBAC) RemoveRole(ctx context.Context, roleName string) error { + role, err := r.store.GetRoleByName(ctx, roleName) + if err != nil { return ErrNotFound } - inUse, err := r.isRoleInUse(ctx, roleID) + inUse, err := r.isRoleInUse(ctx, role.ID) if err != nil { return err } @@ -129,7 +121,7 @@ func (r *RBAC) RemoveRole(ctx context.Context, roleID string) error { return ErrRoleInUse } - return r.store.RemoveRole(ctx, roleID) + return r.store.RemoveRole(ctx, role.ID) } // CreatePermission creates a new permission with optional business rule @@ -182,13 +174,24 @@ func (r *RBAC) RemovePermission(ctx context.Context, permID string) error { return r.store.RemovePermission(ctx, permID) } -// AssignRole assigns a role to a subject -func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { - if err := validateUUIDs(roleID); err != nil { - return err +// CreateSubjectWithRole creates a new subject and assigns a role by role name +func (r *RBAC) CreateSubjectWithRole(ctx context.Context, subjectID, roleName string) error { + role, err := r.store.GetRoleByName(ctx, roleName) + if err != nil { + return ErrNotFound } - if _, err := r.store.GetRole(ctx, roleID); err != nil { + subject := Subject{ + ID: subjectID, + RoleID: role.ID, + } + return r.store.CreateSubject(ctx, subject) +} + +// AssignRole assigns a role to a subject +func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleName string) error { + role, err := r.store.GetRoleByName(ctx, roleName) + if err != nil { return ErrNotFound } @@ -197,16 +200,16 @@ func (r *RBAC) AssignRole(ctx context.Context, subjectID, roleID string) error { return err } - subject.RoleID = roleID + subject.RoleID = role.ID return r.store.UpdateSubject(ctx, subject) } // AddPermissionToRole adds a permission to a role -func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) error { - if err := validateUUIDs(roleID, permID); err != nil { +func (r *RBAC) AddPermissionToRole(ctx context.Context, roleName, permID string) error { + if err := validateUUIDs(permID); err != nil { return err } - role, err := r.store.GetRole(ctx, roleID) + role, err := r.store.GetRoleByName(ctx, roleName) if err != nil { return ErrNotFound } @@ -222,11 +225,11 @@ func (r *RBAC) AddPermissionToRole(ctx context.Context, roleID, permID string) e } // RemovePermissionFromRole removes a permission from a role -func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleID, permID string) error { - if err := validateUUIDs(roleID, permID); err != nil { +func (r *RBAC) RemovePermissionFromRole(ctx context.Context, roleName, permID string) error { + if err := validateUUIDs(permID); err != nil { return err } - role, err := r.store.GetRole(ctx, roleID) + role, err := r.store.GetRoleByName(ctx, roleName) if err != nil { return ErrNotFound } diff --git a/pkg/rbac_test.go b/pkg/rbac_test.go index 1f32dec..87934ba 100644 --- a/pkg/rbac_test.go +++ b/pkg/rbac_test.go @@ -152,6 +152,18 @@ func TestUpdateRole(t *testing.T) { existingEditor := rbac.Role{ID: editorID, Name: "editor", ParentID: adminID} existingViewer := rbac.Role{ID: viewerID, Name: "viewer", ParentID: editorID} + store.EXPECT().GetRoleByName(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, name string) (rbac.Role, error) { + switch name { + case "admin": + return existingAdmin, nil + case "editor": + return existingEditor, nil + case "viewer": + return existingViewer, nil + default: + return rbac.Role{}, rbac.ErrNotFound + } + }).AnyTimes() store.EXPECT().GetRole(ctx, gomock.Any()).DoAndReturn(func(ctx context.Context, id string) (rbac.Role, error) { switch id { case adminID: @@ -172,51 +184,39 @@ func TestUpdateRole(t *testing.T) { tests := []struct { name string - roleID string - newParentID string + roleName string + newParentName string expectedError error expectAnyError bool }{ - { - name: "Update role with invalid UUID", - roleID: "invalid-uuid", - newParentID: adminID, - expectAnyError: true, - }, { name: "Update non-existing role", - roleID: "550e8400-e29b-41d4-a716-446655440099", - newParentID: adminID, + roleName: "nonexistent", + newParentName: "admin", expectedError: rbac.ErrNotFound, }, - { - name: "Update role with invalid parent UUID", - roleID: viewerID, - newParentID: "invalid-parent-uuid", - expectAnyError: true, - }, { name: "Update role with non-existing parent", - roleID: viewerID, - newParentID: "550e8400-e29b-41d4-a716-446655440099", + roleName: "viewer", + newParentName: "nonexistent", expectedError: rbac.ErrNotFound, }, { name: "Update role creates cycle", - roleID: adminID, - newParentID: editorID, + roleName: "admin", + newParentName: "editor", expectedError: rbac.ErrRoleCycle, }, { - name: "Update role successfully", - roleID: viewerID, - newParentID: adminID, + name: "Update role successfully", + roleName: "viewer", + newParentName: "admin", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := r.UpdateRole(ctx, tt.roleID, tt.newParentID) + err := r.UpdateRole(ctx, tt.roleName, tt.newParentName) if tt.expectedError == nil && !tt.expectAnyError { assert.NoError(t, err) @@ -238,10 +238,11 @@ func TestRemoveRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) + roleName := "user" roleID := "550e8400-e29b-41d4-a716-446655440000" - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ID: roleID, Name: "user"}, nil).Times(3) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ID: roleID, Name: roleName}, nil).Times(3) store.EXPECT().ListSubjects(ctx).Return([]string{"user1", "user2"}, nil).Times(3) store.EXPECT().GetSubject(ctx, "user1").Return(rbac.Subject{ID: "user1", RoleID: "some-other-role"}, nil).Times(3) store.EXPECT().GetSubject(ctx, "user2").Return(rbac.Subject{ID: "user2", RoleID: roleID}, nil).Times(1) @@ -255,39 +256,34 @@ func TestRemoveRole(t *testing.T) { tests := []struct { name string - roleID string + roleName string expectedError error expectAnyError bool }{ - { - name: "Remove role with invalid UUID", - roleID: "invalid-uuid", - expectAnyError: true, - }, { name: "Remove non-existing role", - roleID: roleID, + roleName: roleName, expectedError: rbac.ErrNotFound, }, { name: "Remove role that is in use", - roleID: roleID, + roleName: roleName, expectedError: rbac.ErrRoleInUse, }, { name: "Remove role that has children", - roleID: roleID, + roleName: roleName, expectedError: rbac.ErrRoleHasChildren, }, { - name: "Remove role successfully", - roleID: roleID, + name: "Remove role successfully", + roleName: roleName, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := r.RemoveRole(ctx, tt.roleID) + err := r.RemoveRole(ctx, tt.roleName) if tt.expectedError == nil && !tt.expectAnyError { assert.NoError(t, err) @@ -472,22 +468,22 @@ func TestAddPermissionToRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) - roleID := "550e8400-e29b-41d4-a716-446655440000" + roleName := "editor" permID := "550e8400-e29b-41d4-a716-446655440001" existingPermID := "550e8400-e29b-41d4-a716-446655440002" existingPerm := rbac.Permission{ID: existingPermID, Resource: "blog", Action: "read"} newPerm := rbac.Permission{ID: permID, Resource: "article", Action: "write"} - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ - ID: roleID, - Name: "editor", + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Name: roleName, Permissions: []rbac.Permission{existingPerm}, }, nil).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ - ID: roleID, - Name: "editor", + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Name: roleName, Permissions: []rbac.Permission{}, }, nil).Times(2) store.EXPECT().GetPermission(ctx, permID).Return(rbac.Permission{}, rbac.ErrNotFound).Times(1) @@ -500,51 +496,45 @@ func TestAddPermissionToRole(t *testing.T) { tests := []struct { name string - roleID string + roleName string permID string expectedError error expectAnyError bool }{ - { - name: "Add permission with invalid role UUID", - roleID: "invalid-uuid", - permID: permID, - expectAnyError: true, - }, { name: "Add permission with invalid permission UUID", - roleID: roleID, + roleName: roleName, permID: "invalid-uuid", expectAnyError: true, }, { name: "Add permission to non-existing role", - roleID: roleID, + roleName: roleName, permID: permID, expectedError: rbac.ErrNotFound, }, { name: "Add duplicate permission to role", - roleID: roleID, + roleName: roleName, permID: existingPermID, expectedError: rbac.ErrAlreadyExists, }, { name: "Add non-existing permission to role", - roleID: roleID, + roleName: roleName, permID: permID, expectedError: rbac.ErrNotFound, }, { - name: "Add permission to role successfully", - roleID: roleID, - permID: permID, + name: "Add permission to role successfully", + roleName: roleName, + permID: permID, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := r.AddPermissionToRole(ctx, tt.roleID, tt.permID) + err := r.AddPermissionToRole(ctx, tt.roleName, tt.permID) if tt.expectedError == nil && !tt.expectAnyError { assert.NoError(t, err) @@ -566,21 +556,22 @@ func TestRemovePermissionFromRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) + roleName := "editor" roleID := "550e8400-e29b-41d4-a716-446655440000" permID := "550e8400-e29b-41d4-a716-446655440001" otherPermID := "550e8400-e29b-41d4-a716-446655440002" existingPerm := rbac.Permission{ID: permID, Resource: "blog", Action: "read"} - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ ID: roleID, - Name: "editor", + Name: roleName, Permissions: []rbac.Permission{}, }, nil).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ ID: roleID, - Name: "editor", + Name: roleName, Permissions: []rbac.Permission{existingPerm}, }, nil).Times(1) store.EXPECT().UpdateRole(ctx, gomock.Any()).Return(nil).Times(1) @@ -591,45 +582,39 @@ func TestRemovePermissionFromRole(t *testing.T) { tests := []struct { name string - roleID string + roleName string permID string expectedError error expectAnyError bool }{ - { - name: "Remove permission with invalid role UUID", - roleID: "invalid-uuid", - permID: permID, - expectAnyError: true, - }, { name: "Remove permission with invalid permission UUID", - roleID: roleID, + roleName: roleName, permID: "invalid-uuid", expectAnyError: true, }, { name: "Remove permission from non-existing role", - roleID: roleID, + roleName: roleName, permID: permID, expectedError: rbac.ErrNotFound, }, { name: "Remove non-existing permission from role", - roleID: roleID, + roleName: roleName, permID: otherPermID, expectedError: rbac.ErrNotFound, }, { - name: "Remove permission from role successfully", - roleID: roleID, - permID: permID, + name: "Remove permission from role successfully", + roleName: roleName, + permID: permID, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := r.RemovePermissionFromRole(ctx, tt.roleID, tt.permID) + err := r.RemovePermissionFromRole(ctx, tt.roleName, tt.permID) if tt.expectedError == nil && !tt.expectAnyError { assert.NoError(t, err) @@ -651,13 +636,14 @@ func TestAssignRole(t *testing.T) { ctx := context.Background() store := mocks.NewMockStore(ctrl) + roleName := "admin" roleID := "550e8400-e29b-41d4-a716-446655440000" subjectID := "subject-123" existingSubject := rbac.Subject{ID: subjectID, RoleID: ""} - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - store.EXPECT().GetRole(ctx, roleID).Return(rbac.Role{ID: roleID, Name: "admin"}, nil).Times(2) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{}, rbac.ErrNotFound).Times(1) + store.EXPECT().GetRoleByName(ctx, roleName).Return(rbac.Role{ID: roleID, Name: roleName}, nil).Times(2) store.EXPECT().GetSubject(ctx, subjectID).Return(rbac.Subject{}, rbac.ErrNotFound).Times(1) store.EXPECT().GetSubject(ctx, subjectID).Return(existingSubject, nil).Times(1) store.EXPECT().UpdateSubject(ctx, rbac.Subject{ID: subjectID, RoleID: roleID}).Return(nil).Times(1) @@ -669,38 +655,32 @@ func TestAssignRole(t *testing.T) { tests := []struct { name string subjectID string - roleID string + roleName string expectedError error expectAnyError bool }{ - { - name: "Assign role with invalid role UUID", - subjectID: subjectID, - roleID: "invalid-uuid", - expectAnyError: true, - }, { name: "Assign non-existing role", subjectID: subjectID, - roleID: roleID, + roleName: roleName, expectedError: rbac.ErrNotFound, }, { name: "Assign role to non-existing subject", subjectID: subjectID, - roleID: roleID, + roleName: roleName, expectAnyError: true, }, { name: "Assign role successfully", subjectID: subjectID, - roleID: roleID, + roleName: roleName, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := r.AssignRole(ctx, tt.subjectID, tt.roleID) + err := r.AssignRole(ctx, tt.subjectID, tt.roleName) if tt.expectedError == nil && !tt.expectAnyError { assert.NoError(t, err) diff --git a/pkg/store.go b/pkg/store.go index b1cccdf..7d82a38 100644 --- a/pkg/store.go +++ b/pkg/store.go @@ -7,6 +7,7 @@ type Store interface { // Roles CreateRole(ctx context.Context, role Role) error GetRole(ctx context.Context, roleID string) (Role, error) + GetRoleByName(ctx context.Context, name string) (Role, error) UpdateRole(ctx context.Context, role Role) error RemoveRole(ctx context.Context, roleID string) error ListRoles(ctx context.Context) ([]Role, error) @@ -18,6 +19,7 @@ type Store interface { RemovePermission(ctx context.Context, id string) error // Subjects + CreateSubject(ctx context.Context, subject Subject) error GetSubject(ctx context.Context, subjectID string) (Subject, error) UpdateSubject(ctx context.Context, subject Subject) error ListSubjects(ctx context.Context) ([]string, error) diff --git a/pkg/store/gorm.go b/pkg/store/gorm.go index 52cd38f..ae7702d 100644 --- a/pkg/store/gorm.go +++ b/pkg/store/gorm.go @@ -1,6 +1,10 @@ package store import ( + "context" + "fmt" + + rbac "github.com/codescalers/rbac/pkg" "gorm.io/gorm" ) @@ -45,3 +49,235 @@ type Subject struct { ID string `gorm:"primaryKey"` RoleID string `gorm:"index"` } + +func (s *GormStore) Close() error { + db, err := s.db.DB() + if err != nil { + return err + } + return db.Close() +} + +func (s *GormStore) CreateRole(ctx context.Context, role rbac.Role) error { + r := Role{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + ParentID: role.ParentID, + } + + r.Permissions = convertPermissionsFromRBAC(role.Permissions) + + return s.db.WithContext(ctx).Create(&r).Error +} + +func (s *GormStore) GetRole(ctx context.Context, roleID string) (rbac.Role, error) { + var r Role + err := s.db.WithContext(ctx).Preload("Permissions").First(&r, "id = ?", roleID).Error + if err != nil { + return rbac.Role{}, err + } + + role := rbac.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + ParentID: r.ParentID, + Permissions: convertToRBACPermissions(r.Permissions), + } + + return role, nil +} + +func (s *GormStore) GetRoleByName(ctx context.Context, name string) (rbac.Role, error) { + var r Role + err := s.db.WithContext(ctx).Preload("Permissions").First(&r, "name = ?", name).Error + if err != nil { + return rbac.Role{}, err + } + + role := rbac.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + ParentID: r.ParentID, + Permissions: convertToRBACPermissions(r.Permissions), + } + + return role, nil +} + +func (s *GormStore) UpdateRole(ctx context.Context, role rbac.Role) error { + var permIDs []string + for _, p := range role.Permissions { + permIDs = append(permIDs, p.ID) + } + + var perms []Permission + if len(permIDs) > 0 { + if err := s.db.WithContext(ctx).Find(&perms, "id IN ?", permIDs).Error; err != nil { + return err + } + if len(perms) != len(permIDs) { + return fmt.Errorf("some permissions not found for IDs: %v", permIDs) + } + } + + r := Role{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + ParentID: role.ParentID, + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&r).Association("Permissions").Replace(perms); err != nil { + return err + } + return tx.Save(&r).Error + }) +} + +func (s *GormStore) RemoveRole(ctx context.Context, roleID string) error { + return s.db.WithContext(ctx).Delete(&Role{}, "id = ?", roleID).Error +} + +func (s *GormStore) ListRoles(ctx context.Context) ([]rbac.Role, error) { + var roles []Role + err := s.db.WithContext(ctx).Preload("Permissions").Find(&roles).Error + if err != nil { + return nil, err + } + + result := make([]rbac.Role, 0, len(roles)) + for _, r := range roles { + role := rbac.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + ParentID: r.ParentID, + Permissions: convertToRBACPermissions(r.Permissions), + } + result = append(result, role) + } + + return result, nil +} + +func (s *GormStore) CreatePermission(ctx context.Context, p rbac.Permission) error { + perm := Permission{ + ID: p.ID, + Resource: p.Resource, + Action: p.Action, + BizRule: p.BizRule, + } + return s.db.WithContext(ctx).Create(&perm).Error +} + +func (s *GormStore) GetPermission(ctx context.Context, id string) (rbac.Permission, error) { + var permission Permission + err := s.db.WithContext(ctx).First(&permission, "id = ?", id).Error + if err != nil { + return rbac.Permission{}, err + } + + return rbac.Permission{ + ID: permission.ID, + Resource: permission.Resource, + Action: permission.Action, + BizRule: permission.BizRule, + }, nil +} + +func (s *GormStore) ListPermissions(ctx context.Context) ([]rbac.Permission, error) { + var perms []Permission + err := s.db.WithContext(ctx).Find(&perms).Error + if err != nil { + return nil, err + } + + result := make([]rbac.Permission, 0, len(perms)) + for _, perm := range perms { + result = append(result, rbac.Permission{ + ID: perm.ID, + Resource: perm.Resource, + Action: perm.Action, + BizRule: perm.BizRule, + }) + } + return result, nil +} + +func (s *GormStore) RemovePermission(ctx context.Context, id string) error { + return s.db.WithContext(ctx).Delete(&Permission{}, "id = ?", id).Error +} + +func (s *GormStore) CreateSubject(ctx context.Context, subject rbac.Subject) error { + sub := Subject{ + ID: subject.ID, + RoleID: subject.RoleID, + } + return s.db.WithContext(ctx).Create(&sub).Error +} + +func (s *GormStore) GetSubject(ctx context.Context, subjectID string) (rbac.Subject, error) { + var sub Subject + err := s.db.WithContext(ctx).First(&sub, "id = ?", subjectID).Error + if err != nil { + return rbac.Subject{}, err + } + + return rbac.Subject{ + ID: sub.ID, + RoleID: sub.RoleID, + }, nil +} + +func (s *GormStore) UpdateSubject(ctx context.Context, subject rbac.Subject) error { + sub := Subject{ + ID: subject.ID, + RoleID: subject.RoleID, + } + return s.db.WithContext(ctx).Save(&sub).Error +} + +func (s *GormStore) ListSubjects(ctx context.Context) ([]string, error) { + var subjects []Subject + err := s.db.WithContext(ctx).Find(&subjects).Error + if err != nil { + return nil, err + } + + result := make([]string, 0, len(subjects)) + for _, sub := range subjects { + result = append(result, sub.ID) + } + + return result, nil +} + +func convertPermissionsFromRBAC(rbacPerms []rbac.Permission) []Permission { + perms := make([]Permission, 0, len(rbacPerms)) + for _, p := range rbacPerms { + perms = append(perms, Permission{ + ID: p.ID, + Resource: p.Resource, + Action: p.Action, + BizRule: p.BizRule, + }) + } + return perms +} + +func convertToRBACPermissions(perms []Permission) []rbac.Permission { + rbacPerms := make([]rbac.Permission, 0, len(perms)) + for _, p := range perms { + rbacPerms = append(rbacPerms, rbac.Permission{ + ID: p.ID, + Resource: p.Resource, + Action: p.Action, + BizRule: p.BizRule, + }) + } + return rbacPerms +} From 64d531d961fefce3df8df92e513116ec9526d626 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Wed, 8 Oct 2025 13:01:24 +0300 Subject: [PATCH 13/14] refactor: update role creation to use role names instead of IDs and enhance README with usage examples --- README.md | 182 ++++++++++++++++++++++++++++++++++++++++++++++- example/main.go | 2 +- pkg/rbac.go | 17 ++--- pkg/rbac_test.go | 27 +++---- 4 files changed, 199 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 33cd56b..7f762bf 100644 --- a/README.md +++ b/README.md @@ -1 +1,181 @@ -# rbac \ No newline at end of file +# RBAC - Role-Based Access Control + +This project implements a flexible and powerful Role-Based Access Control (RBAC) system for Go applications. It provides hierarchical role management, dynamic permission evaluation through business rules, and supports multiple storage backends. + +## Installation + +- To use the provided package: + + ```bash + import "github.com/codescalers/rbac/pkg" + ``` + + First import the package in your Go file + +- Then get the package by running this command in CLI: + + ```bash + go get github.com/codescalers/rbac + ``` + +- Initialize the RBAC system: + + ```go + // Setup database + db, _ := gorm.Open(sqlite.Open("rbac.db"), &gorm.Config{}) + store, _ := store.NewGormStore(db) + + // Create RBAC instance + r, _ := rbac.NewRBAC(context.Background(), store) + ``` + +## Usage + +The RBAC system provides a simple and intuitive API for managing roles, permissions, and subjects. Here's how to use the main features: + +### Example: Blog Access Control + +```go +ctx := context.Background() + +// 1. Create roles with hierarchy +userRole, _ := r.CreateRole(ctx, "user", "Regular user") +adminRole, _ := r.CreateRole(ctx, "admin", "Administrator", "user") + +// 2. Create permissions +readPerm, _ := r.CreatePermission(ctx, "blog", "read") +writePerm, _ := r.CreatePermission(ctx, "blog", "write") +deletePerm, _ := r.CreatePermission(ctx, "blog", "delete") + +// 3. Assign permissions to roles +r.AddPermissionToRole(ctx, "user", readPerm.ID) +r.AddPermissionToRole(ctx, "admin", writePerm.ID) +r.AddPermissionToRole(ctx, "admin", deletePerm.ID) + +// 4. Create subjects with roles +r.CreateSubjectWithRole(ctx, "user-123", "user") +r.CreateSubjectWithRole(ctx, "admin-456", "admin") + +// 5. Check permissions +type Blog struct { + ID string + Title string +} + +func (b Blog) Name() string { return "blog" } + +blog := Blog{ID: "1", Title: "My Post"} + +// User can read (has direct permission) +canRead, _ := r.Can(ctx, "user-123", "read", blog) +fmt.Println("User can read:", canRead) // true + +// User cannot delete (doesn't have permission) +canDelete, _ := r.Can(ctx, "user-123", "delete", blog) +fmt.Println("User can delete:", canDelete) // false + +// Admin can read (inherited from user role) +canRead, _ = r.Can(ctx, "admin-456", "read", blog) +fmt.Println("Admin can read:", canRead) // true + +// Admin can delete (has direct permission) +canDelete, _ = r.Can(ctx, "admin-456", "delete", blog) +fmt.Println("Admin can delete:", canDelete) // true +``` + +### Using Business Rules + +Business rules allow dynamic permission evaluation based on resource context (e.g., ownership): + +```go +// Define a business rule +type OwnershipRule struct{} + +func (r OwnershipRule) Name() string { + return "ownership" +} + +func (r OwnershipRule) Evaluate(ctx context.Context, subjectID string, resource rbac.Resource) (bool, error) { + blog, ok := resource.(BlogPost) + if !ok { + return false, fmt.Errorf("expected BlogPost") + } + return blog.OwnerID == subjectID, nil +} + +// Register the rule +r.RegisterBizRule(rbac.BizRule(OwnershipRule{})) + +// Create permission with business rule +updateOwnPerm, _ := r.CreatePermission(ctx, "blog", "update", "ownership") + +// Add to role +r.AddPermissionToRole(ctx, "user", updateOwnPerm.ID) + +// Check permission +type BlogPost struct { + ID string + Title string + OwnerID string +} + +func (b BlogPost) Name() string { return "blog" } + +myPost := BlogPost{ID: "1", Title: "My Post", OwnerID: "user-123"} +otherPost := BlogPost{ID: "2", Title: "Other Post", OwnerID: "user-789"} + +// User can update their own post +canUpdate, _ := r.Can(ctx, "user-123", "update", myPost) +fmt.Println("Can update own post:", canUpdate) // true + +// User cannot update others' posts +canUpdate, _ = r.Can(ctx, "user-123", "update", otherPost) +fmt.Println("Can update other post:", canUpdate) // false +``` + +### Role Hierarchy + +Child roles automatically inherit all permissions from their parent roles: + +```go +// Create hierarchy: viewer <- editor <- admin +viewer, _ := r.CreateRole(ctx, "viewer", "Can view content") +editor, _ := r.CreateRole(ctx, "editor", "Can edit content", "viewer") +admin, _ := r.CreateRole(ctx, "admin", "Full access", "editor") + +// Assign permissions +readPerm, _ := r.CreatePermission(ctx, "document", "read") +editPerm, _ := r.CreatePermission(ctx, "document", "edit") +deletePerm, _ := r.CreatePermission(ctx, "document", "delete") + +r.AddPermissionToRole(ctx, "viewer", readPerm.ID) +r.AddPermissionToRole(ctx, "editor", editPerm.ID) +r.AddPermissionToRole(ctx, "admin", deletePerm.ID) + +// Create users +r.CreateSubjectWithRole(ctx, "user-1", "viewer") +r.CreateSubjectWithRole(ctx, "user-2", "editor") +r.CreateSubjectWithRole(ctx, "user-3", "admin") + +// viewer: can only read +// editor: can read (inherited) + edit +// admin: can read (inherited) + edit (inherited) + delete +``` + +For detailed API documentation, see [API Reference](./docs/API.md). + +## Storage Backends + +The library includes comprehensive tests with over 45 test cases: + +```bash +# Run all tests +go test ./pkg/... -v + +# Run tests with coverage +go test ./pkg/... -cover +``` + +## License + +MIT License - see [LICENSE](LICENSE) file for details. diff --git a/example/main.go b/example/main.go index 899af19..e3cd8ab 100644 --- a/example/main.go +++ b/example/main.go @@ -100,7 +100,7 @@ func main() { if err != nil { log.Fatal(err) } - adminRole, err := r.CreateRole(ctx, "admin", "Administrator with full access", userRole.ID) + adminRole, err := r.CreateRole(ctx, "admin", "Administrator with full access", "user") if err != nil { log.Fatal(err) } diff --git a/pkg/rbac.go b/pkg/rbac.go index e9a7ae9..a8f9594 100644 --- a/pkg/rbac.go +++ b/pkg/rbac.go @@ -46,7 +46,7 @@ func (r *RBAC) initFromSeed(ctx context.Context, roles []Role) error { } // CreateRole creates a new role with optional parent for hierarchy -func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentID ...string) (Role, error) { +func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentName ...string) (Role, error) { n := normalizeString(name) if n == "" { return Role{}, ErrInvalidName @@ -62,17 +62,14 @@ func (r *RBAC) CreateRole(ctx context.Context, name, description string, parentI role := Role{ID: uuid.New().String(), Name: n, Description: description} - if len(parentID) > 0 && parentID[0] != "" { - if err := validateUUIDs(parentID[0]); err != nil { - return Role{}, err - } - - // Verify parent exists - if _, err := r.store.GetRole(ctx, parentID[0]); err != nil { - return Role{}, err + if len(parentName) > 0 && parentName[0] != "" { + // Get parent role by name + parent, err := r.store.GetRoleByName(ctx, parentName[0]) + if err != nil { + return Role{}, ErrNotFound } - role.ParentID = parentID[0] + role.ParentID = parent.ID } if err := r.store.CreateRole(ctx, role); err != nil { diff --git a/pkg/rbac_test.go b/pkg/rbac_test.go index 87934ba..156c221 100644 --- a/pkg/rbac_test.go +++ b/pkg/rbac_test.go @@ -51,15 +51,15 @@ func TestCreateRole(t *testing.T) { existingAdmin := rbac.Role{ID: adminID, Name: "admin"} store.EXPECT().ListRoles(ctx).Return([]rbac.Role{existingAdmin}, nil).Times(4) - store.EXPECT().GetRole(ctx, adminID).Return(rbac.Role{ID: adminID, Name: "admin"}, nil).Times(1) + store.EXPECT().GetRoleByName(ctx, "admin").Return(rbac.Role{ID: adminID, Name: "admin"}, nil).Times(1) + store.EXPECT().GetRoleByName(ctx, "nonexistent").Return(rbac.Role{}, rbac.ErrNotFound).Times(1) store.EXPECT().CreateRole(ctx, gomock.Any()).Return(nil).Times(2) tests := []struct { name string roleName string description string - parentID string - setupMock func() + parentName string expectedError error validateRole func(role rbac.Role) }{ @@ -76,13 +76,10 @@ func TestCreateRole(t *testing.T) { expectedError: rbac.ErrDuplicateRole, }, { - name: "Create role with non-existing parent", - roleName: "user", - description: "description", - parentID: "660e8400-e29b-41d4-a716-446655440099", - setupMock: func() { - store.EXPECT().GetRole(ctx, "660e8400-e29b-41d4-a716-446655440099").Return(rbac.Role{}, rbac.ErrNotFound).Times(1) - }, + name: "Create role with non-existing parent", + roleName: "user", + description: "description", + parentName: "nonexistent", expectedError: rbac.ErrNotFound, }, { @@ -98,7 +95,7 @@ func TestCreateRole(t *testing.T) { name: "Create role successfully with parent", roleName: "editor", description: "Editor role", - parentID: adminID, + parentName: "admin", validateRole: func(role rbac.Role) { assert.Equal(t, "editor", role.Name) assert.Equal(t, "Editor role", role.Description) @@ -109,14 +106,10 @@ func TestCreateRole(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.setupMock != nil { - tt.setupMock() - } - var role rbac.Role var err error - if tt.parentID != "" { - role, err = r.CreateRole(ctx, tt.roleName, tt.description, tt.parentID) + if tt.parentName != "" { + role, err = r.CreateRole(ctx, tt.roleName, tt.description, tt.parentName) } else { role, err = r.CreateRole(ctx, tt.roleName, tt.description) } From 614c9bc49dc8d0ba004a3fdb8f73b3955514ce47 Mon Sep 17 00:00:00 2001 From: Salma Elsoly Date: Wed, 8 Oct 2025 14:00:00 +0300 Subject: [PATCH 14/14] feat: add Gin middleware for RBAC with error handling --- go.mod | 43 +++++++++++++++++---- go.sum | 80 +++++++++++++++++++++++++++++++++++++++- pkg/middleware/errors.go | 38 +++++++++++++++++++ pkg/middleware/gin.go | 62 +++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 pkg/middleware/errors.go create mode 100644 pkg/middleware/gin.go diff --git a/go.mod b/go.mod index 05d2714..0ad2045 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,49 @@ module github.com/codescalers/rbac go 1.24.6 require ( + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.0 ) require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -require ( - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - golang.org/x/text v0.20.0 // indirect - gorm.io/driver/sqlite v1.6.0 -) diff --git a/go.sum b/go.sum index 84568cb..6828998 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,98 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= diff --git a/pkg/middleware/errors.go b/pkg/middleware/errors.go new file mode 100644 index 0000000..f376e30 --- /dev/null +++ b/pkg/middleware/errors.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + + rbac "github.com/codescalers/rbac/pkg" +) + +// errorResponse represents an HTTP error response +type errorResponse struct { + StatusCode int + Error string + Message string +} + +// handleError converts RBAC errors to appropriate HTTP error responses +func handleError(err error) errorResponse { + switch err { + case rbac.ErrNotFound: + return errorResponse{ + StatusCode: http.StatusUnauthorized, + Error: "Unauthorized", + Message: "User not found or not assigned to any role", + } + case rbac.ErrInvalidName, rbac.ErrInvalidResourceOrAction: + return errorResponse{ + StatusCode: http.StatusBadRequest, + Error: "Bad Request", + Message: err.Error(), + } + default: + return errorResponse{ + StatusCode: http.StatusInternalServerError, + Error: "Internal Server Error", + Message: err.Error(), + } + } +} diff --git a/pkg/middleware/gin.go b/pkg/middleware/gin.go new file mode 100644 index 0000000..348cfa1 --- /dev/null +++ b/pkg/middleware/gin.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + + rbac "github.com/codescalers/rbac/pkg" + "github.com/gin-gonic/gin" +) + +// GinConfig holds configuration for Gin middleware +type GinConfig struct { + RBAC *rbac.RBAC + Action string + SubjectExtractor GinSubjectExtractor + ResourceExtractor GinResourceExtractor +} + +type GinSubjectExtractor func(*gin.Context) string + +type GinResourceExtractor func(*gin.Context) rbac.Resource + +func RequirePermissionGin(config GinConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Extract subject ID + subjectID := "" + if config.SubjectExtractor != nil { + subjectID = config.SubjectExtractor(c) + } + + if subjectID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required: Subject ID not found"}) + c.Abort() + return + } + + // Extract resource + var resource rbac.Resource + if config.ResourceExtractor != nil { + resource = config.ResourceExtractor(c) + } + + // Check permission + allowed, err := config.RBAC.Can(c.Request.Context(), subjectID, config.Action, resource) + if err != nil { + errResp := handleError(err) + c.JSON(errResp.StatusCode, gin.H{ + "error": errResp.Error, + "message": errResp.Message, + }) + c.Abort() + return + } + + if !allowed { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied for this resource"}) + c.Abort() + return + } + + c.Next() + } +}