@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.
Files changed (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -1,316 +0,0 @@
1
- package user
2
-
3
- import (
4
- "fmt"
5
- "context"
6
- "errors"
7
- "net/http"
8
- "testing"
9
- "time"
10
-
11
- "{{goModule}}/internal/app/user/model"
12
- "{{goModule}}/internal/shared/apperror"
13
- "{{goModule}}/internal/shared/config"
14
-
15
- "github.com/google/uuid"
16
- "gorm.io/gorm"
17
- )
18
-
19
- // fakeTokenStore = in-memory mock of tokenStore, mirroring the real store's
20
- // three-map shape (active / used-tombstone / per-user session set) closely
21
- // enough to exercise rotation + reuse-detection without a real Redis.
22
- type fakeTokenStore struct {
23
- active map[string]uuid.UUID
24
- used map[string]uuid.UUID
25
- sessions map[uuid.UUID]map[string]struct{}
26
- }
27
-
28
- func newFakeTokenStore() *fakeTokenStore {
29
- return &fakeTokenStore{
30
- active: map[string]uuid.UUID{},
31
- used: map[string]uuid.UUID{},
32
- sessions: map[uuid.UUID]map[string]struct{}{},
33
- }
34
- }
35
-
36
- func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
37
- f.active[hash] = userID
38
- if f.sessions[userID] == nil {
39
- f.sessions[userID] = map[string]struct{}{}
40
- }
41
- f.sessions[userID][hash] = struct{}{}
42
- return nil
43
- }
44
- func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
45
- id, ok := f.active[hash]
46
- return id, ok, nil
47
- }
48
- func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
49
- delete(f.active, hash)
50
- delete(f.sessions[userID], hash)
51
- return nil
52
- }
53
- func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
54
- for h := range f.sessions[userID] {
55
- delete(f.active, h)
56
- }
57
- delete(f.sessions, userID)
58
- return nil
59
- }
60
- func (f *fakeTokenStore) MarkRefreshTokenUsed(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
61
- f.used[hash] = userID
62
- return nil
63
- }
64
- func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
65
- id, ok := f.used[hash]
66
- return id, ok, nil
67
- }
68
- func (f *fakeTokenStore) SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error {
69
- return nil
70
- }
71
- func (f *fakeTokenStore) ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error) {
72
- return uuid.Nil, false, nil
73
- }
74
- func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
75
- return nil
76
- }
77
- func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
78
- return uuid.Nil, false, nil
79
- }
80
-
81
- // fakeRepo = mock of the repository interface — only FindByID is exercised
82
- // by the tests below (Refresh looks the user up after validating the
83
- // token), the rest just satisfy the interface.
84
- type fakeRepo struct {
85
- user *model.User
86
- failures map[string]int
87
- lockedUntil map[string]time.Time
88
- }
89
-
90
- // throttle: the fake keeps the counter in memory so the lockout path can be
91
- // exercised without a database. Same shape as the real one — a key, a count,
92
- // and a time — because what the tests care about is when Service decides to
93
- // stop asking bcrypt anything.
94
- func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
95
- return f.lockedUntil[key], nil
96
- }
97
-
98
- func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
99
- if f.lockedUntil == nil {
100
- f.lockedUntil = map[string]time.Time{}
101
- }
102
- f.failures[key]++
103
- if f.failures[key] > freeAttempts {
104
- lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
105
- if lock > maxLock {
106
- lock = maxLock
107
- }
108
- f.lockedUntil[key] = time.Now().Add(lock)
109
- }
110
- return nil
111
- }
112
-
113
- func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
114
- delete(f.failures, key)
115
- delete(f.lockedUntil, key)
116
- return nil
117
- }
118
-
119
- func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
120
- return nil, gorm.ErrRecordNotFound
121
- }
122
- func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error) {
123
- if f.user != nil && f.user.ID == id {
124
- return f.user, nil
125
- }
126
- return nil, gorm.ErrRecordNotFound
127
- }
128
- func (f *fakeRepo) UpdateUser(context.Context, *model.User) error { return nil }
129
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
130
- func (f *fakeRepo) FindIdentity(context.Context, uuid.UUID, model.Provider) (*model.Identity, error) {
131
- return nil, gorm.ErrRecordNotFound
132
- }
133
- func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
134
- return nil, gorm.ErrRecordNotFound
135
- }
136
- func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
137
- func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
138
- func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error { return nil }
139
-
140
- type fakeMailer struct{}
141
-
142
- func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
143
-
144
- // go-scaffold:user-service-test-types
145
-
146
- // newTestService is the one NewService call site every test below shares —
147
- // keeping it singular means `add rbac`'s marker patch only has one place to
148
- // add the roleChecker arg it introduces (patching every call site
149
- // individually would need a marker-per-callsite, easy to miss one).
150
- func newTestService(repo repository, tokens tokenStore) *Service {
151
- return NewService(
152
- repo,
153
- tokens,
154
- fakeMailer{},
155
- config.Config{JWTSecret: "test-secret", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour},
156
- // go-scaffold:user-service-test-args
157
- )
158
- }
159
-
160
- func status(t *testing.T, err error) int {
161
- t.Helper()
162
- var appErr *apperror.AppError
163
- if !errors.As(err, &appErr) {
164
- t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
165
- }
166
- return appErr.HTTPStatus
167
- }
168
-
169
- func TestService_Refresh_RotatesTheToken(t *testing.T) {
170
- ctx := context.Background()
171
- userID := uuid.New()
172
- tokens := newFakeTokenStore()
173
- rawOld := "old-refresh-token"
174
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
175
- t.Fatalf("seed: %v", err)
176
- }
177
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
178
-
179
- auth, err := svc.Refresh(ctx, rawOld)
180
- if err != nil {
181
- t.Fatalf("unexpected error: %v", err)
182
- }
183
- if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
184
- t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
185
- }
186
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
187
- t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
188
- }
189
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
190
- t.Fatal("expected the newly rotated refresh token to be active")
191
- }
192
- }
193
-
194
- func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
195
- ctx := context.Background()
196
- userID := uuid.New()
197
- tokens := newFakeTokenStore()
198
- rawOld := "old-refresh-token"
199
- rawOtherSession := "another-devices-refresh-token"
200
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
201
- t.Fatalf("seed: %v", err)
202
- }
203
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOtherSession), userID, time.Hour); err != nil {
204
- t.Fatalf("seed: %v", err)
205
- }
206
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
207
-
208
- // rotate rawOld once — legitimate use, consumes + tombstones it
209
- if _, err := svc.Refresh(ctx, rawOld); err != nil {
210
- t.Fatalf("first refresh: unexpected error: %v", err)
211
- }
212
-
213
- // replay the now-rotated-out raw value — this is what happens if it leaked
214
- _, err := svc.Refresh(ctx, rawOld)
215
- if got := status(t, err); got != http.StatusUnauthorized {
216
- t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
217
- }
218
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
219
- t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
220
- }
221
- }
222
-
223
- func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
224
- ctx := context.Background()
225
- userID := uuid.New()
226
- tokens := newFakeTokenStore()
227
- rawLegit := "a-legit-session-token"
228
- if err := tokens.SetRefreshToken(ctx, hashToken(rawLegit), userID, time.Hour); err != nil {
229
- t.Fatalf("seed: %v", err)
230
- }
231
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
232
-
233
- _, err := svc.Refresh(ctx, "never-issued-token")
234
- if got := status(t, err); got != http.StatusUnauthorized {
235
- t.Fatalf("want 401 for an unrecognized token, got %d", got)
236
- }
237
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
238
- t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
239
- }
240
- }
241
-
242
- func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
243
- ctx := context.Background()
244
- userID := uuid.New()
245
- otherUserID := uuid.New()
246
- tokens := newFakeTokenStore()
247
- rawA := "user-session-a"
248
- rawB := "user-session-b"
249
- rawOther := "other-users-session"
250
- for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
251
- if err := tokens.SetRefreshToken(ctx, hashToken(raw), id, time.Hour); err != nil {
252
- t.Fatalf("seed: %v", err)
253
- }
254
- }
255
- svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
256
-
257
- if err := svc.LogoutAll(ctx, userID); err != nil {
258
- t.Fatalf("unexpected error: %v", err)
259
- }
260
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
261
- t.Fatal("expected session A to be revoked")
262
- }
263
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
264
- t.Fatal("expected session B to be revoked")
265
- }
266
- if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
267
- t.Fatal("expected a different user's session to be untouched by LogoutAll")
268
- }
269
- }
270
-
271
- // The control that actually stops credential stuffing: the counter follows the
272
- // account, so spreading attempts across a proxy pool doesn't help.
273
- func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
274
- repo := &fakeRepo{
275
- user: &model.User{ID: uuid.New(), Email: "a@example.com"},
276
- failures: map[string]int{},
277
- }
278
- svc := newTestService(repo, newFakeTokenStore())
279
- in := loginInput{Email: "a@example.com", Password: "wrong"}
280
-
281
- // the free attempts answer "wrong password", not "locked"
282
- for i := 0; i < loginFreeAttempts; i++ {
283
- if _, err := svc.Login(context.Background(), in); err == nil {
284
- t.Fatalf("attempt %d: expected a failure", i+1)
285
- } else if code(err) != "AUTH_INVALID_CREDENTIALS" {
286
- t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
287
- }
288
- }
289
-
290
- // the next one trips the lock...
291
- if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
292
- t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
293
- }
294
- // ...and everything after it is refused before any password is checked
295
- if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
296
- t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
297
- }
298
- }
299
-
300
- // A locked login must not lock the reset that fixes it — the person who forgot
301
- // their password is exactly the person who trips the login counter.
302
- func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
303
- if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
304
- t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
305
- }
306
- }
307
-
308
- // code pulls the AppError code out, so a test asserting on behaviour doesn't
309
- // have to care how the error is wrapped.
310
- func code(err error) string {
311
- var appErr *apperror.AppError
312
- if errors.As(err, &appErr) {
313
- return appErr.Code
314
- }
315
- return fmt.Sprintf("%v", err)
316
- }
@@ -1,30 +0,0 @@
1
- package user
2
-
3
- import (
4
- "context"
5
- "time"
6
-
7
- "github.com/google/uuid"
8
- )
9
-
10
- // tokenStore is the short-lived-token surface Service needs — declared
11
- // consumer-side so it can be faked in tests, and so the backing store is a
12
- // choice rather than a hard dependency. Two implementations ship with the
13
- // scaffold and exactly one is written into a project:
14
- //
15
- // `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
16
- // `add auth --store redis` -> tokenstore_redis.go (exact across pods)
17
- //
18
- // Service never learns which one it got.
19
- type tokenStore interface {
20
- SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
21
- GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
22
- DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
23
- RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
24
- MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
25
- IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
26
- SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
27
- ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
28
- SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
29
- ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
30
- }
@@ -1,144 +0,0 @@
1
- // Postgres-backed tokenStore — installed by `add auth --store postgres` (the
2
- // default). The Redis-backed alternative lives in tokenstore_redis.go; only
3
- // one of the two is ever written into a project.
4
- //
5
- // Everything Redis needed two keys for collapses into one table here: the
6
- // per-user session set is just a WHERE clause, and expiry is a column every
7
- // read filters on rather than a TTL the store enforces. What that costs is a
8
- // sweep to keep the table from growing — see DeleteExpired.
9
- package user
10
-
11
- import (
12
- "context"
13
- "database/sql"
14
- "errors"
15
- "time"
16
-
17
- "{{goModule}}/internal/app/user/model"
18
- "{{goModule}}/internal/shared/tx"
19
-
20
- "github.com/google/uuid"
21
- "gorm.io/gorm"
22
- )
23
-
24
- const (
25
- kindRefresh = "refresh"
26
- kindRefreshUsed = "refresh_used"
27
- kindPasswordSet = "pwreset"
28
- kindEmailVerify = "emailverify"
29
- )
30
-
31
- type pgTokenStore struct {
32
- db *gorm.DB
33
- }
34
-
35
- func NewPgTokenStore(db *gorm.DB) *pgTokenStore { return &pgTokenStore{db: db} }
36
-
37
- // put writes a token row, replacing any row that already carries the same
38
- // hash — a collision is astronomically unlikely, but "upsert" is the right
39
- // semantic for "this hash now means this" either way.
40
- func (s *pgTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
41
- row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: time.Now().Add(ttl)}
42
- return tx.From(ctx, s.db).WithContext(ctx).
43
- Where("token_hash = ?", hash).
44
- Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": row.ExpiresAt}).
45
- FirstOrCreate(&row).Error
46
- }
47
-
48
- // lookup finds a live token of the given kind. Expiry is enforced here rather
49
- // than by a sweep, so a row that outlived its TTL is already invisible.
50
- func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
51
- var row model.AuthToken
52
- err := tx.From(ctx, s.db).WithContext(ctx).
53
- Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
54
- Take(&row).Error
55
- if errors.Is(err, gorm.ErrRecordNotFound) {
56
- return uuid.Nil, false, nil
57
- }
58
- if err != nil {
59
- return uuid.Nil, false, err
60
- }
61
- return row.UserID, true, nil
62
- }
63
-
64
- // consume deletes and returns in one statement, which is what makes a
65
- // one-time token actually one-time: two requests racing on the same token
66
- // cannot both come away with a user id.
67
- func (s *pgTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
68
- var userID uuid.UUID
69
- err := tx.From(ctx, s.db).WithContext(ctx).Raw(
70
- `DELETE FROM user_svc.auth_tokens
71
- WHERE token_hash = ? AND kind = ? AND expires_at > now()
72
- RETURNING user_id`, hash, kind).Row().Scan(&userID)
73
- if errors.Is(err, sql.ErrNoRows) {
74
- return uuid.Nil, false, nil
75
- }
76
- if err != nil {
77
- return uuid.Nil, false, err
78
- }
79
- return userID, true, nil
80
- }
81
-
82
- func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
83
- return s.put(ctx, tokenHash, kindRefresh, userID, ttl)
84
- }
85
-
86
- func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
87
- return s.lookup(ctx, tokenHash, kindRefresh)
88
- }
89
-
90
- // DeleteRefreshToken removes the row outright and leaves nothing behind — see
91
- // the AuthToken doc comment for why logout must not leave a tombstone.
92
- func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
93
- return tx.From(ctx, s.db).WithContext(ctx).
94
- Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
95
- Delete(&model.AuthToken{}).Error
96
- }
97
-
98
- // RevokeAllRefreshTokens is one statement here because Postgres can query by
99
- // value — the Redis store has to keep a separate per-user set to answer this,
100
- // and that set accumulates stale members its own comment calls out.
101
- // Tombstones are left in place so reuse detection still works afterwards.
102
- func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
103
- return tx.From(ctx, s.db).WithContext(ctx).
104
- Where("user_id = ? AND kind = ?", userID, kindRefresh).
105
- Delete(&model.AuthToken{}).Error
106
- }
107
-
108
- func (s *pgTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
109
- return s.put(ctx, tokenHash, kindRefreshUsed, userID, ttl)
110
- }
111
-
112
- func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
113
- return s.lookup(ctx, tokenHash, kindRefreshUsed)
114
- }
115
-
116
- func (s *pgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
117
- return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
118
- }
119
-
120
- func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
121
- return s.consume(ctx, tokenHash, kindPasswordSet)
122
- }
123
-
124
- func (s *pgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
125
- return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
126
- }
127
-
128
- func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
129
- return s.consume(ctx, tokenHash, kindEmailVerify)
130
- }
131
-
132
- // DeleteExpired drops rows nothing can use any more. Correctness never depends
133
- // on it — every read already filters on expires_at — so it is purely about
134
- // table size, and calling it on a timer from cmd/worker (or a cron) is enough.
135
- //
136
- // ponytail: not scheduled for you. Wire it up the day the table is big enough
137
- // to notice; a scaffold that silently runs background sweeps nobody asked for
138
- // is harder to reason about than one line you add when it matters.
139
- func (s *pgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
140
- res := tx.From(ctx, s.db).WithContext(ctx).
141
- Where("expires_at <= now()").
142
- Delete(&model.AuthToken{})
143
- return res.RowsAffected, res.Error
144
- }
@@ -1,147 +0,0 @@
1
- // Redis-backed tokenStore — installed by `add auth --store redis`.
2
- // The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
3
- // two is ever written into a project, and Service never learns which.
4
- package user
5
-
6
- import (
7
- "context"
8
- "time"
9
-
10
- "github.com/google/uuid"
11
- "github.com/redis/go-redis/v9"
12
- )
13
-
14
- const (
15
- refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
16
- refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
17
- refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL, reuse-detection tombstone
18
- pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
19
- emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
20
- )
21
-
22
- type redisTokenStore struct {
23
- rdb *redis.Client
24
- }
25
-
26
- func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
27
- return &redisTokenStore{rdb: rdb}
28
- }
29
-
30
- func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
31
- pipe := s.rdb.TxPipeline()
32
- pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
33
- pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
34
- _, err := pipe.Exec(ctx)
35
- return err
36
- }
37
-
38
- func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
39
- raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
40
- if err == redis.Nil {
41
- return uuid.Nil, false, nil
42
- }
43
- if err != nil {
44
- return uuid.Nil, false, err
45
- }
46
- id, err := uuid.Parse(raw)
47
- if err != nil {
48
- return uuid.Nil, false, err
49
- }
50
- return id, true, nil
51
- }
52
-
53
- func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
54
- pipe := s.rdb.TxPipeline()
55
- pipe.Del(ctx, refreshKeyPrefix+tokenHash)
56
- pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
57
- _, err := pipe.Exec(ctx)
58
- return err
59
- }
60
-
61
- // RevokeAllRefreshTokens walks the per-user session set and deletes every
62
- // active refresh token for that user — used when reuse of an already-rotated
63
- // token is detected (see Service.Refresh): that means the raw token leaked,
64
- // so every session, not just the replayed one, is treated as compromised.
65
- //
66
- // ponytail: the per-user set has no per-member TTL cleanup of its own — a
67
- // member outlives its key's TTL as a stale entry until the next revoke or
68
- // rotation touches it. Self-heals over time; revisit if a single user's
69
- // session count grows large enough to matter.
70
- func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
71
- setKey := refreshUserKeyPrefix + userID.String()
72
- hashes, err := s.rdb.SMembers(ctx, setKey).Result()
73
- if err != nil {
74
- return err
75
- }
76
- if len(hashes) == 0 {
77
- return nil
78
- }
79
- pipe := s.rdb.TxPipeline()
80
- for _, h := range hashes {
81
- pipe.Del(ctx, refreshKeyPrefix+h)
82
- }
83
- pipe.Del(ctx, setKey)
84
- _, err = pipe.Exec(ctx)
85
- return err
86
- }
87
-
88
- func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
89
- return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
90
- }
91
-
92
- func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
93
- raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
94
- if err == redis.Nil {
95
- return uuid.Nil, false, nil
96
- }
97
- if err != nil {
98
- return uuid.Nil, false, err
99
- }
100
- id, err := uuid.Parse(raw)
101
- if err != nil {
102
- return uuid.Nil, false, err
103
- }
104
- return id, true, nil
105
- }
106
-
107
- func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
108
- return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
109
- }
110
-
111
- // ConsumePasswordResetToken is one-time-use by construction: GETDEL is
112
- // atomic, so a token can't be raced into being consumed twice.
113
- func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
114
- raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
115
- if err == redis.Nil {
116
- return uuid.Nil, false, nil
117
- }
118
- if err != nil {
119
- return uuid.Nil, false, err
120
- }
121
- id, err := uuid.Parse(raw)
122
- if err != nil {
123
- return uuid.Nil, false, err
124
- }
125
- return id, true, nil
126
- }
127
-
128
- func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
129
- return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
130
- }
131
-
132
- // ConsumeEmailVerifyToken is one-time-use by construction, same as
133
- // ConsumePasswordResetToken — GETDEL is atomic.
134
- func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
135
- raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
136
- if err == redis.Nil {
137
- return uuid.Nil, false, nil
138
- }
139
- if err != nil {
140
- return uuid.Nil, false, err
141
- }
142
- id, err := uuid.Parse(raw)
143
- if err != nil {
144
- return uuid.Nil, false, err
145
- }
146
- return id, true, nil
147
- }
@@ -1,45 +0,0 @@
1
- package role
2
-
3
- import (
4
- "time"
5
-
6
- "{{goModule}}/internal/app/role/model"
7
- )
8
-
9
- // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
10
- type response struct {
11
- Code string `json:"code"`
12
- Name string `json:"name"`
13
- IsSystem bool `json:"is_system"`
14
- Permissions []string `json:"permissions"`
15
- CreatedAt time.Time `json:"created_at"`
16
- }
17
-
18
- func toResponse(m *model.Role, perms []string) response {
19
- return response{Code: m.Code, Name: m.Name, IsSystem: m.IsSystem, Permissions: perms, CreatedAt: m.CreatedAt}
20
- }
21
-
22
- type CreateInput struct {
23
- Code string `json:"code" binding:"required,max=20"`
24
- Name string `json:"name" binding:"required"`
25
- }
26
-
27
- // SetPermissionsInput.PermissionCodes is a *pointer* so that "field absent"
28
- // and "field present but empty" stay different things. An empty list is a
29
- // valid request — it revokes every permission the role had — but with a plain
30
- // []string a body that simply misspells the key (say `{"permissions": [...]}`)
31
- // binds to nil, reads as that same "revoke everything", and answers 200 having
32
- // silently stripped the role. `required` on the pointer rejects the absent
33
- // case with a 400 while still allowing an explicit [].
34
- type SetPermissionsInput struct {
35
- PermissionCodes *[]string `json:"permission_codes" binding:"required"`
36
- }
37
-
38
- type permissionResponse struct {
39
- Code string `json:"code"`
40
- Description string `json:"description"`
41
- }
42
-
43
- func toPermissionResponse(m *model.Permission) permissionResponse {
44
- return permissionResponse{Code: m.Code, Description: m.Description}
45
- }