@nakedev/go-scaffold 0.4.0 → 0.5.0
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 +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Package application contains role management use cases. It owns the rules
|
|
2
|
+
// for permission grants and never imports GORM, Gin, or HTTP error types.
|
|
3
|
+
package application
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"errors"
|
|
8
|
+
"regexp"
|
|
9
|
+
"slices"
|
|
10
|
+
|
|
11
|
+
"{{goModule}}/internal/app/role/domain"
|
|
12
|
+
"{{goModule}}/internal/app/role/ports"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type ServicePort interface {
|
|
16
|
+
PermissionsOf(context.Context, string) (map[string]struct{}, error)
|
|
17
|
+
CodeExists(context.Context, string) (bool, error)
|
|
18
|
+
List(context.Context, int, int) ([]RoleListItem, error)
|
|
19
|
+
Create(context.Context, CreateInput) (*RoleListItem, error)
|
|
20
|
+
SetPermissions(context.Context, string, []string) (*RoleListItem, error)
|
|
21
|
+
Delete(context.Context, string) error
|
|
22
|
+
ListPermissions(context.Context, int, int) ([]domain.Permission, error)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Service struct {
|
|
26
|
+
repo ports.Repository
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func NewService(repo ports.Repository) *Service { return &Service{repo: repo} }
|
|
30
|
+
|
|
31
|
+
func (s *Service) PermissionsOf(ctx context.Context, roleCode string) (map[string]struct{}, error) {
|
|
32
|
+
codes, err := s.repo.PermissionCodes(ctx, roleCode)
|
|
33
|
+
if err != nil {
|
|
34
|
+
return nil, err
|
|
35
|
+
}
|
|
36
|
+
set := make(map[string]struct{}, len(codes))
|
|
37
|
+
for _, code := range codes {
|
|
38
|
+
set[code] = struct{}{}
|
|
39
|
+
}
|
|
40
|
+
return set, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (s *Service) CodeExists(ctx context.Context, code string) (bool, error) {
|
|
44
|
+
if _, err := s.repo.FindByCode(ctx, code); err != nil {
|
|
45
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
46
|
+
return false, nil
|
|
47
|
+
}
|
|
48
|
+
return false, err
|
|
49
|
+
}
|
|
50
|
+
return true, nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (s *Service) List(ctx context.Context, limit, offset int) ([]RoleListItem, error) {
|
|
54
|
+
roles, err := s.repo.FindAll(ctx, limit, offset)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
out := make([]RoleListItem, len(roles))
|
|
59
|
+
for i, role := range roles {
|
|
60
|
+
perms, err := s.repo.PermissionCodes(ctx, role.Code)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, err
|
|
63
|
+
}
|
|
64
|
+
out[i] = RoleListItem{Role: role, Permissions: perms}
|
|
65
|
+
}
|
|
66
|
+
return out, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
70
|
+
|
|
71
|
+
func (s *Service) Create(ctx context.Context, in CreateInput) (*RoleListItem, error) {
|
|
72
|
+
if !roleCodePattern.MatchString(in.Code) {
|
|
73
|
+
return nil, errInvalidCode()
|
|
74
|
+
}
|
|
75
|
+
role := &domain.Role{Code: in.Code, Name: in.Name}
|
|
76
|
+
if err := s.repo.Create(ctx, role); err != nil {
|
|
77
|
+
return nil, err
|
|
78
|
+
}
|
|
79
|
+
return &RoleListItem{Role: *role, Permissions: []string{}}, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (s *Service) SetPermissions(ctx context.Context, code string, permissionCodes []string) (*RoleListItem, error) {
|
|
83
|
+
role, err := s.repo.FindByCode(ctx, code)
|
|
84
|
+
if err != nil {
|
|
85
|
+
return nil, wrapFindErr(err)
|
|
86
|
+
}
|
|
87
|
+
if err := s.validatePermissionCodes(ctx, permissionCodes); err != nil {
|
|
88
|
+
return nil, err
|
|
89
|
+
}
|
|
90
|
+
if !slices.Contains(permissionCodes, PermRoleManage) {
|
|
91
|
+
isLast, err := s.isLastRoleManager(ctx, code)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return nil, err
|
|
94
|
+
}
|
|
95
|
+
if isLast {
|
|
96
|
+
return nil, errLastRoleManager()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if err := s.repo.SetPermissions(ctx, code, permissionCodes); err != nil {
|
|
100
|
+
return nil, err
|
|
101
|
+
}
|
|
102
|
+
return &RoleListItem{Role: *role, Permissions: permissionCodes}, nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (s *Service) validatePermissionCodes(ctx context.Context, codes []string) error {
|
|
106
|
+
all, err := s.repo.AllPermissionCodes(ctx)
|
|
107
|
+
if err != nil {
|
|
108
|
+
return err
|
|
109
|
+
}
|
|
110
|
+
valid := make(map[string]struct{}, len(all))
|
|
111
|
+
for _, code := range all {
|
|
112
|
+
valid[code] = struct{}{}
|
|
113
|
+
}
|
|
114
|
+
for _, code := range codes {
|
|
115
|
+
if _, ok := valid[code]; !ok {
|
|
116
|
+
return errUnknownPermission()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func (s *Service) Delete(ctx context.Context, code string) error {
|
|
123
|
+
role, err := s.repo.FindByCode(ctx, code)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return wrapFindErr(err)
|
|
126
|
+
}
|
|
127
|
+
if role.IsSystem {
|
|
128
|
+
return errSystemRole()
|
|
129
|
+
}
|
|
130
|
+
isLast, err := s.isLastRoleManager(ctx, code)
|
|
131
|
+
if err != nil {
|
|
132
|
+
return err
|
|
133
|
+
}
|
|
134
|
+
if isLast {
|
|
135
|
+
return errLastRoleManager()
|
|
136
|
+
}
|
|
137
|
+
return s.repo.Delete(ctx, code)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func (s *Service) isLastRoleManager(ctx context.Context, code string) (bool, error) {
|
|
141
|
+
others, err := s.repo.CountRolesWithPermission(ctx, PermRoleManage, code)
|
|
142
|
+
if err != nil {
|
|
143
|
+
return false, err
|
|
144
|
+
}
|
|
145
|
+
return others == 0, nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func (s *Service) ListPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
|
|
149
|
+
return s.repo.FindAllPermissions(ctx, limit, offset)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func wrapFindErr(err error) error {
|
|
153
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
154
|
+
return domain.ErrNotFound
|
|
155
|
+
}
|
|
156
|
+
return err
|
|
157
|
+
}
|
package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -6,8 +6,7 @@ import (
|
|
|
6
6
|
"net/http"
|
|
7
7
|
"testing"
|
|
8
8
|
|
|
9
|
-
"{{goModule}}/internal/app/role/
|
|
10
|
-
"{{goModule}}/internal/shared/apperror"
|
|
9
|
+
"{{goModule}}/internal/app/role/domain"
|
|
11
10
|
)
|
|
12
11
|
|
|
13
12
|
// fakeRepo = mock of the repository interface, so the service can be tested
|
|
@@ -15,7 +14,7 @@ import (
|
|
|
15
14
|
// than the one being edited currently grant PermRoleManage", exactly what
|
|
16
15
|
// CountRolesWithPermission's excludeCode param means.
|
|
17
16
|
type fakeRepo struct {
|
|
18
|
-
role *
|
|
17
|
+
role *domain.Role
|
|
19
18
|
findErr error
|
|
20
19
|
allPermissionCodes []string
|
|
21
20
|
countWithPermission int64
|
|
@@ -24,38 +23,46 @@ type fakeRepo struct {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
|
|
27
|
-
func (f *fakeRepo) FindAll(context.Context, int, int) ([]
|
|
28
|
-
func (f *fakeRepo) FindByCode(context.Context, string) (*
|
|
26
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.Role, error) { return nil, nil }
|
|
27
|
+
func (f *fakeRepo) FindByCode(context.Context, string) (*domain.Role, error) {
|
|
29
28
|
if f.findErr != nil {
|
|
30
29
|
return nil, f.findErr
|
|
31
30
|
}
|
|
32
31
|
return f.role, nil
|
|
33
32
|
}
|
|
34
|
-
func (f *fakeRepo) Create(context.Context, *
|
|
33
|
+
func (f *fakeRepo) Create(context.Context, *domain.Role) error { return nil }
|
|
35
34
|
func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
|
|
36
35
|
return f.setPermissionsErr
|
|
37
36
|
}
|
|
38
37
|
func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
|
|
39
|
-
func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]
|
|
38
|
+
func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]domain.Permission, error) {
|
|
40
39
|
return nil, nil
|
|
41
40
|
}
|
|
42
|
-
func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) {
|
|
41
|
+
func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) {
|
|
42
|
+
return f.allPermissionCodes, nil
|
|
43
|
+
}
|
|
43
44
|
func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
|
|
44
45
|
return f.countWithPermission, nil
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
func status(t *testing.T, err error) int {
|
|
48
49
|
t.Helper()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrSystemRole) || errors.Is(err, domain.ErrLastRoleManager) {
|
|
51
|
+
return http.StatusConflict
|
|
52
|
+
}
|
|
53
|
+
if errors.Is(err, domain.ErrInvalidCode) || errors.Is(err, domain.ErrUnknownPermission) {
|
|
54
|
+
return http.StatusUnprocessableEntity
|
|
55
|
+
}
|
|
56
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
57
|
+
return http.StatusNotFound
|
|
52
58
|
}
|
|
53
|
-
|
|
59
|
+
t.Fatalf("unexpected role error %T: %v", err, err)
|
|
60
|
+
return 0
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
|
|
57
64
|
repo := &fakeRepo{
|
|
58
|
-
role: &
|
|
65
|
+
role: &domain.Role{Code: "ops", Name: "Ops"},
|
|
59
66
|
allPermissionCodes: []string{PermRoleManage, "user:read"},
|
|
60
67
|
countWithPermission: 0, // no OTHER role grants PermRoleManage
|
|
61
68
|
}
|
|
@@ -69,7 +76,7 @@ func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
|
|
|
69
76
|
|
|
70
77
|
func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
|
|
71
78
|
repo := &fakeRepo{
|
|
72
|
-
role: &
|
|
79
|
+
role: &domain.Role{Code: "ops", Name: "Ops"},
|
|
73
80
|
allPermissionCodes: []string{PermRoleManage, "user:read"},
|
|
74
81
|
countWithPermission: 1, // another role still grants PermRoleManage
|
|
75
82
|
}
|
|
@@ -81,7 +88,7 @@ func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T)
|
|
|
81
88
|
|
|
82
89
|
func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
|
|
83
90
|
repo := &fakeRepo{
|
|
84
|
-
role: &
|
|
91
|
+
role: &domain.Role{Code: "ops", Name: "Ops"},
|
|
85
92
|
allPermissionCodes: []string{PermRoleManage},
|
|
86
93
|
countWithPermission: 0,
|
|
87
94
|
}
|
|
@@ -93,7 +100,7 @@ func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
|
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
|
|
96
|
-
repo := &fakeRepo{role: &
|
|
103
|
+
repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 0}
|
|
97
104
|
svc := NewService(repo)
|
|
98
105
|
err := svc.Delete(context.Background(), "ops")
|
|
99
106
|
if got := status(t, err); got != http.StatusConflict {
|
|
@@ -102,7 +109,7 @@ func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
|
|
105
|
-
repo := &fakeRepo{role: &
|
|
112
|
+
repo := &fakeRepo{role: &domain.Role{Code: "ops"}, countWithPermission: 1}
|
|
106
113
|
svc := NewService(repo)
|
|
107
114
|
if err := svc.Delete(context.Background(), "ops"); err != nil {
|
|
108
115
|
t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
|
|
@@ -110,7 +117,7 @@ func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
|
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
func TestService_Delete_SystemRoleRejected(t *testing.T) {
|
|
113
|
-
repo := &fakeRepo{role: &
|
|
120
|
+
repo := &fakeRepo{role: &domain.Role{Code: "admin", IsSystem: true}}
|
|
114
121
|
svc := NewService(repo)
|
|
115
122
|
err := svc.Delete(context.Background(), "admin")
|
|
116
123
|
if got := status(t, err); got != http.StatusConflict {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package role
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
httpadapter "{{goModule}}/internal/app/role/adapters/inbound/http"
|
|
7
|
+
"{{goModule}}/internal/app/role/adapters/outbound/postgres"
|
|
8
|
+
"{{goModule}}/internal/app/role/application"
|
|
9
|
+
"{{goModule}}/internal/app/role/domain"
|
|
10
|
+
"{{goModule}}/internal/app/role/ports"
|
|
11
|
+
"{{goModule}}/internal/shared/middleware"
|
|
12
|
+
|
|
13
|
+
"gorm.io/gorm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// Composition is the only public graph exposed to cmd/api. The feature root
|
|
17
|
+
// owns the role repository, application service, permission cache, and HTTP
|
|
18
|
+
// adapter; process wiring does not construct any of those internals.
|
|
19
|
+
type Composition struct {
|
|
20
|
+
Service *application.Service
|
|
21
|
+
Authz *middleware.Authz
|
|
22
|
+
Handler *httpadapter.Handler
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Service = application.Service
|
|
26
|
+
type ServicePort = application.ServicePort
|
|
27
|
+
type Repository = postgres.Repository
|
|
28
|
+
type CreateInput = application.CreateInput
|
|
29
|
+
type SetPermissionsInput = application.SetPermissionsInput
|
|
30
|
+
type Role = domain.Role
|
|
31
|
+
type Permission = domain.Permission
|
|
32
|
+
type AuthorizationPort = ports.AuthorizationPort
|
|
33
|
+
|
|
34
|
+
const PermRoleManage = application.PermRoleManage
|
|
35
|
+
|
|
36
|
+
func NewServiceFromDB(db *gorm.DB) *Service {
|
|
37
|
+
return application.NewService(postgres.NewRepository(db))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func NewCompositionFromDB(db *gorm.DB, jwtSecret string, cacheTTL time.Duration) *Composition {
|
|
41
|
+
service := NewServiceFromDB(db)
|
|
42
|
+
authz := middleware.NewAuthz(service.PermissionsOf, cacheTTL)
|
|
43
|
+
return &Composition{
|
|
44
|
+
Service: service,
|
|
45
|
+
Authz: authz,
|
|
46
|
+
Handler: httpadapter.NewHandler(service, jwtSecret, authz),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Package domain contains role and permission state without GORM or HTTP
|
|
2
|
+
// concerns.
|
|
3
|
+
package domain
|
|
4
|
+
|
|
5
|
+
import "time"
|
|
6
|
+
|
|
7
|
+
type Role struct {
|
|
8
|
+
Code string
|
|
9
|
+
Name string
|
|
10
|
+
IsSystem bool
|
|
11
|
+
CreatedAt time.Time
|
|
12
|
+
UpdatedAt time.Time
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Permission struct {
|
|
16
|
+
Code string
|
|
17
|
+
Description string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RolePermission struct {
|
|
21
|
+
RoleCode string
|
|
22
|
+
PermissionCode string
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package domain
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
ErrNotFound = errors.New("role not found")
|
|
7
|
+
ErrConflict = errors.New("role conflict")
|
|
8
|
+
ErrHasReferences = errors.New("role has references")
|
|
9
|
+
ErrInvalidCode = errors.New("role code is invalid")
|
|
10
|
+
ErrSystemRole = errors.New("system role cannot be changed")
|
|
11
|
+
ErrUnknownPermission = errors.New("permission does not exist")
|
|
12
|
+
ErrLastRoleManager = errors.New("last role manager cannot be removed")
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type RuleError struct {
|
|
16
|
+
Code string
|
|
17
|
+
Message string
|
|
18
|
+
Cause error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (e *RuleError) Error() string { return e.Message }
|
|
22
|
+
func (e *RuleError) Unwrap() error { return e.Cause }
|
|
23
|
+
|
|
24
|
+
func Rule(code, message string, cause error) error {
|
|
25
|
+
return &RuleError{Code: code, Message: message, Cause: cause}
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Package ports contains role's application-owned repository contracts.
|
|
2
|
+
package ports
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/app/role/domain"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type Repository interface {
|
|
11
|
+
PermissionCodes(context.Context, string) ([]string, error)
|
|
12
|
+
FindAll(context.Context, int, int) ([]domain.Role, error)
|
|
13
|
+
FindByCode(context.Context, string) (*domain.Role, error)
|
|
14
|
+
Create(context.Context, *domain.Role) error
|
|
15
|
+
SetPermissions(context.Context, string, []string) error
|
|
16
|
+
Delete(context.Context, string) error
|
|
17
|
+
FindAllPermissions(context.Context, int, int) ([]domain.Permission, error)
|
|
18
|
+
AllPermissionCodes(context.Context) ([]string, error)
|
|
19
|
+
CountRolesWithPermission(context.Context, string, string) (int64, error)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type AuthorizationPort interface {
|
|
23
|
+
PermissionsOf(context.Context, string) (map[string]struct{}, error)
|
|
24
|
+
CodeExists(context.Context, string) (bool, error)
|
|
25
|
+
}
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
--
|
|
2
|
-
--
|
|
3
|
-
|
|
4
|
-
DO $$
|
|
5
|
-
BEGIN
|
|
6
|
-
IF EXISTS (SELECT 1 FROM user_svc.users WHERE role NOT IN ('staff', 'admin')) THEN
|
|
7
|
-
RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
|
|
8
|
-
END IF;
|
|
9
|
-
END $$;
|
|
10
|
-
|
|
11
|
-
ALTER TABLE user_svc.users DROP COLUMN role;
|
|
1
|
+
-- Auth owns users.role, so RBAC removes only the constraint it added. The
|
|
2
|
+
-- column and existing assignments remain valid for auth-only operation.
|
|
3
|
+
ALTER TABLE user_svc.users DROP CONSTRAINT IF EXISTS users_role_fkey;
|
|
12
4
|
|
|
13
5
|
DROP TABLE role_svc.role_permissions;
|
|
14
6
|
DROP TABLE role_svc.permissions;
|
|
@@ -33,9 +33,20 @@ INSERT INTO role_svc.permissions (code, description) VALUES
|
|
|
33
33
|
INSERT INTO role_svc.role_permissions (role_code, permission_code)
|
|
34
34
|
SELECT 'admin', code FROM role_svc.permissions;
|
|
35
35
|
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
-- Auth owns the stable users.role column. RBAC owns the role catalog and adds
|
|
37
|
+
-- only the cross-schema constraint after that catalog exists. This keeps each
|
|
38
|
+
-- migration aligned with its module instead of making RBAC recreate auth's
|
|
39
|
+
-- persistence shape.
|
|
40
|
+
DO $$
|
|
41
|
+
BEGIN
|
|
42
|
+
IF NOT EXISTS (
|
|
43
|
+
SELECT 1
|
|
44
|
+
FROM pg_constraint
|
|
45
|
+
WHERE conname = 'users_role_fkey'
|
|
46
|
+
AND conrelid = 'user_svc.users'::regclass
|
|
47
|
+
) THEN
|
|
48
|
+
ALTER TABLE user_svc.users
|
|
49
|
+
ADD CONSTRAINT users_role_fkey
|
|
50
|
+
FOREIGN KEY (role) REFERENCES role_svc.roles(code);
|
|
51
|
+
END IF;
|
|
52
|
+
END $$;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package queue
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
"gorm.io/driver/postgres"
|
|
12
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type riverRoundTripJob struct {
|
|
16
|
+
Marker string `json:"marker"`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (riverRoundTripJob) Kind() string { return "queue-test-round-trip" }
|
|
20
|
+
|
|
21
|
+
// TestRiverEnqueueAndWorkerProcess proves the Postgres queue path beyond
|
|
22
|
+
// client construction: a job is written, picked up by the worker adapter,
|
|
23
|
+
// decoded, and handed to the registered backend-neutral handler.
|
|
24
|
+
func TestRiverEnqueueAndWorkerProcess(t *testing.T) {
|
|
25
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
26
|
+
if dsn == "" {
|
|
27
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
28
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
29
|
+
}
|
|
30
|
+
t.Skip("River integration test skipped: set TEST_DB_DSN to a database with River migrations")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatalf("open test database: %v", err)
|
|
36
|
+
}
|
|
37
|
+
sqlDB, err := db.DB()
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("get test database handle: %v", err)
|
|
40
|
+
}
|
|
41
|
+
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
42
|
+
|
|
43
|
+
worker, err := NewRiverWorker(db, 1)
|
|
44
|
+
if err != nil {
|
|
45
|
+
t.Fatalf("create River worker: %v", err)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
received := make(chan string, 1)
|
|
49
|
+
worker.Handle((riverRoundTripJob{}).Kind(), func(_ context.Context, raw []byte) error {
|
|
50
|
+
var job riverRoundTripJob
|
|
51
|
+
if err := json.Unmarshal(raw, &job); err != nil {
|
|
52
|
+
return err
|
|
53
|
+
}
|
|
54
|
+
received <- job.Marker
|
|
55
|
+
return nil
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
workerCtx, cancel := context.WithCancel(context.Background())
|
|
59
|
+
t.Cleanup(func() {
|
|
60
|
+
cancel()
|
|
61
|
+
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
62
|
+
defer stopCancel()
|
|
63
|
+
if err := worker.Stop(stopCtx); err != nil {
|
|
64
|
+
t.Errorf("stop River worker: %v", err)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
if err := worker.Start(workerCtx); err != nil {
|
|
68
|
+
t.Fatalf("start River worker: %v", err)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expected := uuid.NewString()
|
|
72
|
+
if err := worker.Enqueue(context.Background(), riverRoundTripJob{Marker: expected}, nil); err != nil {
|
|
73
|
+
t.Fatalf("enqueue River job: %v", err)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
select {
|
|
77
|
+
case actual := <-received:
|
|
78
|
+
if actual != expected {
|
|
79
|
+
t.Fatalf("worker received marker %q, want %q", actual, expected)
|
|
80
|
+
}
|
|
81
|
+
case <-time.After(10 * time.Second):
|
|
82
|
+
t.Fatal("River worker did not process the enqueued job within 10s")
|
|
83
|
+
}
|
|
84
|
+
}
|