@nakedev/go-scaffold 0.1.4 → 0.3.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/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
CREATE
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS user_svc;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE user_svc.users (
|
|
2
4
|
id UUID PRIMARY KEY,
|
|
3
|
-
email VARCHAR(255) NOT NULL
|
|
5
|
+
email VARCHAR(255) NOT NULL,
|
|
4
6
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
5
7
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
6
8
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
7
9
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
10
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
11
|
);
|
|
12
|
+
|
|
13
|
+
-- A unique INDEX, not an inline UNIQUE constraint, and named to match the
|
|
14
|
+
-- `uniqueIndex:idx_users_email` tag on model.User exactly. Postgres names an
|
|
15
|
+
-- inline UNIQUE "users_email_key"; GORM's AutoMigrate (dev, AUTO_MIGRATE=true)
|
|
16
|
+
-- then sees uniqueness it didn't create and tries to drop it under its own
|
|
17
|
+
-- name, which fails the boot with
|
|
18
|
+
-- constraint "uni_users_email" of relation "users" does not exist
|
|
19
|
+
-- for anyone who ran `make migrate-up` first — which `add rbac` tells you to.
|
|
20
|
+
CREATE UNIQUE INDEX idx_users_email ON user_svc.users (email);
|
|
@@ -24,10 +24,15 @@ type CreateInput struct {
|
|
|
24
24
|
Name string `json:"name" binding:"required"`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// SetPermissionsInput.PermissionCodes is
|
|
28
|
-
//
|
|
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 [].
|
|
29
34
|
type SetPermissionsInput struct {
|
|
30
|
-
PermissionCodes []string `json:"permission_codes"`
|
|
35
|
+
PermissionCodes *[]string `json:"permission_codes" binding:"required"`
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
type permissionResponse struct {
|
|
@@ -70,11 +70,13 @@ func (h *Handler) setPermissions(c *gin.Context) {
|
|
|
70
70
|
c.Error(httpx.BindErr(err))
|
|
71
71
|
return
|
|
72
72
|
}
|
|
73
|
-
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), in.PermissionCodes)
|
|
73
|
+
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
|
|
74
74
|
if err != nil {
|
|
75
75
|
c.Error(err)
|
|
76
76
|
return
|
|
77
77
|
}
|
|
78
|
+
// the grant is useless until the cache that answers Require() knows about it
|
|
79
|
+
h.authz.Invalidate(item.Code)
|
|
78
80
|
c.JSON(http.StatusOK, toResponse(&item.Role, item.Permissions))
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -83,6 +85,7 @@ func (h *Handler) delete(c *gin.Context) {
|
|
|
83
85
|
c.Error(err)
|
|
84
86
|
return
|
|
85
87
|
}
|
|
88
|
+
h.authz.Invalidate(c.Param("code"))
|
|
86
89
|
c.Status(http.StatusNoContent)
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -7,3 +7,6 @@ type Permission struct {
|
|
|
7
7
|
Code string `json:"code" gorm:"type:varchar(50);primaryKey"`
|
|
8
8
|
Description string `json:"description" gorm:"not null"`
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
// TableName — see Role.TableName; same schema, same reasoning.
|
|
12
|
+
func (Permission) TableName() string { return "role_svc.permissions" }
|
|
@@ -16,3 +16,7 @@ type Role struct {
|
|
|
16
16
|
CreatedAt time.Time `json:"created_at"`
|
|
17
17
|
UpdatedAt time.Time `json:"updated_at"`
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
// TableName pins this to role_svc rather than GORM's default inflection —
|
|
21
|
+
// every domain gets its own schema, see docs/architect/patterns.md.
|
|
22
|
+
func (Role) TableName() string { return "role_svc.roles" }
|
|
@@ -6,3 +6,6 @@ type RolePermission struct {
|
|
|
6
6
|
RoleCode string `json:"role_code" gorm:"primaryKey;type:varchar(20)"`
|
|
7
7
|
PermissionCode string `json:"permission_code" gorm:"primaryKey;type:varchar(50)"`
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
// TableName — see Role.TableName; same schema, same reasoning.
|
|
11
|
+
func (RolePermission) TableName() string { return "role_svc.role_permissions" }
|
|
@@ -4,6 +4,7 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
|
|
6
6
|
"{{goModule}}/internal/app/role/model"
|
|
7
|
+
"{{goModule}}/internal/shared/tx"
|
|
7
8
|
|
|
8
9
|
"gorm.io/gorm"
|
|
9
10
|
)
|
|
@@ -21,7 +22,7 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
21
22
|
// PermissionCodes returns the permission codes granted to roleCode.
|
|
22
23
|
func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
|
|
23
24
|
var codes []string
|
|
24
|
-
err := r.db.WithContext(ctx).
|
|
25
|
+
err := tx.From(ctx, r.db).WithContext(ctx).
|
|
25
26
|
Model(&model.RolePermission{}).
|
|
26
27
|
Where("role_code = ?", roleCode).
|
|
27
28
|
Pluck("permission_code", &codes).Error
|
|
@@ -30,28 +31,28 @@ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]st
|
|
|
30
31
|
|
|
31
32
|
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
|
|
32
33
|
var items []model.Role
|
|
33
|
-
err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
|
|
34
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
|
|
34
35
|
return items, err
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
|
|
38
39
|
var m model.Role
|
|
39
|
-
if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
|
|
40
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
|
|
40
41
|
return nil, err
|
|
41
42
|
}
|
|
42
43
|
return &m, nil
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
func (r *Repository) Create(ctx context.Context, m *model.Role) error {
|
|
46
|
-
return r.db.WithContext(ctx).Create(m).Error
|
|
47
|
+
return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// SetPermissions replaces roleCode's entire permission grant set in one
|
|
50
51
|
// transaction — simpler for a checkbox-style admin UI than diffing
|
|
51
52
|
// add/remove, and cheap at this scale (a handful of rows per role).
|
|
52
53
|
func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
|
|
53
|
-
return r.db.WithContext(ctx).Transaction(func(
|
|
54
|
-
if err :=
|
|
54
|
+
return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
|
|
55
|
+
if err := t.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
|
|
55
56
|
return err
|
|
56
57
|
}
|
|
57
58
|
if len(permissionCodes) == 0 {
|
|
@@ -61,17 +62,17 @@ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permis
|
|
|
61
62
|
for i, code := range permissionCodes {
|
|
62
63
|
grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
|
|
63
64
|
}
|
|
64
|
-
return
|
|
65
|
+
return t.Create(&grants).Error
|
|
65
66
|
})
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
func (r *Repository) Delete(ctx context.Context, code string) error {
|
|
69
|
-
return r.db.WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
|
|
70
|
+
return tx.From(ctx, r.db).WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
|
|
73
74
|
var items []model.Permission
|
|
74
|
-
err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
|
|
75
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
|
|
75
76
|
return items, err
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -80,7 +81,7 @@ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int)
|
|
|
80
81
|
// SetPermissions request.
|
|
81
82
|
func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
|
|
82
83
|
var codes []string
|
|
83
|
-
err := r.db.WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
|
|
84
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
|
|
84
85
|
return codes, err
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -89,7 +90,7 @@ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
|
|
|
89
90
|
// would leave no role holding permissionCode at all.
|
|
90
91
|
func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
|
|
91
92
|
var count int64
|
|
92
|
-
err := r.db.WithContext(ctx).Model(&model.RolePermission{}).
|
|
93
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.RolePermission{}).
|
|
93
94
|
Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
|
|
94
95
|
Count(&count).Error
|
|
95
96
|
return count, err
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
package role
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/role/model"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
"gorm.io/driver/postgres"
|
|
12
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// repositoryDBForTest expects the test database schema to come from the same
|
|
16
|
+
// versioned SQL migrations used in production. Unit tests stay database-free;
|
|
17
|
+
// CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
|
|
18
|
+
// instead of becoming a false-green skip. Same pattern as every generated
|
|
19
|
+
// module's repository_test.go.
|
|
20
|
+
func repositoryDBForTest(t *testing.T) *gorm.DB {
|
|
21
|
+
t.Helper()
|
|
22
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
23
|
+
if dsn == "" {
|
|
24
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
25
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
26
|
+
}
|
|
27
|
+
t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
31
|
+
if err != nil {
|
|
32
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
33
|
+
t.Fatalf("open required test database: %v", err)
|
|
34
|
+
}
|
|
35
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
sqlDB, err := db.DB()
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("get SQL database handle: %v", err)
|
|
41
|
+
}
|
|
42
|
+
if err := sqlDB.Ping(); err != nil {
|
|
43
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
44
|
+
t.Fatalf("ping required test database: %v", err)
|
|
45
|
+
}
|
|
46
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
tx := db.Begin()
|
|
50
|
+
if tx.Error != nil {
|
|
51
|
+
t.Fatalf("begin test transaction: %v", tx.Error)
|
|
52
|
+
}
|
|
53
|
+
t.Cleanup(func() {
|
|
54
|
+
if err := tx.Rollback().Error; err != nil {
|
|
55
|
+
t.Errorf("rollback test transaction: %v", err)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
return tx
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// testCode returns a role/permission code that fits both varchar(20) and
|
|
62
|
+
// varchar(50) columns and won't collide with the seeded 'staff'/'admin'
|
|
63
|
+
// roles or 'role:manage'/'user:manage-role'/'user:read' permissions.
|
|
64
|
+
func testCode(t *testing.T) string {
|
|
65
|
+
t.Helper()
|
|
66
|
+
return "t" + uuid.New().String()[:7]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// seedPermission inserts directly rather than through Repository, which has
|
|
70
|
+
// no create method for Permission on purpose (see model/permission.go.hbs:
|
|
71
|
+
// "seeded by migration, not admin-created"). Tests still need rows to grant.
|
|
72
|
+
func seedPermission(t *testing.T, tx *gorm.DB, code string) {
|
|
73
|
+
t.Helper()
|
|
74
|
+
if err := tx.WithContext(context.Background()).Create(&model.Permission{Code: code, Description: code}).Error; err != nil {
|
|
75
|
+
t.Fatalf("seed permission %s: %v", code, err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SetPermissions deletes-then-recreates the grant set inside one transaction
|
|
80
|
+
// (see repository.go.hbs). A stub can assert it was called with the right
|
|
81
|
+
// arguments, but not that the *table* ends up holding exactly the new set —
|
|
82
|
+
// that's a property of the real delete+insert, not of the call.
|
|
83
|
+
func TestRepository_SetPermissions_ReplacesEntireSet(t *testing.T) {
|
|
84
|
+
tx := repositoryDBForTest(t)
|
|
85
|
+
repo := NewRepository(tx)
|
|
86
|
+
ctx := context.Background()
|
|
87
|
+
|
|
88
|
+
roleCode := testCode(t)
|
|
89
|
+
permA, permB, permC := testCode(t), testCode(t), testCode(t)
|
|
90
|
+
if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
|
|
91
|
+
t.Fatalf("seed role: %v", err)
|
|
92
|
+
}
|
|
93
|
+
seedPermission(t, tx, permA)
|
|
94
|
+
seedPermission(t, tx, permB)
|
|
95
|
+
seedPermission(t, tx, permC)
|
|
96
|
+
|
|
97
|
+
if err := repo.SetPermissions(ctx, roleCode, []string{permA, permB}); err != nil {
|
|
98
|
+
t.Fatalf("grant a,b: %v", err)
|
|
99
|
+
}
|
|
100
|
+
if err := repo.SetPermissions(ctx, roleCode, []string{permC}); err != nil {
|
|
101
|
+
t.Fatalf("replace with c: %v", err)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
got, err := repo.PermissionCodes(ctx, roleCode)
|
|
105
|
+
if err != nil {
|
|
106
|
+
t.Fatalf("permission codes: %v", err)
|
|
107
|
+
}
|
|
108
|
+
if len(got) != 1 || got[0] != permC {
|
|
109
|
+
t.Fatalf("want only [%s] after replacing the grant set, got %v", permC, got)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// role_permissions references both roles and permissions with
|
|
114
|
+
// ON DELETE CASCADE (add_roles.up.sql) — deleting a role should never leave
|
|
115
|
+
// an orphaned grant row behind, and should never surface as an FK error the
|
|
116
|
+
// caller has to handle.
|
|
117
|
+
func TestRepository_Delete_CascadesRolePermissions(t *testing.T) {
|
|
118
|
+
tx := repositoryDBForTest(t)
|
|
119
|
+
repo := NewRepository(tx)
|
|
120
|
+
ctx := context.Background()
|
|
121
|
+
|
|
122
|
+
roleCode := testCode(t)
|
|
123
|
+
permCode := testCode(t)
|
|
124
|
+
if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
|
|
125
|
+
t.Fatalf("seed role: %v", err)
|
|
126
|
+
}
|
|
127
|
+
seedPermission(t, tx, permCode)
|
|
128
|
+
if err := repo.SetPermissions(ctx, roleCode, []string{permCode}); err != nil {
|
|
129
|
+
t.Fatalf("grant: %v", err)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if err := repo.Delete(ctx, roleCode); err != nil {
|
|
133
|
+
t.Fatalf("delete role: %v", err)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
got, err := repo.PermissionCodes(ctx, roleCode)
|
|
137
|
+
if err != nil {
|
|
138
|
+
t.Fatalf("permission codes after delete: %v", err)
|
|
139
|
+
}
|
|
140
|
+
if len(got) != 0 {
|
|
141
|
+
t.Fatalf("want role_permissions cascade-deleted along with the role, still have %v", got)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// CountRolesWithPermission backs the "would this leave no role holding the
|
|
146
|
+
// permission" guard in service.go — the exclusion in its WHERE clause is
|
|
147
|
+
// exactly the kind of off-by-one a stub returning a canned int can't catch.
|
|
148
|
+
func TestRepository_CountRolesWithPermission_ExcludesGivenRole(t *testing.T) {
|
|
149
|
+
tx := repositoryDBForTest(t)
|
|
150
|
+
repo := NewRepository(tx)
|
|
151
|
+
ctx := context.Background()
|
|
152
|
+
|
|
153
|
+
roleA, roleB := testCode(t), testCode(t)
|
|
154
|
+
permCode := testCode(t)
|
|
155
|
+
if err := repo.Create(ctx, &model.Role{Code: roleA, Name: roleA}); err != nil {
|
|
156
|
+
t.Fatalf("seed role a: %v", err)
|
|
157
|
+
}
|
|
158
|
+
if err := repo.Create(ctx, &model.Role{Code: roleB, Name: roleB}); err != nil {
|
|
159
|
+
t.Fatalf("seed role b: %v", err)
|
|
160
|
+
}
|
|
161
|
+
seedPermission(t, tx, permCode)
|
|
162
|
+
if err := repo.SetPermissions(ctx, roleA, []string{permCode}); err != nil {
|
|
163
|
+
t.Fatalf("grant to a: %v", err)
|
|
164
|
+
}
|
|
165
|
+
if err := repo.SetPermissions(ctx, roleB, []string{permCode}); err != nil {
|
|
166
|
+
t.Fatalf("grant to b: %v", err)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
count, err := repo.CountRolesWithPermission(ctx, permCode, roleA)
|
|
170
|
+
if err != nil {
|
|
171
|
+
t.Fatalf("count: %v", err)
|
|
172
|
+
}
|
|
173
|
+
if count != 1 {
|
|
174
|
+
t.Fatalf("want 1 (roleB only, roleA excluded), got %d", count)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -77,6 +77,13 @@ func (s *Service) List(ctx context.Context, limit, offset int) ([]roleListItem,
|
|
|
77
77
|
if err != nil {
|
|
78
78
|
return nil, apperror.NewInternal()
|
|
79
79
|
}
|
|
80
|
+
// ponytail: one query per role, not a join. A system has a handful of
|
|
81
|
+
// roles, so this is a handful of round trips on an admin screen nobody
|
|
82
|
+
// loads in a loop — the join costs more to read than it saves.
|
|
83
|
+
//
|
|
84
|
+
// Do not copy this shape into a domain whose list is user-sized. There the
|
|
85
|
+
// upgrade is one query with a join (or an IN over the page's ids) and a
|
|
86
|
+
// group-by in Go; here it would be ceremony.
|
|
80
87
|
out := make([]roleListItem, len(roles))
|
|
81
88
|
for i, r := range roles {
|
|
82
89
|
perms, err := s.repo.PermissionCodes(ctx, r.Code)
|
|
@@ -54,6 +54,25 @@ func (a *Authz) Require(perm string) gin.HandlerFunc {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Invalidate drops a role's cached permissions so the next request re-reads
|
|
58
|
+
// them. The write path (PATCH /roles/:code/permissions) runs in this same
|
|
59
|
+
// process, so letting the grant sit behind the TTL was a pure own-goal:
|
|
60
|
+
// granting a permission appeared to succeed and then did nothing for up to
|
|
61
|
+
// AUTHZ_CACHE_TTL_MIN, and logging in again didn't help either — the cache is
|
|
62
|
+
// keyed on the role, not the token — so the only way out was restarting the
|
|
63
|
+
// app.
|
|
64
|
+
//
|
|
65
|
+
// ponytail: same-process only. Other pods keep their copy until the TTL
|
|
66
|
+
// expires; that's the documented ceiling, and Redis pub/sub is the upgrade if
|
|
67
|
+
// instant cluster-wide invalidation ever matters.
|
|
68
|
+
func (a *Authz) Invalidate(roleCodes ...string) {
|
|
69
|
+
a.mu.Lock()
|
|
70
|
+
defer a.mu.Unlock()
|
|
71
|
+
for _, code := range roleCodes {
|
|
72
|
+
delete(a.cache, code)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
// ponytail: process-local cache, a handful of roles x AUTHZ_CACHE_TTL_MIN —
|
|
58
77
|
// a role's permission grants take effect within the TTL, no pub/sub needed.
|
|
59
78
|
// Move to Redis only if instant cross-pod invalidation turns out to matter.
|
|
@@ -57,7 +57,7 @@ func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
|
|
|
57
57
|
r := gin.New()
|
|
58
58
|
// Require only records a *apperror.AppError via c.Error — Error(true) is
|
|
59
59
|
// what actually translates that into the response status code, same as
|
|
60
|
-
// the real router (see cmd/api/
|
|
60
|
+
// the real router (see cmd/api/wiring.go).
|
|
61
61
|
r.Use(Error(true))
|
|
62
62
|
r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
|
|
63
63
|
c.Status(http.StatusOK)
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
-- raise a clear, actionable error instead of a raw FK-violation.
|
|
4
4
|
DO $$
|
|
5
5
|
BEGIN
|
|
6
|
-
IF EXISTS (SELECT 1 FROM users WHERE role NOT IN ('staff', 'admin')) THEN
|
|
6
|
+
IF EXISTS (SELECT 1 FROM user_svc.users WHERE role NOT IN ('staff', 'admin')) THEN
|
|
7
7
|
RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
|
|
8
8
|
END IF;
|
|
9
9
|
END $$;
|
|
10
10
|
|
|
11
|
-
ALTER TABLE users DROP COLUMN role;
|
|
11
|
+
ALTER TABLE user_svc.users DROP COLUMN role;
|
|
12
12
|
|
|
13
|
-
DROP TABLE role_permissions;
|
|
14
|
-
DROP TABLE permissions;
|
|
15
|
-
DROP TABLE roles;
|
|
13
|
+
DROP TABLE role_svc.role_permissions;
|
|
14
|
+
DROP TABLE role_svc.permissions;
|
|
15
|
+
DROP TABLE role_svc.roles;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
CREATE
|
|
1
|
+
CREATE SCHEMA IF NOT EXISTS role_svc;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE role_svc.roles (
|
|
2
4
|
code VARCHAR(20) PRIMARY KEY,
|
|
3
5
|
name TEXT NOT NULL,
|
|
4
6
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
@@ -6,30 +8,34 @@ CREATE TABLE roles (
|
|
|
6
8
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
7
9
|
);
|
|
8
10
|
|
|
9
|
-
CREATE TABLE permissions (
|
|
11
|
+
CREATE TABLE role_svc.permissions (
|
|
10
12
|
code VARCHAR(50) PRIMARY KEY,
|
|
11
13
|
description TEXT NOT NULL
|
|
12
14
|
);
|
|
13
15
|
|
|
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,
|
|
16
|
+
CREATE TABLE role_svc.role_permissions (
|
|
17
|
+
role_code VARCHAR(20) NOT NULL REFERENCES role_svc.roles(code) ON DELETE CASCADE,
|
|
18
|
+
permission_code VARCHAR(50) NOT NULL REFERENCES role_svc.permissions(code) ON DELETE CASCADE,
|
|
17
19
|
PRIMARY KEY (role_code, permission_code)
|
|
18
20
|
);
|
|
19
21
|
|
|
20
|
-
INSERT INTO roles (code, name, is_system) VALUES
|
|
22
|
+
INSERT INTO role_svc.roles (code, name, is_system) VALUES
|
|
21
23
|
('staff', 'Staff', TRUE),
|
|
22
24
|
('admin', 'Admin', TRUE);
|
|
23
25
|
|
|
24
|
-
INSERT INTO permissions (code, description) VALUES
|
|
26
|
+
INSERT INTO role_svc.permissions (code, description) VALUES
|
|
25
27
|
('role:manage', 'Create roles and change their permissions'),
|
|
26
28
|
('user:manage-role', 'Change a user''s assigned role'),
|
|
27
29
|
('user:read', 'List and view other users');
|
|
28
30
|
|
|
29
31
|
-- admin gets every permission that exists, staff gets none — no behavior
|
|
30
32
|
-- 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
|
+
INSERT INTO role_svc.role_permissions (role_code, permission_code)
|
|
34
|
+
SELECT 'admin', code FROM role_svc.permissions;
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
-- cross-schema FK: role_svc's migration is the one place allowed to alter
|
|
37
|
+
-- user_svc's table, because this feature's whole point is "give users a
|
|
38
|
+
-- role" — see docs/architect/patterns.md's FK rules for why a plain FK, not
|
|
39
|
+
-- an app-level existence check, is the right way to enforce this.
|
|
40
|
+
ALTER TABLE user_svc.users
|
|
41
|
+
ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES role_svc.roles(code);
|
|
@@ -2,63 +2,69 @@ package main
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"errors"
|
|
6
5
|
"log/slog"
|
|
7
6
|
"os"
|
|
8
7
|
"os/signal"
|
|
9
8
|
"syscall"
|
|
10
9
|
"time"
|
|
11
10
|
|
|
11
|
+
{{#if riverQueue}}
|
|
12
|
+
"{{goModule}}/internal/platform/database"
|
|
13
|
+
{{/if}}
|
|
12
14
|
"{{goModule}}/internal/platform/mail"
|
|
13
15
|
"{{goModule}}/internal/platform/queue"
|
|
14
16
|
"{{goModule}}/internal/shared/config"
|
|
15
17
|
// go-scaffold:imports
|
|
16
18
|
)
|
|
17
19
|
|
|
20
|
+
// workerConcurrency is how many jobs run at once in this process. Scale out
|
|
21
|
+
// by running more worker processes before raising this.
|
|
22
|
+
const workerConcurrency = 10
|
|
23
|
+
|
|
18
24
|
func main() {
|
|
19
25
|
cfg := config.Load()
|
|
20
26
|
|
|
21
27
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
|
|
22
28
|
slog.SetDefault(logger)
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
{{#if riverQueue}}
|
|
31
|
+
// The queue lives in the same database as your data — the worker needs a
|
|
32
|
+
// connection to it, not to a separate broker.
|
|
33
|
+
db, err := database.Open(cfg)
|
|
25
34
|
if err != nil {
|
|
26
|
-
logger.Error("
|
|
35
|
+
logger.Error("open db", "error", err)
|
|
27
36
|
os.Exit(1)
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
q, err := queue.NewRiverWorker(db, workerConcurrency)
|
|
40
|
+
{{else}}
|
|
41
|
+
q, err := queue.NewAsynqWorker(cfg.RedisURL, workerConcurrency)
|
|
42
|
+
{{/if}}
|
|
43
|
+
if err != nil {
|
|
44
|
+
logger.Error("start queue", "error", err)
|
|
45
|
+
os.Exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
q.Handle(mail.KindSendEmail, mail.Handle(mail.Open(cfg)))
|
|
31
49
|
// go-scaffold:queue-handlers
|
|
32
50
|
|
|
33
51
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
34
52
|
defer stop()
|
|
35
53
|
|
|
36
|
-
|
|
37
|
-
logger.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}()
|
|
54
|
+
if err := q.Start(ctx); err != nil {
|
|
55
|
+
logger.Error("worker start", "error", err)
|
|
56
|
+
os.Exit(1)
|
|
57
|
+
}
|
|
58
|
+
logger.Info("worker started", "concurrency", workerConcurrency)
|
|
43
59
|
|
|
44
60
|
<-ctx.Done()
|
|
45
61
|
logger.Info("shutting down worker")
|
|
46
62
|
|
|
63
|
+
// Give in-flight jobs a bounded window to finish before exiting.
|
|
47
64
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
48
65
|
defer cancel()
|
|
49
|
-
|
|
50
|
-
|
|
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")
|
|
66
|
+
if err := q.Stop(shutdownCtx); err != nil {
|
|
67
|
+
logger.Warn("worker shutdown timed out", "error", err)
|
|
62
68
|
}
|
|
63
69
|
logger.Info("worker stopped")
|
|
64
70
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package mail
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"fmt"
|
|
5
6
|
"log/slog"
|
|
6
7
|
"net/smtp"
|
|
@@ -46,3 +47,23 @@ func (c *Client) Send(to, subject, body string) error {
|
|
|
46
47
|
}
|
|
47
48
|
return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
// SyncClient sends in the caller's goroutine, satisfying the same mailer
|
|
52
|
+
// interface AsyncClient does. It exists so `add auth` can stand on its own:
|
|
53
|
+
// a project that hasn't asked for a queue shouldn't be made to run a second
|
|
54
|
+
// binary just to send a verification email.
|
|
55
|
+
//
|
|
56
|
+
// The trade is real — /auth/register and /auth/forgot-password block until
|
|
57
|
+
// SMTP answers, where the async client returns as soon as the job is written.
|
|
58
|
+
// Add a worker later and the wiring in cmd/api/wiring.go switches to
|
|
59
|
+
// AsyncClient; nothing in the user service changes.
|
|
60
|
+
type SyncClient struct{ c *Client }
|
|
61
|
+
|
|
62
|
+
func NewSyncClient(c *Client) *SyncClient { return &SyncClient{c: c} }
|
|
63
|
+
|
|
64
|
+
// Send ignores ctx: there is no queue here to join a transaction, so the mail
|
|
65
|
+
// goes out whether or not the caller's transaction ends up committing. That is
|
|
66
|
+
// the guarantee the Postgres-backed queue exists to give you.
|
|
67
|
+
func (c *SyncClient) Send(_ context.Context, to, subject, body string) error {
|
|
68
|
+
return c.c.Send(to, subject, body)
|
|
69
|
+
}
|