@nakedev/go-scaffold 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,101 @@
1
+ package role
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+ "{{goModule}}/internal/shared/middleware"
8
+ "{{goModule}}/internal/shared/pagination"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ // PermRoleManage gates every endpoint in this file — role/permission
14
+ // management is a single admin capability, not split per-action.
15
+ const PermRoleManage = "role:manage"
16
+
17
+ type Handler struct {
18
+ svc *Service
19
+ jwtSecret string
20
+ authz *middleware.Authz
21
+ }
22
+
23
+ func NewHandler(svc *Service, jwtSecret string, authz *middleware.Authz) *Handler {
24
+ return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
25
+ }
26
+
27
+ // Register wires role's routes onto the router group (takes an IRouter so it can be nested under /v1)
28
+ func (h *Handler) Register(rg gin.IRouter) {
29
+ roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
30
+ roles.GET("", h.list)
31
+ roles.POST("", h.create)
32
+ roles.PATCH("/:code/permissions", h.setPermissions)
33
+ roles.DELETE("/:code", h.delete)
34
+
35
+ perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(PermRoleManage))
36
+ perms.GET("", h.listPermissions)
37
+ }
38
+
39
+ func (h *Handler) list(c *gin.Context) {
40
+ p := pagination.Parse(c)
41
+ items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
42
+ if err != nil {
43
+ c.Error(err)
44
+ return
45
+ }
46
+ out := make([]response, len(items))
47
+ for i := range items {
48
+ out[i] = toResponse(&items[i].Role, items[i].Permissions)
49
+ }
50
+ c.JSON(http.StatusOK, p.Response(out))
51
+ }
52
+
53
+ func (h *Handler) create(c *gin.Context) {
54
+ var in CreateInput
55
+ if err := c.ShouldBindJSON(&in); err != nil {
56
+ c.Error(httpx.BindErr(err))
57
+ return
58
+ }
59
+ item, err := h.svc.Create(c.Request.Context(), in)
60
+ if err != nil {
61
+ c.Error(err)
62
+ return
63
+ }
64
+ c.JSON(http.StatusCreated, toResponse(&item.Role, item.Permissions))
65
+ }
66
+
67
+ func (h *Handler) setPermissions(c *gin.Context) {
68
+ var in SetPermissionsInput
69
+ if err := c.ShouldBindJSON(&in); err != nil {
70
+ c.Error(httpx.BindErr(err))
71
+ return
72
+ }
73
+ item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), in.PermissionCodes)
74
+ if err != nil {
75
+ c.Error(err)
76
+ return
77
+ }
78
+ c.JSON(http.StatusOK, toResponse(&item.Role, item.Permissions))
79
+ }
80
+
81
+ func (h *Handler) delete(c *gin.Context) {
82
+ if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
83
+ c.Error(err)
84
+ return
85
+ }
86
+ c.Status(http.StatusNoContent)
87
+ }
88
+
89
+ func (h *Handler) listPermissions(c *gin.Context) {
90
+ p := pagination.Parse(c)
91
+ items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
92
+ if err != nil {
93
+ c.Error(err)
94
+ return
95
+ }
96
+ out := make([]permissionResponse, len(items))
97
+ for i := range items {
98
+ out[i] = toPermissionResponse(&items[i])
99
+ }
100
+ c.JSON(http.StatusOK, p.Response(out))
101
+ }
@@ -0,0 +1,9 @@
1
+ package model
2
+
3
+ // Permission = an action middleware.Authz.Require checks for. Rows are
4
+ // seeded by migration, not admin-created — a permission with no matching
5
+ // Require() call in the code would do nothing.
6
+ type Permission struct {
7
+ Code string `json:"code" gorm:"type:varchar(50);primaryKey"`
8
+ Description string `json:"description" gorm:"not null"`
9
+ }
@@ -0,0 +1,18 @@
1
+ // Package model holds this domain's GORM tables — a folder (not a single
2
+ // file) so a domain with more than one table can add a file per table
3
+ // without cluttering the domain package's top level.
4
+ package model
5
+
6
+ import "time"
7
+
8
+ // Role = an assignable permission level, backed by a real table instead of a
9
+ // hardcoded enum — adding one is a row insert, not a migration + redeploy.
10
+ // Code is the primary key (not a generated UUID): users.role already stores
11
+ // this string, so using it as the join key needs zero data migration.
12
+ type Role struct {
13
+ Code string `json:"code" gorm:"type:varchar(20);primaryKey"`
14
+ Name string `json:"name" gorm:"not null"`
15
+ IsSystem bool `json:"is_system" gorm:"not null;default:false"`
16
+ CreatedAt time.Time `json:"created_at"`
17
+ UpdatedAt time.Time `json:"updated_at"`
18
+ }
@@ -0,0 +1,8 @@
1
+ package model
2
+
3
+ // RolePermission = the grant between Role and Permission — the one thing
4
+ // admins actually edit; Role and Permission rows themselves stay fixed.
5
+ type RolePermission struct {
6
+ RoleCode string `json:"role_code" gorm:"primaryKey;type:varchar(20)"`
7
+ PermissionCode string `json:"permission_code" gorm:"primaryKey;type:varchar(50)"`
8
+ }
@@ -0,0 +1,96 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/role/model"
7
+
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ // Repository = data access for role (the only place that touches the DB for this domain)
12
+ // every method takes ctx, so a cancelled request cancels the query too
13
+ type Repository struct {
14
+ db *gorm.DB
15
+ }
16
+
17
+ func NewRepository(db *gorm.DB) *Repository {
18
+ return &Repository{db: db}
19
+ }
20
+
21
+ // PermissionCodes returns the permission codes granted to roleCode.
22
+ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
23
+ var codes []string
24
+ err := r.db.WithContext(ctx).
25
+ Model(&model.RolePermission{}).
26
+ Where("role_code = ?", roleCode).
27
+ Pluck("permission_code", &codes).Error
28
+ return codes, err
29
+ }
30
+
31
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
32
+ var items []model.Role
33
+ err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
34
+ return items, err
35
+ }
36
+
37
+ func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
38
+ var m model.Role
39
+ if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
40
+ return nil, err
41
+ }
42
+ return &m, nil
43
+ }
44
+
45
+ func (r *Repository) Create(ctx context.Context, m *model.Role) error {
46
+ return r.db.WithContext(ctx).Create(m).Error
47
+ }
48
+
49
+ // SetPermissions replaces roleCode's entire permission grant set in one
50
+ // transaction — simpler for a checkbox-style admin UI than diffing
51
+ // add/remove, and cheap at this scale (a handful of rows per role).
52
+ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
53
+ return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
54
+ if err := tx.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
55
+ return err
56
+ }
57
+ if len(permissionCodes) == 0 {
58
+ return nil
59
+ }
60
+ grants := make([]model.RolePermission, len(permissionCodes))
61
+ for i, code := range permissionCodes {
62
+ grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
63
+ }
64
+ return tx.Create(&grants).Error
65
+ })
66
+ }
67
+
68
+ func (r *Repository) Delete(ctx context.Context, code string) error {
69
+ return r.db.WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
70
+ }
71
+
72
+ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
73
+ var items []model.Permission
74
+ err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
75
+ return items, err
76
+ }
77
+
78
+ // AllPermissionCodes returns every permission code that exists, unpaginated
79
+ // — the catalog is a handful of rows, cheap to fetch whole for validating a
80
+ // SetPermissions request.
81
+ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
82
+ var codes []string
83
+ err := r.db.WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
84
+ return codes, err
85
+ }
86
+
87
+ // CountRolesWithPermission counts roles other than excludeCode that grant
88
+ // permissionCode — used to check whether editing or deleting excludeCode
89
+ // would leave no role holding permissionCode at all.
90
+ func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
91
+ var count int64
92
+ err := r.db.WithContext(ctx).Model(&model.RolePermission{}).
93
+ Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
94
+ Count(&count).Error
95
+ return count, err
96
+ }
@@ -0,0 +1,210 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "regexp"
7
+ "slices"
8
+
9
+ "{{goModule}}/internal/app/role/model"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ "{{goModule}}/internal/shared/dberr"
12
+
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ // repository = what the service needs from the data layer (declared on the
17
+ // consumer side, so it can be faked in tests)
18
+ type repository interface {
19
+ PermissionCodes(ctx context.Context, roleCode string) ([]string, error)
20
+ FindAll(ctx context.Context, limit, offset int) ([]model.Role, error)
21
+ FindByCode(ctx context.Context, code string) (*model.Role, error)
22
+ Create(ctx context.Context, m *model.Role) error
23
+ SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error
24
+ Delete(ctx context.Context, code string) error
25
+ FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error)
26
+ AllPermissionCodes(ctx context.Context) ([]string, error)
27
+ CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error)
28
+ }
29
+
30
+ // Service = business logic for role (rules live here, it knows nothing about HTTP)
31
+ type Service struct {
32
+ repo repository
33
+ }
34
+
35
+ func NewService(repo repository) *Service {
36
+ return &Service{repo: repo}
37
+ }
38
+
39
+ // PermissionsOf resolves roleCode's permission set. This is the func value
40
+ // injected into middleware.Authz from main.go — shared/ can't import a
41
+ // domain package, so it calls this through a func, not a direct dependency.
42
+ func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
43
+ codes, err := s.repo.PermissionCodes(ctx, roleCode)
44
+ if err != nil {
45
+ return nil, err
46
+ }
47
+ set := make(map[string]struct{}, len(codes))
48
+ for _, c := range codes {
49
+ set[c] = struct{}{}
50
+ }
51
+ return set, nil
52
+ }
53
+
54
+ // CodeExists reports whether code names a real role — used by user.Service's
55
+ // SetRole to validate a role assignment: AutoMigrate-only test schemas never
56
+ // create the users_role_fkey FK, so that alone can't be relied on to reject
57
+ // an unknown code.
58
+ func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
59
+ if _, err := s.repo.FindByCode(ctx, code); err != nil {
60
+ if errors.Is(err, gorm.ErrRecordNotFound) {
61
+ return false, nil
62
+ }
63
+ return false, err
64
+ }
65
+ return true, nil
66
+ }
67
+
68
+ // roleListItem pairs a role with its granted permission codes — every
69
+ // role-shaped response (list, create, set-permissions) carries both.
70
+ type roleListItem struct {
71
+ model.Role
72
+ Permissions []string
73
+ }
74
+
75
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]roleListItem, error) {
76
+ roles, err := s.repo.FindAll(ctx, limit, offset)
77
+ if err != nil {
78
+ return nil, apperror.NewInternal()
79
+ }
80
+ out := make([]roleListItem, len(roles))
81
+ for i, r := range roles {
82
+ perms, err := s.repo.PermissionCodes(ctx, r.Code)
83
+ if err != nil {
84
+ return nil, apperror.NewInternal()
85
+ }
86
+ out[i] = roleListItem{Role: r, Permissions: perms}
87
+ }
88
+ return out, nil
89
+ }
90
+
91
+ // roleCodePattern mirrors the seeded system codes' shape (staff, admin) —
92
+ // lowercase, no spaces, safe to use as a URL path segment and a JWT claim.
93
+ var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
94
+
95
+ func (s *Service) Create(ctx context.Context, in CreateInput) (*roleListItem, error) {
96
+ if !roleCodePattern.MatchString(in.Code) {
97
+ return nil, errInvalidCode()
98
+ }
99
+ m := &model.Role{Code: in.Code, Name: in.Name}
100
+ if err := s.repo.Create(ctx, m); err != nil {
101
+ if dberr.IsDuplicate(err) {
102
+ return nil, errConflict()
103
+ }
104
+ return nil, apperror.NewInternal()
105
+ }
106
+ return &roleListItem{Role: *m, Permissions: []string{}}, nil
107
+ }
108
+
109
+ // SetPermissions replaces code's entire permission grant set with
110
+ // permissionCodes — a full-replace, not add/remove, matches a checkbox-style
111
+ // admin UI ("here's the new full set") better than diffing.
112
+ func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*roleListItem, error) {
113
+ m, err := s.repo.FindByCode(ctx, code)
114
+ if err != nil {
115
+ return nil, wrapFindErr(err)
116
+ }
117
+ if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
118
+ return nil, err
119
+ }
120
+ if !slices.Contains(permissionCodes, PermRoleManage) {
121
+ isLast, err := s.isLastRoleManager(ctx, code)
122
+ if err != nil {
123
+ return nil, apperror.NewInternal()
124
+ }
125
+ if isLast {
126
+ return nil, errLastRoleManager()
127
+ }
128
+ }
129
+ if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
130
+ // the migration's FK is a backstop for this same check — reachable
131
+ // only under a race with a concurrent permission deletion.
132
+ if dberr.IsForeignKey(err) {
133
+ return nil, errUnknownPermission()
134
+ }
135
+ return nil, apperror.NewInternal()
136
+ }
137
+ return &roleListItem{Role: *m, Permissions: permissionCodes}, nil
138
+ }
139
+
140
+ // validatePermissionCodes checks every code against the actual permission
141
+ // catalog — the DB's FK alone isn't enough (AutoMigrate, used in dev/tests,
142
+ // never creates it, only the raw migration SQL does).
143
+ func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
144
+ all, err := s.repo.AllPermissionCodes(ctx)
145
+ if err != nil {
146
+ return apperror.NewInternal()
147
+ }
148
+ valid := make(map[string]struct{}, len(all))
149
+ for _, c := range all {
150
+ valid[c] = struct{}{}
151
+ }
152
+ for _, c := range codes {
153
+ if _, ok := valid[c]; !ok {
154
+ return errUnknownPermission()
155
+ }
156
+ }
157
+ return nil
158
+ }
159
+
160
+ func (s *Service) Delete(ctx context.Context, code string) error {
161
+ m, err := s.repo.FindByCode(ctx, code)
162
+ if err != nil {
163
+ return wrapFindErr(err)
164
+ }
165
+ if m.IsSystem {
166
+ return errSystemRole()
167
+ }
168
+ isLast, err := s.isLastRoleManager(ctx, code)
169
+ if err != nil {
170
+ return apperror.NewInternal()
171
+ }
172
+ if isLast {
173
+ return errLastRoleManager()
174
+ }
175
+ if err := s.repo.Delete(ctx, code); err != nil {
176
+ if dberr.IsForeignKey(err) {
177
+ return errHasReferences()
178
+ }
179
+ return apperror.NewInternal()
180
+ }
181
+ return nil
182
+ }
183
+
184
+ // isLastRoleManager reports whether code is the only role left granting
185
+ // PermRoleManage — blocks a self-lockout no one could fix back through this
186
+ // same API. Best-effort: reachable only under a race with a concurrent edit
187
+ // to the same permission, not worth a serializable transaction for an
188
+ // admin-only, low-traffic action.
189
+ func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
190
+ others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
191
+ if err != nil {
192
+ return false, err
193
+ }
194
+ return others == 0, nil
195
+ }
196
+
197
+ func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
198
+ items, err := s.repo.FindAllPermissions(ctx, limit, offset)
199
+ if err != nil {
200
+ return nil, apperror.NewInternal()
201
+ }
202
+ return items, nil
203
+ }
204
+
205
+ func wrapFindErr(err error) error {
206
+ if errors.Is(err, gorm.ErrRecordNotFound) {
207
+ return errNotFound()
208
+ }
209
+ return apperror.NewInternal()
210
+ }
@@ -0,0 +1,119 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "testing"
8
+
9
+ "{{goModule}}/internal/app/role/model"
10
+ "{{goModule}}/internal/shared/apperror"
11
+ )
12
+
13
+ // fakeRepo = mock of the repository interface, so the service can be tested
14
+ // without a DB — countWithPermission stands in for "how many roles OTHER
15
+ // than the one being edited currently grant PermRoleManage", exactly what
16
+ // CountRolesWithPermission's excludeCode param means.
17
+ type fakeRepo struct {
18
+ role *model.Role
19
+ findErr error
20
+ allPermissionCodes []string
21
+ countWithPermission int64
22
+ setPermissionsErr error
23
+ deleteErr error
24
+ }
25
+
26
+ func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
27
+ func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.Role, error) { return nil, nil }
28
+ func (f *fakeRepo) FindByCode(context.Context, string) (*model.Role, error) {
29
+ if f.findErr != nil {
30
+ return nil, f.findErr
31
+ }
32
+ return f.role, nil
33
+ }
34
+ func (f *fakeRepo) Create(context.Context, *model.Role) error { return nil }
35
+ func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
36
+ return f.setPermissionsErr
37
+ }
38
+ func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
39
+ func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]model.Permission, error) {
40
+ return nil, nil
41
+ }
42
+ func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) { return f.allPermissionCodes, nil }
43
+ func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
44
+ return f.countWithPermission, nil
45
+ }
46
+
47
+ func status(t *testing.T, err error) int {
48
+ t.Helper()
49
+ var appErr *apperror.AppError
50
+ if !errors.As(err, &appErr) {
51
+ t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
52
+ }
53
+ return appErr.HTTPStatus
54
+ }
55
+
56
+ func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
57
+ repo := &fakeRepo{
58
+ role: &model.Role{Code: "ops", Name: "Ops"},
59
+ allPermissionCodes: []string{PermRoleManage, "user:read"},
60
+ countWithPermission: 0, // no OTHER role grants PermRoleManage
61
+ }
62
+ svc := NewService(repo)
63
+ // dropping PermRoleManage from the only role that has it would lock every admin out
64
+ _, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"})
65
+ if got := status(t, err); got != http.StatusConflict {
66
+ t.Fatalf("want 409 last-role-manager lockout, got %d", got)
67
+ }
68
+ }
69
+
70
+ func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
71
+ repo := &fakeRepo{
72
+ role: &model.Role{Code: "ops", Name: "Ops"},
73
+ allPermissionCodes: []string{PermRoleManage, "user:read"},
74
+ countWithPermission: 1, // another role still grants PermRoleManage
75
+ }
76
+ svc := NewService(repo)
77
+ if _, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"}); err != nil {
78
+ t.Fatalf("expected success when another role still manages roles, got %v", err)
79
+ }
80
+ }
81
+
82
+ func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
83
+ repo := &fakeRepo{
84
+ role: &model.Role{Code: "ops", Name: "Ops"},
85
+ allPermissionCodes: []string{PermRoleManage},
86
+ countWithPermission: 0,
87
+ }
88
+ svc := NewService(repo)
89
+ // the guard only fires when PermRoleManage is being dropped — keeping it never trips it
90
+ if _, err := svc.SetPermissions(context.Background(), "ops", []string{PermRoleManage}); err != nil {
91
+ t.Fatalf("keeping %s in the new set should never trigger the lockout guard, got %v", PermRoleManage, err)
92
+ }
93
+ }
94
+
95
+ func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
96
+ repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 0}
97
+ svc := NewService(repo)
98
+ err := svc.Delete(context.Background(), "ops")
99
+ if got := status(t, err); got != http.StatusConflict {
100
+ t.Fatalf("want 409 last-role-manager lockout on delete, got %d", got)
101
+ }
102
+ }
103
+
104
+ func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
105
+ repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 1}
106
+ svc := NewService(repo)
107
+ if err := svc.Delete(context.Background(), "ops"); err != nil {
108
+ t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
109
+ }
110
+ }
111
+
112
+ func TestService_Delete_SystemRoleRejected(t *testing.T) {
113
+ repo := &fakeRepo{role: &model.Role{Code: "admin", IsSystem: true}}
114
+ svc := NewService(repo)
115
+ err := svc.Delete(context.Background(), "admin")
116
+ if got := status(t, err); got != http.StatusConflict {
117
+ t.Fatalf("want 409 for a system role, got %d", got)
118
+ }
119
+ }
@@ -0,0 +1,88 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "sync"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/shared/apperror"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ // Authz answers "does this role have this permission?". shared/ can't import
15
+ // a domain package (see RequireAuth's comment), so the role->permission
16
+ // lookup is injected as a func value from main.go instead of calling the
17
+ // role domain directly.
18
+ type Authz struct {
19
+ resolve func(ctx context.Context, roleCode string) (map[string]struct{}, error)
20
+ ttl time.Duration
21
+
22
+ mu sync.RWMutex
23
+ cache map[string]cachedPerms
24
+ }
25
+
26
+ type cachedPerms struct {
27
+ perms map[string]struct{}
28
+ expires time.Time
29
+ }
30
+
31
+ // NewAuthz builds an Authz that calls resolve on a cache miss. The cache key
32
+ // is the role code, not the user or the request — a handful of roles means
33
+ // a handful of DB round trips per TTL window, independent of traffic.
34
+ func NewAuthz(resolve func(context.Context, string) (map[string]struct{}, error), ttl time.Duration) *Authz {
35
+ return &Authz{resolve: resolve, ttl: ttl, cache: map[string]cachedPerms{}}
36
+ }
37
+
38
+ // Require 403s unless the caller's role (set by RequireAuth) grants perm.
39
+ // Must run after RequireAuth.
40
+ func (a *Authz) Require(perm string) gin.HandlerFunc {
41
+ return func(c *gin.Context) {
42
+ perms, err := a.permsOf(c.Request.Context(), c.GetString(RoleKey))
43
+ if err != nil {
44
+ c.Error(err)
45
+ c.Abort()
46
+ return
47
+ }
48
+ if _, ok := perms[perm]; !ok {
49
+ c.Error(apperror.New(http.StatusForbidden, "FORBIDDEN", "insufficient permissions"))
50
+ c.Abort()
51
+ return
52
+ }
53
+ c.Next()
54
+ }
55
+ }
56
+
57
+ // ponytail: process-local cache, a handful of roles x AUTHZ_CACHE_TTL_MIN —
58
+ // a role's permission grants take effect within the TTL, no pub/sub needed.
59
+ // Move to Redis only if instant cross-pod invalidation turns out to matter.
60
+ //
61
+ // Double-checked locking: the common case (cache hit) only needs RLock, so
62
+ // concurrent requests for different roles never block each other. Only a
63
+ // miss takes the exclusive Lock, and only for the one role being resolved —
64
+ // ceiling is that a miss still blocks other roles' misses (not their hits)
65
+ // for the duration of one resolve() call; move to a per-role singleflight
66
+ // if that contention ever actually shows up under load.
67
+ func (a *Authz) permsOf(ctx context.Context, role string) (map[string]struct{}, error) {
68
+ a.mu.RLock()
69
+ e, ok := a.cache[role]
70
+ a.mu.RUnlock()
71
+ if ok && time.Now().Before(e.expires) {
72
+ return e.perms, nil
73
+ }
74
+
75
+ a.mu.Lock()
76
+ defer a.mu.Unlock()
77
+ // re-check: another goroutine may have refreshed this role while we
78
+ // were waiting for the write lock.
79
+ if e, ok := a.cache[role]; ok && time.Now().Before(e.expires) {
80
+ return e.perms, nil
81
+ }
82
+ perms, err := a.resolve(ctx, role)
83
+ if err != nil {
84
+ return nil, err
85
+ }
86
+ a.cache[role] = cachedPerms{perms: perms, expires: time.Now().Add(a.ttl)}
87
+ return perms, nil
88
+ }