@nakedev/go-scaffold 0.4.3 → 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 +567 -513
- package/dist/commands/auth.js +12 -1
- package/dist/commands/check.js +281 -0
- package/dist/commands/create.js +2 -1
- package/dist/commands/generate.js +5 -2
- package/dist/commands/method.js +66 -70
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +19 -8
- package/dist/commands/undo.js +6 -3
- package/dist/commands/worker.js +14 -4
- package/dist/index.js +13 -1
- package/dist/templates/auth-manifest.js +46 -45
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +82 -60
- package/dist/templates/rbac-manifest.js +15 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +2 -0
- package/dist/utils/auth-patcher.js +22 -22
- package/dist/utils/config.js +24 -5
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +3 -3
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/platform-patcher.js +27 -0
- package/dist/utils/rbac-patcher.js +73 -216
- package/package.json +1 -1
- package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
- package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
- package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
- package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
- package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
- package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
- 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/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
- package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
- 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/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
- package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
- package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
- package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
- package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
- 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/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
- package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
- package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
- package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
- package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
- package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
- package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
- package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
- 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/migrations/create_auth_tokens.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
- 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 +23 -10
- 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 +54 -36
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +55 -31
- package/templates/create/base/README.md.hbs +77 -22
- package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +64 -26
- package/templates/create/features/docs/patterns.md.hbs +117 -90
- package/templates/create/features/docs/techstack.md.hbs +17 -2
- 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 -537
- package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
- 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/commands.go.hbs +0 -95
- package/templates/generate/module/composition.go.hbs +0 -23
- package/templates/generate/module/cqrs_test.go.hbs +0 -7
- 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 -179
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/commands.go.hbs +0 -34
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -82
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/queries.go.hbs +0 -45
- package/templates/generate/module/minimal/service.go.hbs +0 -71
- 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/queries.go.hbs +0 -62
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -164
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -1,89 +1,33 @@
|
|
|
1
1
|
package user
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
-
"context"
|
|
5
4
|
"fmt"
|
|
6
5
|
"time"
|
|
7
6
|
|
|
7
|
+
httpadapter "{{goModule}}/internal/app/user/adapters/inbound/http"
|
|
8
|
+
userpassword "{{goModule}}/internal/app/user/adapters/outbound/password"
|
|
9
|
+
userpostgres "{{goModule}}/internal/app/user/adapters/outbound/postgres"
|
|
8
10
|
"{{goModule}}/internal/app/user/application"
|
|
9
|
-
"{{goModule}}/internal/app/user/
|
|
11
|
+
"{{goModule}}/internal/app/user/ports"
|
|
10
12
|
googleprovider "{{goModule}}/internal/platform/authprovider/google"
|
|
11
13
|
"{{goModule}}/internal/platform/mail"
|
|
14
|
+
{{#if worker}}
|
|
15
|
+
"{{goModule}}/internal/platform/queue"
|
|
16
|
+
{{/if}}
|
|
12
17
|
"{{goModule}}/internal/shared/config"
|
|
13
18
|
"{{goModule}}/internal/shared/middleware"
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
{{#if redis}}
|
|
20
|
+
{{#if redis}}
|
|
21
|
+
userredis "{{goModule}}/internal/app/user/adapters/outbound/redis"
|
|
18
22
|
"github.com/redis/go-redis/v9"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"{{goModule}}/internal/platform/queue"
|
|
22
|
-
{{/if}}
|
|
23
|
+
{{/if}}
|
|
24
|
+
"github.com/gin-gonic/gin"
|
|
23
25
|
"gorm.io/gorm"
|
|
24
26
|
)
|
|
25
27
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
type recoveryRepositoryAdapter struct {
|
|
30
|
-
repo repository
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
func (a recoveryRepositoryAdapter) FindIdentity(ctx context.Context, userID uuid.UUID) (*application.Identity, error) {
|
|
34
|
-
identity, err := a.repo.FindIdentity(ctx, userID, model.ProviderLocal)
|
|
35
|
-
if err != nil {
|
|
36
|
-
return nil, err
|
|
37
|
-
}
|
|
38
|
-
if identity.PasswordHash == nil {
|
|
39
|
-
return nil, fmt.Errorf("local identity has no password")
|
|
40
|
-
}
|
|
41
|
-
return &application.Identity{ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash}, nil
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
func (a recoveryRepositoryAdapter) UpdateIdentity(ctx context.Context, identity *application.Identity) error {
|
|
45
|
-
current, err := a.repo.FindIdentity(ctx, identity.UserID, model.ProviderLocal)
|
|
46
|
-
if err != nil {
|
|
47
|
-
return err
|
|
48
|
-
}
|
|
49
|
-
current.PasswordHash = &identity.PasswordHash
|
|
50
|
-
return a.repo.UpdateIdentity(ctx, current)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
func (a recoveryRepositoryAdapter) FindUser(ctx context.Context, userID uuid.UUID) (*application.User, error) {
|
|
54
|
-
user, err := a.repo.FindByID(ctx, userID)
|
|
55
|
-
if err != nil {
|
|
56
|
-
return nil, err
|
|
57
|
-
}
|
|
58
|
-
return &application.User{ID: user.ID, EmailVerified: user.EmailVerified}, nil
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
func (a recoveryRepositoryAdapter) UpdateUser(ctx context.Context, user *application.User) error {
|
|
62
|
-
current, err := a.repo.FindByID(ctx, user.ID)
|
|
63
|
-
if err != nil {
|
|
64
|
-
return err
|
|
65
|
-
}
|
|
66
|
-
current.EmailVerified = user.EmailVerified
|
|
67
|
-
return a.repo.UpdateUser(ctx, current)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
type bcryptPasswordHasher struct{}
|
|
71
|
-
|
|
72
|
-
func (bcryptPasswordHasher) Hash(password string) (string, error) {
|
|
73
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
74
|
-
if err != nil {
|
|
75
|
-
return "", err
|
|
76
|
-
}
|
|
77
|
-
return string(hash), nil
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
func newRecovery(repo repository, tokens application.RecoveryTokens) *application.Recovery {
|
|
81
|
-
return application.NewRecovery(recoveryRepositoryAdapter{repo: repo}, tokens, bcryptPasswordHasher{})
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// NewHandlerFromDB is auth's local composition root. cmd/api owns shared
|
|
85
|
-
// infrastructure and cross-feature dependencies; repository, token store,
|
|
86
|
-
// mailer, service, limiter, and handler construction stays in this package.
|
|
28
|
+
// NewHandlerFromDB is the user module's composition root. It assembles the
|
|
29
|
+
// application service and adapters here; cmd/api only supplies process-level
|
|
30
|
+
// infrastructure and registers the returned inbound handler.
|
|
87
31
|
func NewHandlerFromDB(
|
|
88
32
|
db *gorm.DB,
|
|
89
33
|
cfg config.Config,
|
|
@@ -92,23 +36,23 @@ func NewHandlerFromDB(
|
|
|
92
36
|
{{/if}}
|
|
93
37
|
{{#if worker}}
|
|
94
38
|
q queue.Enqueuer,
|
|
95
|
-
|
|
39
|
+
{{/if}}
|
|
96
40
|
roleChecker RoleChecker,
|
|
97
|
-
authz
|
|
98
|
-
) *Handler {
|
|
41
|
+
authz Authorizer,
|
|
42
|
+
) *httpadapter.Handler {
|
|
99
43
|
{{#if redis}}
|
|
100
|
-
tokens := NewRedisTokenStore(rdb, db)
|
|
44
|
+
tokens := userredis.NewRedisTokenStore(rdb, db)
|
|
101
45
|
{{else}}
|
|
102
|
-
tokens := NewPgTokenStore(db)
|
|
46
|
+
tokens := userpostgres.NewPgTokenStore(db)
|
|
103
47
|
{{/if}}
|
|
104
48
|
|
|
105
49
|
{{#if worker}}
|
|
106
|
-
|
|
50
|
+
mailer := mail.NewAsyncClient(q)
|
|
107
51
|
{{else}}
|
|
108
|
-
|
|
52
|
+
mailer := mail.NewSyncClient(mail.Open(cfg))
|
|
109
53
|
{{/if}}
|
|
110
54
|
|
|
111
|
-
|
|
55
|
+
providers := make([]application.LoginProvider, 0, 1)
|
|
112
56
|
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" && cfg.GoogleOAuthRedirectURI != "" {
|
|
113
57
|
providers = append(providers, googleprovider.New(googleprovider.Config{
|
|
114
58
|
ClientID: cfg.GoogleClientID,
|
|
@@ -117,7 +61,7 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
|
|
|
117
61
|
}))
|
|
118
62
|
}
|
|
119
63
|
providerRegistry := application.NewProviderRegistry(providers...)
|
|
120
|
-
if err :=
|
|
64
|
+
if err := httpadapter.ValidateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
|
|
121
65
|
panic(err)
|
|
122
66
|
}
|
|
123
67
|
if cfg.JWTRefreshTTL <= 0 || cfg.JWTRefreshMaxTTL <= 0 {
|
|
@@ -127,17 +71,18 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
|
|
|
127
71
|
panic(fmt.Errorf("OAUTH_STATE_TTL_MIN must be positive"))
|
|
128
72
|
}
|
|
129
73
|
|
|
130
|
-
service := NewService(Dependencies{
|
|
131
|
-
Repository: NewRepository(db),
|
|
74
|
+
service := application.NewService(application.Dependencies{
|
|
75
|
+
Repository: userpostgres.NewRepository(db),
|
|
76
|
+
Passwords: userpassword.NewBCryptHasher(),
|
|
132
77
|
RefreshTokens: tokens,
|
|
133
78
|
OAuthTransactions: tokens,
|
|
134
79
|
RecoveryTokens: tokens,
|
|
135
|
-
MFA: NewPostgresMFAStore(db),
|
|
136
|
-
Mailer:
|
|
80
|
+
MFA: userpostgres.NewPostgresMFAStore(db),
|
|
81
|
+
Mailer: mailer,
|
|
137
82
|
Providers: providerRegistry,
|
|
138
83
|
Roles: roleChecker,
|
|
139
84
|
Clock: time.Now,
|
|
140
|
-
}, AuthConfig{
|
|
85
|
+
}, application.AuthConfig{
|
|
141
86
|
JWTSecret: cfg.JWTSecret,
|
|
142
87
|
JWTAccessTTL: cfg.JWTAccessTTL,
|
|
143
88
|
JWTRefreshTTL: cfg.JWTRefreshTTL,
|
|
@@ -147,7 +92,7 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
|
|
|
147
92
|
PasswordResetURL: cfg.PasswordResetURL,
|
|
148
93
|
EmailVerifyTTL: cfg.EmailVerifyTTL,
|
|
149
94
|
EmailVerifyURL: cfg.EmailVerifyURL,
|
|
150
|
-
MFA: MFASettings{
|
|
95
|
+
MFA: application.MFASettings{
|
|
151
96
|
Enabled: cfg.AuthMFAEnabled,
|
|
152
97
|
Issuer: cfg.MFAIssuer,
|
|
153
98
|
EncryptionKey: cfg.MFAEncryptionKey,
|
|
@@ -161,5 +106,63 @@ outbound := mail.NewSyncClient(mail.Open(cfg))
|
|
|
161
106
|
{{else}}
|
|
162
107
|
limiter := middleware.NewMemoryLimiter()
|
|
163
108
|
{{/if}}
|
|
164
|
-
return NewHandlerWithOrigins(
|
|
109
|
+
return httpadapter.NewHandlerWithOrigins(
|
|
110
|
+
service,
|
|
111
|
+
cfg.JWTSecret,
|
|
112
|
+
cfg.JWTRefreshTTL,
|
|
113
|
+
cfg.CookieSecure,
|
|
114
|
+
cfg.CookieSameSite,
|
|
115
|
+
cfg.CORSAllowedOrigins,
|
|
116
|
+
limiter,
|
|
117
|
+
authz,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Public aliases keep the composition surface small for cmd/seed and for
|
|
122
|
+
// optional RBAC wiring. Consumers do not need to know adapter package names.
|
|
123
|
+
type Dependencies = application.Dependencies
|
|
124
|
+
type AuthConfig = application.AuthConfig
|
|
125
|
+
type MFASettings = application.MFASettings
|
|
126
|
+
type ProviderRegistry = application.ProviderRegistry
|
|
127
|
+
type RoleChecker = ports.RoleChecker
|
|
128
|
+
type Service = application.Service
|
|
129
|
+
type ServicePort = application.ServicePort
|
|
130
|
+
type Repository = userpostgres.Repository
|
|
131
|
+
{{#unless redis}}
|
|
132
|
+
type PgTokenStore = userpostgres.PgTokenStore
|
|
133
|
+
{{/unless}}
|
|
134
|
+
type PostgresMFAStore = userpostgres.PostgresMFAStore
|
|
135
|
+
{{#if redis}}
|
|
136
|
+
type RedisTokenStore = userredis.RedisTokenStore
|
|
137
|
+
{{/if}}
|
|
138
|
+
|
|
139
|
+
type Authorizer interface {
|
|
140
|
+
Require(string) gin.HandlerFunc
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func NewService(deps Dependencies, cfg AuthConfig) *Service {
|
|
144
|
+
return application.NewService(deps, cfg)
|
|
165
145
|
}
|
|
146
|
+
|
|
147
|
+
func ValidateMFASettings(settings MFASettings) error {
|
|
148
|
+
return application.ValidateMFASettings(settings)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func NewRepository(db *gorm.DB) *Repository {
|
|
152
|
+
return userpostgres.NewRepository(db)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
{{#unless redis}}
|
|
156
|
+
func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
|
|
157
|
+
return userpostgres.NewPgTokenStore(db)
|
|
158
|
+
}
|
|
159
|
+
{{/unless}}
|
|
160
|
+
|
|
161
|
+
func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore {
|
|
162
|
+
return userpostgres.NewPostgresMFAStore(db)
|
|
163
|
+
}
|
|
164
|
+
{{#if redis}}
|
|
165
|
+
func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
|
|
166
|
+
return userredis.NewRedisTokenStore(rdb, db)
|
|
167
|
+
}
|
|
168
|
+
{{/if}}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Package domain contains transport- and persistence-neutral user values.
|
|
2
|
+
package domain
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type User struct {
|
|
11
|
+
ID uuid.UUID
|
|
12
|
+
Email string
|
|
13
|
+
Name string
|
|
14
|
+
AvatarURL string
|
|
15
|
+
EmailVerified bool
|
|
16
|
+
Role string
|
|
17
|
+
CreatedAt time.Time
|
|
18
|
+
UpdatedAt time.Time
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DefaultRole is part of auth's persisted user contract. RBAC may add the
|
|
22
|
+
// role catalog later, but every newly issued access token must carry the same
|
|
23
|
+
// default that the users migration applies at the database boundary.
|
|
24
|
+
const DefaultRole = "staff"
|
|
25
|
+
|
|
26
|
+
type Provider string
|
|
27
|
+
|
|
28
|
+
const (
|
|
29
|
+
ProviderLocal Provider = "local"
|
|
30
|
+
ProviderGoogle Provider = "google"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
type Identity struct {
|
|
34
|
+
ID uuid.UUID
|
|
35
|
+
UserID uuid.UUID
|
|
36
|
+
Provider Provider
|
|
37
|
+
PasswordHash *string
|
|
38
|
+
ProviderUID *string
|
|
39
|
+
CreatedAt time.Time
|
|
40
|
+
UpdatedAt time.Time
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
package domain
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
ErrNotFound = errors.New("user not found")
|
|
7
|
+
ErrConflict = errors.New("user conflict")
|
|
8
|
+
ErrInvalidCredential = errors.New("invalid credentials")
|
|
9
|
+
ErrInvalidToken = errors.New("invalid or expired token")
|
|
10
|
+
ErrEmailTaken = errors.New("email already registered")
|
|
11
|
+
ErrTooManyAttempts = errors.New("too many failed attempts")
|
|
12
|
+
ErrAlreadyVerified = errors.New("email already verified")
|
|
13
|
+
ErrMFAUnavailable = errors.New("MFA is unavailable")
|
|
14
|
+
ErrMFAInvalid = errors.New("invalid or expired MFA code")
|
|
15
|
+
ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
|
|
16
|
+
ErrMFANotEnrolled = errors.New("MFA is not enrolled")
|
|
17
|
+
ErrMFASetupRequired = errors.New("MFA setup is required")
|
|
18
|
+
ErrUnknownRole = errors.New("unknown role")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type RuleError struct {
|
|
22
|
+
Code string
|
|
23
|
+
Message string
|
|
24
|
+
Cause error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (e *RuleError) Error() string { return e.Message }
|
|
28
|
+
func (e *RuleError) Unwrap() error { return e.Cause }
|
|
29
|
+
|
|
30
|
+
func Rule(code, message string, cause error) error {
|
|
31
|
+
return &RuleError{Code: code, Message: message, Cause: cause}
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package ports
|
|
2
|
+
|
|
3
|
+
// PasswordHasher is the application-owned outbound contract for password
|
|
4
|
+
// storage and verification. The algorithm and cost policy belong to an
|
|
5
|
+
// adapter, not to the auth use cases.
|
|
6
|
+
type PasswordHasher interface {
|
|
7
|
+
Hash(string) (string, error)
|
|
8
|
+
Compare(hash, password string) error
|
|
9
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Package ports contains user auth's application-owned outbound contracts.
|
|
2
|
+
package ports
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type UserRepository interface {
|
|
14
|
+
FindByEmail(context.Context, string) (*domain.User, error)
|
|
15
|
+
FindByID(context.Context, uuid.UUID) (*domain.User, error)
|
|
16
|
+
UpdateUser(context.Context, *domain.User) error
|
|
17
|
+
FindAll(context.Context, int, int) ([]domain.User, error)
|
|
18
|
+
FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
|
|
19
|
+
FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
|
|
20
|
+
CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
|
|
21
|
+
CreateIdentity(context.Context, *domain.Identity) error
|
|
22
|
+
UpdateIdentity(context.Context, *domain.Identity) error
|
|
23
|
+
LoginLockedUntil(context.Context, string) (time.Time, error)
|
|
24
|
+
RecordLoginFailure(context.Context, string, int, time.Duration) error
|
|
25
|
+
ClearLoginFailures(context.Context, string) error
|
|
26
|
+
// go-scaffold:repository-interface
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type AuthMailer interface {
|
|
30
|
+
Send(context.Context, string, string, string) error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type RoleChecker interface {
|
|
34
|
+
CodeExists(context.Context, string) (bool, error)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type RefreshTokenRecord struct {
|
|
38
|
+
UserID uuid.UUID `json:"user_id"`
|
|
39
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
40
|
+
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RefreshTokenStore interface {
|
|
44
|
+
SetRefreshToken(context.Context, string, RefreshTokenRecord) error
|
|
45
|
+
GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
|
|
46
|
+
ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
|
|
47
|
+
DeleteRefreshToken(context.Context, string, uuid.UUID) error
|
|
48
|
+
RevokeAllRefreshTokens(context.Context, uuid.UUID) error
|
|
49
|
+
IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type LoginTransaction struct {
|
|
53
|
+
Provider string `json:"provider"`
|
|
54
|
+
CodeChallenge string `json:"code_challenge"`
|
|
55
|
+
Nonce string `json:"nonce"`
|
|
56
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type OAuthTransactionStore interface {
|
|
60
|
+
SetLoginTransaction(context.Context, string, LoginTransaction) error
|
|
61
|
+
ConsumeLoginTransaction(context.Context, string) (LoginTransaction, bool, error)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RecoveryTokenStore interface {
|
|
65
|
+
WithTransaction(context.Context, func(context.Context) error) error
|
|
66
|
+
ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error)
|
|
67
|
+
ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error)
|
|
68
|
+
SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error
|
|
69
|
+
SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type MFAEnrollment struct {
|
|
73
|
+
EncryptedSecret string
|
|
74
|
+
Enabled bool
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type MFAChallenge struct {
|
|
78
|
+
UserID uuid.UUID
|
|
79
|
+
ExpiresAt time.Time
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type MFAStore interface {
|
|
83
|
+
GetEnrollment(context.Context, uuid.UUID) (MFAEnrollment, bool, error)
|
|
84
|
+
PutPendingEnrollment(context.Context, uuid.UUID, string) error
|
|
85
|
+
ConfirmEnrollment(context.Context, uuid.UUID, string, []string) error
|
|
86
|
+
Disable(context.Context, uuid.UUID) error
|
|
87
|
+
CreateChallenge(context.Context, string, MFAChallenge) error
|
|
88
|
+
ConsumeChallenge(context.Context, string) (MFAChallenge, bool, error)
|
|
89
|
+
ConsumeRecoveryCode(context.Context, uuid.UUID, string) (bool, error)
|
|
90
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Durable recovery tokens for both refresh-store choices. Refresh rotation may
|
|
2
2
|
-- use Redis, but password reset and email verification share this DB contract.
|
|
3
|
-
-- Only the SHA-256 hash is stored; see model
|
|
3
|
+
-- Only the SHA-256 hash is stored; see adapters/outbound/postgres/model.go for what `kind`
|
|
4
4
|
-- means and why the rotation tombstone is a separate row.
|
|
5
5
|
CREATE TABLE user_svc.auth_tokens (
|
|
6
6
|
token_hash TEXT PRIMARY KEY,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- Failed-attempt counter per account. Keyed on a hash of the submitted email
|
|
2
2
|
-- so it works for addresses that have no account (otherwise throttling itself
|
|
3
3
|
-- leaks which addresses are registered) and so this table is not a user list.
|
|
4
|
-
-- See model
|
|
4
|
+
-- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
|
|
5
5
|
CREATE TABLE user_svc.login_throttle (
|
|
6
6
|
email_hash TEXT PRIMARY KEY,
|
|
7
7
|
failures INTEGER NOT NULL DEFAULT 0,
|
|
@@ -6,12 +6,13 @@ CREATE TABLE user_svc.users (
|
|
|
6
6
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
7
7
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
8
8
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
9
|
+
role VARCHAR(20) NOT NULL DEFAULT 'staff',
|
|
9
10
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
10
11
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
11
12
|
);
|
|
12
13
|
|
|
13
14
|
-- A unique INDEX, not an inline UNIQUE constraint, and named to match the
|
|
14
|
-
-- `uniqueIndex:idx_users_email` tag on model
|
|
15
|
+
-- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
|
|
15
16
|
-- inline UNIQUE "users_email_key"; the development bootstrap then sees
|
|
16
17
|
-- uniqueness it didn't create and tries to drop it under its own
|
|
17
18
|
-- name, which fails the boot with
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Package httpadapter is role's inbound HTTP boundary. It owns route,
|
|
2
|
+
// authentication middleware, request binding, response DTOs, and translation
|
|
3
|
+
// from application errors to the API error envelope.
|
|
4
|
+
package httpadapter
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"errors"
|
|
8
|
+
"net/http"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/role/application"
|
|
11
|
+
"{{goModule}}/internal/app/role/domain"
|
|
12
|
+
"{{goModule}}/internal/shared/apperror"
|
|
13
|
+
"{{goModule}}/internal/shared/httpx"
|
|
14
|
+
"{{goModule}}/internal/shared/middleware"
|
|
15
|
+
"{{goModule}}/internal/shared/pagination"
|
|
16
|
+
|
|
17
|
+
"github.com/gin-gonic/gin"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type Handler struct {
|
|
21
|
+
svc application.ServicePort
|
|
22
|
+
jwtSecret string
|
|
23
|
+
authz *middleware.Authz
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func NewHandler(svc application.ServicePort, jwtSecret string, authz *middleware.Authz) *Handler {
|
|
27
|
+
return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (h *Handler) Register(rg gin.IRouter) {
|
|
31
|
+
roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
|
|
32
|
+
roles.GET("", h.list)
|
|
33
|
+
roles.POST("", h.create)
|
|
34
|
+
roles.PATCH("/:code/permissions", h.setPermissions)
|
|
35
|
+
roles.DELETE("/:code", h.delete)
|
|
36
|
+
|
|
37
|
+
perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
|
|
38
|
+
perms.GET("", h.listPermissions)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (h *Handler) list(c *gin.Context) {
|
|
42
|
+
p := pagination.Parse(c)
|
|
43
|
+
items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
|
|
44
|
+
if err != nil {
|
|
45
|
+
c.Error(toHTTPError(err))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
out := make([]application.RoleResponse, len(items))
|
|
49
|
+
for i := range items {
|
|
50
|
+
out[i] = application.ToRoleResponse(items[i])
|
|
51
|
+
}
|
|
52
|
+
c.JSON(http.StatusOK, p.Response(out))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (h *Handler) create(c *gin.Context) {
|
|
56
|
+
var in application.CreateInput
|
|
57
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
58
|
+
c.Error(httpx.BindErr(err))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
item, err := h.svc.Create(c.Request.Context(), in)
|
|
62
|
+
if err != nil {
|
|
63
|
+
c.Error(toHTTPError(err))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (h *Handler) setPermissions(c *gin.Context) {
|
|
70
|
+
var in application.SetPermissionsInput
|
|
71
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
72
|
+
c.Error(httpx.BindErr(err))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if in.PermissionCodes == nil {
|
|
76
|
+
c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
|
|
80
|
+
if err != nil {
|
|
81
|
+
c.Error(toHTTPError(err))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
h.authz.Invalidate(item.Role.Code)
|
|
85
|
+
c.JSON(http.StatusOK, application.ToRoleResponse(*item))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (h *Handler) delete(c *gin.Context) {
|
|
89
|
+
if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
|
|
90
|
+
c.Error(toHTTPError(err))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
h.authz.Invalidate(c.Param("code"))
|
|
94
|
+
c.Status(http.StatusNoContent)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (h *Handler) listPermissions(c *gin.Context) {
|
|
98
|
+
p := pagination.Parse(c)
|
|
99
|
+
items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
|
|
100
|
+
if err != nil {
|
|
101
|
+
c.Error(toHTTPError(err))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
out := make([]application.PermissionResponse, len(items))
|
|
105
|
+
for i := range items {
|
|
106
|
+
out[i] = application.ToPermissionResponse(items[i])
|
|
107
|
+
}
|
|
108
|
+
c.JSON(http.StatusOK, p.Response(out))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func toHTTPError(err error) error {
|
|
112
|
+
if err == nil {
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
var appErr *apperror.AppError
|
|
116
|
+
if errors.As(err, &appErr) {
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
var rule *domain.RuleError
|
|
120
|
+
if errors.As(err, &rule) {
|
|
121
|
+
status := http.StatusUnprocessableEntity
|
|
122
|
+
switch {
|
|
123
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
124
|
+
status = http.StatusNotFound
|
|
125
|
+
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrHasReferences), errors.Is(err, domain.ErrSystemRole), errors.Is(err, domain.ErrLastRoleManager):
|
|
126
|
+
status = http.StatusConflict
|
|
127
|
+
}
|
|
128
|
+
return apperror.New(status, rule.Code, rule.Message)
|
|
129
|
+
}
|
|
130
|
+
switch {
|
|
131
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
132
|
+
return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
|
|
133
|
+
case errors.Is(err, domain.ErrConflict):
|
|
134
|
+
return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role conflict")
|
|
135
|
+
case errors.Is(err, domain.ErrHasReferences):
|
|
136
|
+
return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
|
|
137
|
+
case errors.Is(err, domain.ErrUnknownPermission):
|
|
138
|
+
return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
|
|
139
|
+
default:
|
|
140
|
+
return apperror.NewInternal(err)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/shared/middleware"
|
|
9
|
+
|
|
10
|
+
"github.com/gin-gonic/gin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestHandler_Register(t *testing.T) {
|
|
14
|
+
gin.SetMode(gin.TestMode)
|
|
15
|
+
authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
16
|
+
return map[string]struct{}{}, nil
|
|
17
|
+
}, time.Minute)
|
|
18
|
+
NewHandler(nil, "", authz).Register(gin.New())
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Package postgres contains role persistence records. They are deliberately
|
|
2
|
+
// separate from role/domain so schema tags cannot leak into use cases.
|
|
3
|
+
package postgres
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/role/domain"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type Role struct {
|
|
12
|
+
Code string `gorm:"type:varchar(20);primaryKey"`
|
|
13
|
+
Name string `gorm:"not null"`
|
|
14
|
+
IsSystem bool `gorm:"not null;default:false"`
|
|
15
|
+
CreatedAt time.Time
|
|
16
|
+
UpdatedAt time.Time
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (Role) TableName() string { return "role_svc.roles" }
|
|
20
|
+
|
|
21
|
+
type Permission struct {
|
|
22
|
+
Code string `gorm:"type:varchar(50);primaryKey"`
|
|
23
|
+
Description string `gorm:"not null"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (Permission) TableName() string { return "role_svc.permissions" }
|
|
27
|
+
|
|
28
|
+
type RolePermission struct {
|
|
29
|
+
RoleCode string `gorm:"primaryKey;type:varchar(20)"`
|
|
30
|
+
PermissionCode string `gorm:"primaryKey;type:varchar(50)"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (RolePermission) TableName() string { return "role_svc.role_permissions" }
|
|
34
|
+
|
|
35
|
+
func toDomainRole(row *Role) domain.Role {
|
|
36
|
+
return domain.Role{Code: row.Code, Name: row.Name, IsSystem: row.IsSystem, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func fromDomainRole(value *domain.Role) *Role {
|
|
40
|
+
if value == nil {
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
return &Role{Code: value.Code, Name: value.Name, IsSystem: value.IsSystem, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func toDomainPermission(row *Permission) domain.Permission {
|
|
47
|
+
return domain.Permission{Code: row.Code, Description: row.Description}
|
|
48
|
+
}
|