@nakedev/go-scaffold 0.1.2 → 0.1.4
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 +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package role
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/role/model"
|
|
10
|
+
"{{goModule}}/internal/shared/apperror"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// fakeRepo = mock of the repository interface, so the service can be tested
|
|
14
|
+
// without a DB — countWithPermission stands in for "how many roles OTHER
|
|
15
|
+
// than the one being edited currently grant PermRoleManage", exactly what
|
|
16
|
+
// CountRolesWithPermission's excludeCode param means.
|
|
17
|
+
type fakeRepo struct {
|
|
18
|
+
role *model.Role
|
|
19
|
+
findErr error
|
|
20
|
+
allPermissionCodes []string
|
|
21
|
+
countWithPermission int64
|
|
22
|
+
setPermissionsErr error
|
|
23
|
+
deleteErr error
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (f *fakeRepo) PermissionCodes(context.Context, string) ([]string, error) { return nil, nil }
|
|
27
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.Role, error) { return nil, nil }
|
|
28
|
+
func (f *fakeRepo) FindByCode(context.Context, string) (*model.Role, error) {
|
|
29
|
+
if f.findErr != nil {
|
|
30
|
+
return nil, f.findErr
|
|
31
|
+
}
|
|
32
|
+
return f.role, nil
|
|
33
|
+
}
|
|
34
|
+
func (f *fakeRepo) Create(context.Context, *model.Role) error { return nil }
|
|
35
|
+
func (f *fakeRepo) SetPermissions(context.Context, string, []string) error {
|
|
36
|
+
return f.setPermissionsErr
|
|
37
|
+
}
|
|
38
|
+
func (f *fakeRepo) Delete(context.Context, string) error { return f.deleteErr }
|
|
39
|
+
func (f *fakeRepo) FindAllPermissions(context.Context, int, int) ([]model.Permission, error) {
|
|
40
|
+
return nil, nil
|
|
41
|
+
}
|
|
42
|
+
func (f *fakeRepo) AllPermissionCodes(context.Context) ([]string, error) { return f.allPermissionCodes, nil }
|
|
43
|
+
func (f *fakeRepo) CountRolesWithPermission(context.Context, string, string) (int64, error) {
|
|
44
|
+
return f.countWithPermission, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func status(t *testing.T, err error) int {
|
|
48
|
+
t.Helper()
|
|
49
|
+
var appErr *apperror.AppError
|
|
50
|
+
if !errors.As(err, &appErr) {
|
|
51
|
+
t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
|
|
52
|
+
}
|
|
53
|
+
return appErr.HTTPStatus
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestService_SetPermissions_LastRoleManagerLockout(t *testing.T) {
|
|
57
|
+
repo := &fakeRepo{
|
|
58
|
+
role: &model.Role{Code: "ops", Name: "Ops"},
|
|
59
|
+
allPermissionCodes: []string{PermRoleManage, "user:read"},
|
|
60
|
+
countWithPermission: 0, // no OTHER role grants PermRoleManage
|
|
61
|
+
}
|
|
62
|
+
svc := NewService(repo)
|
|
63
|
+
// dropping PermRoleManage from the only role that has it would lock every admin out
|
|
64
|
+
_, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"})
|
|
65
|
+
if got := status(t, err); got != http.StatusConflict {
|
|
66
|
+
t.Fatalf("want 409 last-role-manager lockout, got %d", got)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestService_SetPermissions_AllowedWhenAnotherRoleStillManages(t *testing.T) {
|
|
71
|
+
repo := &fakeRepo{
|
|
72
|
+
role: &model.Role{Code: "ops", Name: "Ops"},
|
|
73
|
+
allPermissionCodes: []string{PermRoleManage, "user:read"},
|
|
74
|
+
countWithPermission: 1, // another role still grants PermRoleManage
|
|
75
|
+
}
|
|
76
|
+
svc := NewService(repo)
|
|
77
|
+
if _, err := svc.SetPermissions(context.Background(), "ops", []string{"user:read"}); err != nil {
|
|
78
|
+
t.Fatalf("expected success when another role still manages roles, got %v", err)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestService_SetPermissions_KeepingRoleManageNeverLocksOut(t *testing.T) {
|
|
83
|
+
repo := &fakeRepo{
|
|
84
|
+
role: &model.Role{Code: "ops", Name: "Ops"},
|
|
85
|
+
allPermissionCodes: []string{PermRoleManage},
|
|
86
|
+
countWithPermission: 0,
|
|
87
|
+
}
|
|
88
|
+
svc := NewService(repo)
|
|
89
|
+
// the guard only fires when PermRoleManage is being dropped — keeping it never trips it
|
|
90
|
+
if _, err := svc.SetPermissions(context.Background(), "ops", []string{PermRoleManage}); err != nil {
|
|
91
|
+
t.Fatalf("keeping %s in the new set should never trigger the lockout guard, got %v", PermRoleManage, err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestService_Delete_LastRoleManagerLockout(t *testing.T) {
|
|
96
|
+
repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 0}
|
|
97
|
+
svc := NewService(repo)
|
|
98
|
+
err := svc.Delete(context.Background(), "ops")
|
|
99
|
+
if got := status(t, err); got != http.StatusConflict {
|
|
100
|
+
t.Fatalf("want 409 last-role-manager lockout on delete, got %d", got)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func TestService_Delete_AllowedWhenAnotherRoleStillManages(t *testing.T) {
|
|
105
|
+
repo := &fakeRepo{role: &model.Role{Code: "ops"}, countWithPermission: 1}
|
|
106
|
+
svc := NewService(repo)
|
|
107
|
+
if err := svc.Delete(context.Background(), "ops"); err != nil {
|
|
108
|
+
t.Fatalf("expected delete to succeed when another role still manages roles, got %v", err)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func TestService_Delete_SystemRoleRejected(t *testing.T) {
|
|
113
|
+
repo := &fakeRepo{role: &model.Role{Code: "admin", IsSystem: true}}
|
|
114
|
+
svc := NewService(repo)
|
|
115
|
+
err := svc.Delete(context.Background(), "admin")
|
|
116
|
+
if got := status(t, err); got != http.StatusConflict {
|
|
117
|
+
t.Fatalf("want 409 for a system role, got %d", got)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"sync"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/shared/apperror"
|
|
10
|
+
|
|
11
|
+
"github.com/gin-gonic/gin"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Authz answers "does this role have this permission?". shared/ can't import
|
|
15
|
+
// a domain package (see RequireAuth's comment), so the role->permission
|
|
16
|
+
// lookup is injected as a func value from main.go instead of calling the
|
|
17
|
+
// role domain directly.
|
|
18
|
+
type Authz struct {
|
|
19
|
+
resolve func(ctx context.Context, roleCode string) (map[string]struct{}, error)
|
|
20
|
+
ttl time.Duration
|
|
21
|
+
|
|
22
|
+
mu sync.RWMutex
|
|
23
|
+
cache map[string]cachedPerms
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type cachedPerms struct {
|
|
27
|
+
perms map[string]struct{}
|
|
28
|
+
expires time.Time
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// NewAuthz builds an Authz that calls resolve on a cache miss. The cache key
|
|
32
|
+
// is the role code, not the user or the request — a handful of roles means
|
|
33
|
+
// a handful of DB round trips per TTL window, independent of traffic.
|
|
34
|
+
func NewAuthz(resolve func(context.Context, string) (map[string]struct{}, error), ttl time.Duration) *Authz {
|
|
35
|
+
return &Authz{resolve: resolve, ttl: ttl, cache: map[string]cachedPerms{}}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Require 403s unless the caller's role (set by RequireAuth) grants perm.
|
|
39
|
+
// Must run after RequireAuth.
|
|
40
|
+
func (a *Authz) Require(perm string) gin.HandlerFunc {
|
|
41
|
+
return func(c *gin.Context) {
|
|
42
|
+
perms, err := a.permsOf(c.Request.Context(), c.GetString(RoleKey))
|
|
43
|
+
if err != nil {
|
|
44
|
+
c.Error(err)
|
|
45
|
+
c.Abort()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if _, ok := perms[perm]; !ok {
|
|
49
|
+
c.Error(apperror.New(http.StatusForbidden, "FORBIDDEN", "insufficient permissions"))
|
|
50
|
+
c.Abort()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
c.Next()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ponytail: process-local cache, a handful of roles x AUTHZ_CACHE_TTL_MIN —
|
|
58
|
+
// a role's permission grants take effect within the TTL, no pub/sub needed.
|
|
59
|
+
// Move to Redis only if instant cross-pod invalidation turns out to matter.
|
|
60
|
+
//
|
|
61
|
+
// Double-checked locking: the common case (cache hit) only needs RLock, so
|
|
62
|
+
// concurrent requests for different roles never block each other. Only a
|
|
63
|
+
// miss takes the exclusive Lock, and only for the one role being resolved —
|
|
64
|
+
// ceiling is that a miss still blocks other roles' misses (not their hits)
|
|
65
|
+
// for the duration of one resolve() call; move to a per-role singleflight
|
|
66
|
+
// if that contention ever actually shows up under load.
|
|
67
|
+
func (a *Authz) permsOf(ctx context.Context, role string) (map[string]struct{}, error) {
|
|
68
|
+
a.mu.RLock()
|
|
69
|
+
e, ok := a.cache[role]
|
|
70
|
+
a.mu.RUnlock()
|
|
71
|
+
if ok && time.Now().Before(e.expires) {
|
|
72
|
+
return e.perms, nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
a.mu.Lock()
|
|
76
|
+
defer a.mu.Unlock()
|
|
77
|
+
// re-check: another goroutine may have refreshed this role while we
|
|
78
|
+
// were waiting for the write lock.
|
|
79
|
+
if e, ok := a.cache[role]; ok && time.Now().Before(e.expires) {
|
|
80
|
+
return e.perms, nil
|
|
81
|
+
}
|
|
82
|
+
perms, err := a.resolve(ctx, role)
|
|
83
|
+
if err != nil {
|
|
84
|
+
return nil, err
|
|
85
|
+
}
|
|
86
|
+
a.cache[role] = cachedPerms{perms: perms, expires: time.Now().Add(a.ttl)}
|
|
87
|
+
return perms, nil
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"sync/atomic"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/gin-gonic/gin"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestAuthz_CachesWithinTTL(t *testing.T) {
|
|
15
|
+
var calls int32
|
|
16
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
17
|
+
atomic.AddInt32(&calls, 1)
|
|
18
|
+
return map[string]struct{}{"x": {}}, nil
|
|
19
|
+
}, time.Minute)
|
|
20
|
+
|
|
21
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
22
|
+
t.Fatalf("unexpected error: %v", err)
|
|
23
|
+
}
|
|
24
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
25
|
+
t.Fatalf("unexpected error: %v", err)
|
|
26
|
+
}
|
|
27
|
+
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
28
|
+
t.Fatalf("want resolve called once for two calls within TTL (cache hit), got %d", got)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestAuthz_ReResolvesAfterTTLExpires(t *testing.T) {
|
|
33
|
+
var calls int32
|
|
34
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
35
|
+
atomic.AddInt32(&calls, 1)
|
|
36
|
+
return map[string]struct{}{"x": {}}, nil
|
|
37
|
+
}, 10*time.Millisecond)
|
|
38
|
+
|
|
39
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
40
|
+
t.Fatalf("unexpected error: %v", err)
|
|
41
|
+
}
|
|
42
|
+
time.Sleep(20 * time.Millisecond)
|
|
43
|
+
if _, err := a.permsOf(context.Background(), "staff"); err != nil {
|
|
44
|
+
t.Fatalf("unexpected error: %v", err)
|
|
45
|
+
}
|
|
46
|
+
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
47
|
+
t.Fatalf("want resolve called again once the TTL expires, got %d calls", got)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
|
|
52
|
+
gin.SetMode(gin.TestMode)
|
|
53
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
54
|
+
return map[string]struct{}{"other:perm": {}}, nil
|
|
55
|
+
}, time.Minute)
|
|
56
|
+
|
|
57
|
+
r := gin.New()
|
|
58
|
+
// Require only records a *apperror.AppError via c.Error — Error(true) is
|
|
59
|
+
// what actually translates that into the response status code, same as
|
|
60
|
+
// the real router (see cmd/api/main.go).
|
|
61
|
+
r.Use(Error(true))
|
|
62
|
+
r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
|
|
63
|
+
c.Status(http.StatusOK)
|
|
64
|
+
})
|
|
65
|
+
w := httptest.NewRecorder()
|
|
66
|
+
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
67
|
+
if w.Code != http.StatusForbidden {
|
|
68
|
+
t.Fatalf("want 403 without the required permission, got %d", w.Code)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func TestAuthz_Require_AllowsGrantedPermission(t *testing.T) {
|
|
73
|
+
gin.SetMode(gin.TestMode)
|
|
74
|
+
a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
75
|
+
return map[string]struct{}{"role:manage": {}}, nil
|
|
76
|
+
}, time.Minute)
|
|
77
|
+
|
|
78
|
+
r := gin.New()
|
|
79
|
+
r.Use(Error(true))
|
|
80
|
+
r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "admin") }, a.Require("role:manage"), func(c *gin.Context) {
|
|
81
|
+
c.Status(http.StatusOK)
|
|
82
|
+
})
|
|
83
|
+
w := httptest.NewRecorder()
|
|
84
|
+
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
|
85
|
+
if w.Code != http.StatusOK {
|
|
86
|
+
t.Fatalf("want 200 with the required permission granted, got %d", w.Code)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- if an admin created a custom role after this migration and it's still
|
|
2
|
+
-- assigned to any user, dropping the roles table would orphan that value —
|
|
3
|
+
-- raise a clear, actionable error instead of a raw FK-violation.
|
|
4
|
+
DO $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF EXISTS (SELECT 1 FROM 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 users DROP COLUMN role;
|
|
12
|
+
|
|
13
|
+
DROP TABLE role_permissions;
|
|
14
|
+
DROP TABLE permissions;
|
|
15
|
+
DROP TABLE roles;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
CREATE TABLE roles (
|
|
2
|
+
code VARCHAR(20) PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
5
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
6
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE permissions (
|
|
10
|
+
code VARCHAR(50) PRIMARY KEY,
|
|
11
|
+
description TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE role_permissions (
|
|
15
|
+
role_code VARCHAR(20) NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
|
|
16
|
+
permission_code VARCHAR(50) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
|
|
17
|
+
PRIMARY KEY (role_code, permission_code)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
INSERT INTO roles (code, name, is_system) VALUES
|
|
21
|
+
('staff', 'Staff', TRUE),
|
|
22
|
+
('admin', 'Admin', TRUE);
|
|
23
|
+
|
|
24
|
+
INSERT INTO permissions (code, description) VALUES
|
|
25
|
+
('role:manage', 'Create roles and change their permissions'),
|
|
26
|
+
('user:manage-role', 'Change a user''s assigned role'),
|
|
27
|
+
('user:read', 'List and view other users');
|
|
28
|
+
|
|
29
|
+
-- admin gets every permission that exists, staff gets none — no behavior
|
|
30
|
+
-- change for existing users until an admin explicitly grants something.
|
|
31
|
+
INSERT INTO role_permissions (role_code, permission_code)
|
|
32
|
+
SELECT 'admin', code FROM permissions;
|
|
33
|
+
|
|
34
|
+
ALTER TABLE users
|
|
35
|
+
ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES roles(code);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"os"
|
|
8
|
+
"os/signal"
|
|
9
|
+
"syscall"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"{{goModule}}/internal/platform/mail"
|
|
13
|
+
"{{goModule}}/internal/platform/queue"
|
|
14
|
+
"{{goModule}}/internal/shared/config"
|
|
15
|
+
// go-scaffold:imports
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func main() {
|
|
19
|
+
cfg := config.Load()
|
|
20
|
+
|
|
21
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
22
|
+
slog.SetDefault(logger)
|
|
23
|
+
|
|
24
|
+
srv, err := queue.NewServer(cfg.RedisURL)
|
|
25
|
+
if err != nil {
|
|
26
|
+
logger.Error("new queue server", "error", err)
|
|
27
|
+
os.Exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
srv.Handle(mail.TypeSendEmail, mail.HandleSendEmail(mail.Open(cfg)))
|
|
31
|
+
// go-scaffold:queue-handlers
|
|
32
|
+
|
|
33
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
34
|
+
defer stop()
|
|
35
|
+
|
|
36
|
+
go func() {
|
|
37
|
+
logger.Info("worker started")
|
|
38
|
+
if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) {
|
|
39
|
+
logger.Error("worker server", "error", err)
|
|
40
|
+
os.Exit(1)
|
|
41
|
+
}
|
|
42
|
+
}()
|
|
43
|
+
|
|
44
|
+
<-ctx.Done()
|
|
45
|
+
logger.Info("shutting down worker")
|
|
46
|
+
|
|
47
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
48
|
+
defer cancel()
|
|
49
|
+
// asynq's Shutdown() blocks until in-flight tasks finish (or the process
|
|
50
|
+
// is killed) with no context/timeout parameter of its own — run it in a
|
|
51
|
+
// goroutine and race it against shutdownCtx so a stuck task can't hang
|
|
52
|
+
// the process forever.
|
|
53
|
+
done := make(chan struct{}, 1)
|
|
54
|
+
go func() {
|
|
55
|
+
srv.Shutdown()
|
|
56
|
+
close(done)
|
|
57
|
+
}()
|
|
58
|
+
select {
|
|
59
|
+
case <-done:
|
|
60
|
+
case <-shutdownCtx.Done():
|
|
61
|
+
logger.Warn("worker shutdown timed out")
|
|
62
|
+
}
|
|
63
|
+
logger.Info("worker stopped")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func parseLevel(s string) slog.Level {
|
|
67
|
+
switch s {
|
|
68
|
+
case "debug":
|
|
69
|
+
return slog.LevelDebug
|
|
70
|
+
case "warn":
|
|
71
|
+
return slog.LevelWarn
|
|
72
|
+
case "error":
|
|
73
|
+
return slog.LevelError
|
|
74
|
+
default:
|
|
75
|
+
return slog.LevelInfo
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package cache
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"{{goModule}}/internal/shared/config"
|
|
5
|
+
|
|
6
|
+
"github.com/redis/go-redis/v9"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Open connects to Redis — it talks to a real external system, so it lives
|
|
10
|
+
// in platform/, not shared/. Used for ephemeral, TTL-bound data and as the
|
|
11
|
+
// backing store for the async task queue (platform/queue).
|
|
12
|
+
func Open(cfg config.Config) (*redis.Client, error) {
|
|
13
|
+
opts, err := redis.ParseURL(cfg.RedisURL)
|
|
14
|
+
if err != nil {
|
|
15
|
+
return nil, err
|
|
16
|
+
}
|
|
17
|
+
return redis.NewClient(opts), nil
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"log/slog"
|
|
6
|
+
"net/smtp"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/shared/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Client sends plain-text email over SMTP (net/smtp already handles STARTTLS
|
|
12
|
+
// when the server offers it, which covers Gmail/SendGrid/Mailgun/SES/Resend
|
|
13
|
+
// SMTP relays on port 587) — talks to a real external system, so it lives in
|
|
14
|
+
// platform/, not shared/.
|
|
15
|
+
type Client struct {
|
|
16
|
+
host, port, username, password, from string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func Open(cfg config.Config) *Client {
|
|
20
|
+
return &Client{
|
|
21
|
+
host: cfg.SMTPHost,
|
|
22
|
+
port: cfg.SMTPPort,
|
|
23
|
+
username: cfg.SMTPUsername,
|
|
24
|
+
password: cfg.SMTPPassword,
|
|
25
|
+
from: cfg.SMTPFrom,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Send delivers a plain-text email. If SMTP isn't configured (dev default —
|
|
30
|
+
// SMTP_HOST unset), it logs the message instead of failing, so flows that
|
|
31
|
+
// send email stay testable without a real mail server.
|
|
32
|
+
func (c *Client) Send(to, subject, body string) error {
|
|
33
|
+
if c.host == "" {
|
|
34
|
+
// ponytail: no SMTP configured — log instead of failing. Upgrade path:
|
|
35
|
+
// set SMTP_HOST (+ USERNAME/PASSWORD/FROM) once a real environment needs it.
|
|
36
|
+
slog.Info("email not sent (SMTP not configured)", "to", to, "subject", subject, "body", body)
|
|
37
|
+
return nil
|
|
38
|
+
}
|
|
39
|
+
msg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, c.from, subject, body)
|
|
40
|
+
// nil Auth when no username is set — a relay/local catcher that doesn't
|
|
41
|
+
// require auth (or doesn't advertise AUTH at all) rejects a non-nil Auth
|
|
42
|
+
// outright, even with empty credentials.
|
|
43
|
+
var auth smtp.Auth
|
|
44
|
+
if c.username != "" {
|
|
45
|
+
auth = smtp.PlainAuth("", c.username, c.password, c.host)
|
|
46
|
+
}
|
|
47
|
+
return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
|
|
7
|
+
"github.com/hibiken/asynq"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const TypeSendEmail = "email:send"
|
|
11
|
+
|
|
12
|
+
type sendEmailPayload struct {
|
|
13
|
+
To string
|
|
14
|
+
Subject string
|
|
15
|
+
Body string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func NewSendEmailTask(p sendEmailPayload) *asynq.Task {
|
|
19
|
+
payload, _ := json.Marshal(p)
|
|
20
|
+
return asynq.NewTask(TypeSendEmail, payload)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func HandleSendEmail(client *Client) asynq.Handler {
|
|
24
|
+
return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
|
|
25
|
+
var p sendEmailPayload
|
|
26
|
+
if err := json.Unmarshal(task.Payload(), &p); err != nil {
|
|
27
|
+
return err
|
|
28
|
+
}
|
|
29
|
+
return client.Send(p.To, p.Subject, p.Body)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AsyncClient struct {
|
|
34
|
+
q interface {
|
|
35
|
+
Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func NewAsyncClient(q interface {
|
|
40
|
+
Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
|
|
41
|
+
}) *AsyncClient {
|
|
42
|
+
return &AsyncClient{q: q}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Send enqueues the email instead of sending it inline, so the caller (an
|
|
46
|
+
// HTTP handler) returns in ~µs instead of blocking on SMTP. cmd/worker picks
|
|
47
|
+
// it up and calls Client.Send.
|
|
48
|
+
func (c *AsyncClient) Send(to, subject, body string) error {
|
|
49
|
+
task := NewSendEmailTask(sendEmailPayload{To: to, Subject: subject, Body: body})
|
|
50
|
+
_, err := c.q.Enqueue(task, asynq.MaxRetry(5))
|
|
51
|
+
return err
|
|
52
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package queue
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/hibiken/asynq"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
// Client enqueues tasks to Redis. Created by cmd/api; each domain or platform
|
|
8
|
+
// package defines its own task types and calls Client.Enqueue.
|
|
9
|
+
type Client struct {
|
|
10
|
+
inner *asynq.Client
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func NewClient(redisURL string) (*Client, error) {
|
|
14
|
+
opt, err := asynq.ParseRedisURI(redisURL)
|
|
15
|
+
if err != nil {
|
|
16
|
+
return nil, err
|
|
17
|
+
}
|
|
18
|
+
return &Client{inner: asynq.NewClient(opt)}, nil
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (c *Client) Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error) {
|
|
22
|
+
info, err := c.inner.Enqueue(task, opts...)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return "", err
|
|
25
|
+
}
|
|
26
|
+
return info.ID, nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (c *Client) Close() error {
|
|
30
|
+
return c.inner.Close()
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package queue
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"log/slog"
|
|
7
|
+
|
|
8
|
+
"github.com/hibiken/asynq"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Server runs the background worker. Created by cmd/worker; handlers register
|
|
12
|
+
// their task types on the mux before Start is called.
|
|
13
|
+
type Server struct {
|
|
14
|
+
inner *asynq.Server
|
|
15
|
+
mux *asynq.ServeMux
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// slogAdapter wraps *slog.Logger to implement asynq.Logger. Asynq calls these
|
|
19
|
+
// like the stdlib `log` package (a plain message, sometimes several args
|
|
20
|
+
// meant to be concatenated) — fmt.Sprint joins them into one message string
|
|
21
|
+
// rather than passing them as slog key-value pairs, which would otherwise
|
|
22
|
+
// misinterpret a lone message argument as a key with no value.
|
|
23
|
+
type slogAdapter struct {
|
|
24
|
+
inner *slog.Logger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
|
|
28
|
+
func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
|
|
29
|
+
func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
|
|
30
|
+
func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
31
|
+
func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
32
|
+
|
|
33
|
+
func NewServer(redisURL string) (*Server, error) {
|
|
34
|
+
opt, err := asynq.ParseRedisURI(redisURL)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return nil, err
|
|
37
|
+
}
|
|
38
|
+
srv := &Server{
|
|
39
|
+
inner: asynq.NewServer(opt, asynq.Config{
|
|
40
|
+
Concurrency: 10,
|
|
41
|
+
Logger: &slogAdapter{inner: slog.Default()},
|
|
42
|
+
}),
|
|
43
|
+
mux: asynq.NewServeMux(),
|
|
44
|
+
}
|
|
45
|
+
// Global error handler for tasks that panic or return a non-Retryable error.
|
|
46
|
+
srv.mux.Use(func(next asynq.Handler) asynq.Handler {
|
|
47
|
+
return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
|
|
48
|
+
err := next.ProcessTask(ctx, task)
|
|
49
|
+
if err != nil {
|
|
50
|
+
slog.Error("task failed", "type", task.Type(), "error", err)
|
|
51
|
+
}
|
|
52
|
+
return err
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
return srv, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (s *Server) Handle(pattern string, handler asynq.Handler) {
|
|
59
|
+
s.mux.Handle(pattern, handler)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (s *Server) Start() error {
|
|
63
|
+
return s.inner.Start(s.mux)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *Server) Shutdown() {
|
|
67
|
+
s.inner.Shutdown()
|
|
68
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# development | production — the one gate for prod behavior (config.IsProd):
|
|
2
|
+
# hides error `details` from responses. Any other value makes the app refuse
|
|
3
|
+
# to boot (so a typo can't silently downgrade prod).
|
|
4
|
+
APP_ENV=development # prod: production
|
|
5
|
+
|
|
1
6
|
PORT=8080
|
|
2
7
|
DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable
|
|
3
8
|
LOG_LEVEL=info
|
|
@@ -5,3 +10,18 @@ AUTO_MIGRATE=true
|
|
|
5
10
|
DB_MAX_OPEN_CONNS=10
|
|
6
11
|
DB_MAX_IDLE_CONNS=10
|
|
7
12
|
DB_CONN_MAX_LIFETIME_MIN=5
|
|
13
|
+
|
|
14
|
+
# read only by `go test`, never by cmd/api — a separate database on purpose: the
|
|
15
|
+
# integration-test harness does DropTable+AutoMigrate on every run, which would
|
|
16
|
+
# otherwise wipe the schema `make migrate-up` built in DB_DSN above.
|
|
17
|
+
# Create it once: make db-create DB_NAME={{dbName}}_test
|
|
18
|
+
TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
|
|
19
|
+
|
|
20
|
+
# comma-separated frontend origins allowed to call this API with credentials (cookies)
|
|
21
|
+
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
|
22
|
+
{{#if observability}}
|
|
23
|
+
|
|
24
|
+
# OTLP/HTTP endpoint for trace export (e.g. localhost:4318) — empty disables
|
|
25
|
+
# tracing entirely: no exporter is created, no network calls are made
|
|
26
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=
|
|
27
|
+
{{/if}}
|