@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.
- package/README.md +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +13 -0
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- 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
|
+
}
|