@nakedev/go-scaffold 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,159 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "time"
6
+
7
+ "github.com/google/uuid"
8
+ "github.com/redis/go-redis/v9"
9
+ )
10
+
11
+ const (
12
+ refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
13
+ refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
14
+ refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL (reuse-detection tombstone)
15
+ pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
16
+ emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
17
+ )
18
+
19
+ // tokenStore is what Service needs from Redis for refresh tokens — declared
20
+ // consumer-side so it can be faked in tests without a real Redis.
21
+ type tokenStore interface {
22
+ SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
23
+ GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
24
+ DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
25
+ RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
26
+ MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
27
+ IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
28
+ SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
29
+ ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
30
+ SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
31
+ ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
32
+ }
33
+
34
+ type redisTokenStore struct {
35
+ rdb *redis.Client
36
+ }
37
+
38
+ func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
39
+ return &redisTokenStore{rdb: rdb}
40
+ }
41
+
42
+ func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
43
+ pipe := s.rdb.TxPipeline()
44
+ pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
45
+ pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
46
+ _, err := pipe.Exec(ctx)
47
+ return err
48
+ }
49
+
50
+ func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
51
+ raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
52
+ if err == redis.Nil {
53
+ return uuid.Nil, false, nil
54
+ }
55
+ if err != nil {
56
+ return uuid.Nil, false, err
57
+ }
58
+ id, err := uuid.Parse(raw)
59
+ if err != nil {
60
+ return uuid.Nil, false, err
61
+ }
62
+ return id, true, nil
63
+ }
64
+
65
+ func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
66
+ pipe := s.rdb.TxPipeline()
67
+ pipe.Del(ctx, refreshKeyPrefix+tokenHash)
68
+ pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
69
+ _, err := pipe.Exec(ctx)
70
+ return err
71
+ }
72
+
73
+ // RevokeAllRefreshTokens walks the per-user session set and deletes every
74
+ // active refresh token for that user — used when reuse of an already-rotated
75
+ // token is detected (see Service.Refresh): that means the raw token leaked,
76
+ // so every session, not just the replayed one, is treated as compromised.
77
+ //
78
+ // ponytail: the per-user set has no per-member TTL cleanup of its own — a
79
+ // member outlives its key's TTL as a stale entry until the next revoke or
80
+ // rotation touches it. Self-heals over time; revisit if a single user's
81
+ // session count grows large enough to matter.
82
+ func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
83
+ setKey := refreshUserKeyPrefix + userID.String()
84
+ hashes, err := s.rdb.SMembers(ctx, setKey).Result()
85
+ if err != nil {
86
+ return err
87
+ }
88
+ if len(hashes) == 0 {
89
+ return nil
90
+ }
91
+ pipe := s.rdb.TxPipeline()
92
+ for _, h := range hashes {
93
+ pipe.Del(ctx, refreshKeyPrefix+h)
94
+ }
95
+ pipe.Del(ctx, setKey)
96
+ _, err = pipe.Exec(ctx)
97
+ return err
98
+ }
99
+
100
+ func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
101
+ return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
102
+ }
103
+
104
+ func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
105
+ raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
106
+ if err == redis.Nil {
107
+ return uuid.Nil, false, nil
108
+ }
109
+ if err != nil {
110
+ return uuid.Nil, false, err
111
+ }
112
+ id, err := uuid.Parse(raw)
113
+ if err != nil {
114
+ return uuid.Nil, false, err
115
+ }
116
+ return id, true, nil
117
+ }
118
+
119
+ func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
120
+ return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
121
+ }
122
+
123
+ // ConsumePasswordResetToken is one-time-use by construction: GETDEL is
124
+ // atomic, so a token can't be raced into being consumed twice.
125
+ func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
126
+ raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
127
+ if err == redis.Nil {
128
+ return uuid.Nil, false, nil
129
+ }
130
+ if err != nil {
131
+ return uuid.Nil, false, err
132
+ }
133
+ id, err := uuid.Parse(raw)
134
+ if err != nil {
135
+ return uuid.Nil, false, err
136
+ }
137
+ return id, true, nil
138
+ }
139
+
140
+ func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
141
+ return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
142
+ }
143
+
144
+ // ConsumeEmailVerifyToken is one-time-use by construction, same as
145
+ // ConsumePasswordResetToken — GETDEL is atomic.
146
+ func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
147
+ raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
148
+ if err == redis.Nil {
149
+ return uuid.Nil, false, nil
150
+ }
151
+ if err != nil {
152
+ return uuid.Nil, false, err
153
+ }
154
+ id, err := uuid.Parse(raw)
155
+ if err != nil {
156
+ return uuid.Nil, false, err
157
+ }
158
+ return id, true, nil
159
+ }
@@ -0,0 +1,64 @@
1
+ package middleware
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+
7
+ "{{goModule}}/internal/shared/apperror"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/golang-jwt/jwt/v5"
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ const UserIDKey = "user_id"
15
+
16
+ // go-scaffold:middleware-auth-keys
17
+
18
+ // accessClaims mirrors internal/app/user's own claims shape — duplicated
19
+ // rather than imported, since shared/ can never import a domain package.
20
+ // The two are kept in sync by convention: sub = user id, typ = "access",
21
+ // nothing else is load-bearing here.
22
+ type accessClaims struct {
23
+ Typ string `json:"typ"`
24
+ // go-scaffold:middleware-auth-claims
25
+ jwt.RegisteredClaims
26
+ }
27
+
28
+ // RequireAuth validates a Bearer access token and puts the caller's user id
29
+ // in context. Rejects a refresh token presented as an access token via the
30
+ // typ claim — the two are structurally identical JWTs otherwise.
31
+ func RequireAuth(secret string) gin.HandlerFunc {
32
+ return func(c *gin.Context) {
33
+ header := c.GetHeader("Authorization")
34
+ raw, ok := strings.CutPrefix(header, "Bearer ")
35
+ if !ok || raw == "" {
36
+ unauthorized(c)
37
+ return
38
+ }
39
+
40
+ var claims accessClaims
41
+ _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
42
+ return []byte(secret), nil
43
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
44
+ if err != nil || claims.Typ != "access" {
45
+ unauthorized(c)
46
+ return
47
+ }
48
+
49
+ userID, err := uuid.Parse(claims.Subject)
50
+ if err != nil {
51
+ unauthorized(c)
52
+ return
53
+ }
54
+
55
+ c.Set(UserIDKey, userID)
56
+ // go-scaffold:middleware-auth-context
57
+ c.Next()
58
+ }
59
+ }
60
+
61
+ func unauthorized(c *gin.Context) {
62
+ c.Error(apperror.New(http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid access token"))
63
+ c.Abort()
64
+ }
@@ -0,0 +1,44 @@
1
+ package middleware
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "{{goModule}}/internal/shared/apperror"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/redis/go-redis/v9"
11
+ )
12
+
13
+ // RateLimit throttles requests per client IP to `limit` within `window`,
14
+ // keyed by `name` (a per-route label so /auth/login and /auth/register don't
15
+ // share a budget) — a fixed-window counter via Redis INCR+EXPIRE.
16
+ //
17
+ // ponytail: fixed-window, not sliding — simple and correct, the tradeoff is
18
+ // a client can burst up to ~2x limit right at a window boundary (limit
19
+ // requests just before it resets, then limit more right after). Good enough
20
+ // for throttling credential-stuffing/spam, not a hard quota; move to a
21
+ // sliding-window log if that boundary burst ever actually matters.
22
+ func RateLimit(rdb *redis.Client, name string, limit int, window time.Duration) gin.HandlerFunc {
23
+ return func(c *gin.Context) {
24
+ key := "ratelimit:" + name + ":" + c.ClientIP()
25
+ ctx := c.Request.Context()
26
+
27
+ count, err := rdb.Incr(ctx, key).Result()
28
+ if err != nil {
29
+ // fail open: a Redis blip shouldn't take down login/register
30
+ // entirely — the endpoint's own logic is still the real guard.
31
+ c.Next()
32
+ return
33
+ }
34
+ if count == 1 {
35
+ rdb.Expire(ctx, key, window)
36
+ }
37
+ if count > int64(limit) {
38
+ c.Error(apperror.New(http.StatusTooManyRequests, "RATE_LIMITED", "too many requests, try again later"))
39
+ c.Abort()
40
+ return
41
+ }
42
+ c.Next()
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS identities;
@@ -0,0 +1,11 @@
1
+ CREATE TABLE identities (
2
+ id UUID PRIMARY KEY,
3
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
+ provider VARCHAR(20) NOT NULL,
5
+ password_hash TEXT,
6
+ provider_uid TEXT,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9
+ UNIQUE (user_id, provider),
10
+ UNIQUE (provider, provider_uid)
11
+ );
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS users;
@@ -0,0 +1,9 @@
1
+ CREATE TABLE users (
2
+ id UUID PRIMARY KEY,
3
+ email VARCHAR(255) NOT NULL UNIQUE,
4
+ name VARCHAR(255) NOT NULL DEFAULT '',
5
+ avatar_url TEXT NOT NULL DEFAULT '',
6
+ email_verified BOOLEAN NOT NULL DEFAULT false,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
+ );
@@ -0,0 +1,24 @@
1
+ get:
2
+ summary: List permissions
3
+ description: The catalog of every permission code roles can be granted — permissions themselves are seeded by migrations, not created via the API.
4
+ operationId: listPermissions
5
+ tags: [rbac]
6
+ security: [{ bearerAuth: [] }]
7
+ parameters:
8
+ - $ref: '../common/parameters.yaml#/Limit'
9
+ - $ref: '../common/parameters.yaml#/Offset'
10
+ responses:
11
+ "200":
12
+ description: paginated list
13
+ content:
14
+ application/json:
15
+ schema:
16
+ allOf:
17
+ - $ref: '../common/schemas.yaml#/PageEnvelope'
18
+ - type: object
19
+ properties:
20
+ data:
21
+ type: array
22
+ items: { $ref: './schemas.yaml#/PermissionResponse' }
23
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
24
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
@@ -0,0 +1,31 @@
1
+ parameters:
2
+ - name: code
3
+ in: path
4
+ required: true
5
+ schema: { type: string }
6
+ patch:
7
+ summary: Replace a role's permissions
8
+ description: >-
9
+ Rejects unknown permission codes (422), and rejects removing role:manage
10
+ from the only role that still has it, to avoid locking every admin out
11
+ of role management (409).
12
+ operationId: setRolePermissions
13
+ tags: [rbac]
14
+ security: [{ bearerAuth: [] }]
15
+ requestBody:
16
+ required: true
17
+ content:
18
+ application/json:
19
+ schema: { $ref: './schemas.yaml#/SetPermissionsInput' }
20
+ responses:
21
+ "200":
22
+ description: updated
23
+ content:
24
+ application/json:
25
+ schema: { $ref: './schemas.yaml#/RoleResponse' }
26
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
27
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
28
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
29
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
30
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
31
+ "422": { $ref: '../common/responses.yaml#/UnprocessableEntityError' }
@@ -0,0 +1,17 @@
1
+ parameters:
2
+ - name: code
3
+ in: path
4
+ required: true
5
+ schema: { type: string }
6
+ delete:
7
+ summary: Delete a role
8
+ description: System roles (staff/admin) cannot be deleted, nor can a role still assigned to users.
9
+ operationId: deleteRole
10
+ tags: [rbac]
11
+ security: [{ bearerAuth: [] }]
12
+ responses:
13
+ "204": { description: deleted }
14
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
15
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
16
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
17
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
@@ -0,0 +1,43 @@
1
+ get:
2
+ summary: List roles
3
+ operationId: listRoles
4
+ tags: [rbac]
5
+ security: [{ bearerAuth: [] }]
6
+ parameters:
7
+ - $ref: '../common/parameters.yaml#/Limit'
8
+ - $ref: '../common/parameters.yaml#/Offset'
9
+ responses:
10
+ "200":
11
+ description: paginated list
12
+ content:
13
+ application/json:
14
+ schema:
15
+ allOf:
16
+ - $ref: '../common/schemas.yaml#/PageEnvelope'
17
+ - type: object
18
+ properties:
19
+ data:
20
+ type: array
21
+ items: { $ref: './schemas.yaml#/RoleResponse' }
22
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
23
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
24
+ post:
25
+ summary: Create a role
26
+ operationId: createRole
27
+ tags: [rbac]
28
+ security: [{ bearerAuth: [] }]
29
+ requestBody:
30
+ required: true
31
+ content:
32
+ application/json:
33
+ schema: { $ref: './schemas.yaml#/RoleCreateInput' }
34
+ responses:
35
+ "201":
36
+ description: created
37
+ content:
38
+ application/json:
39
+ schema: { $ref: './schemas.yaml#/RoleResponse' }
40
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
41
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
42
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
43
+ "409": { $ref: '../common/responses.yaml#/ConflictError' }
@@ -0,0 +1,37 @@
1
+ RoleCreateInput:
2
+ type: object
3
+ required: [code, name]
4
+ properties:
5
+ code: { type: string, maxLength: 20, description: "lowercase, digits, underscores only" }
6
+ name: { type: string }
7
+
8
+ SetPermissionsInput:
9
+ type: object
10
+ properties:
11
+ permission_codes:
12
+ type: array
13
+ items: { type: string }
14
+ description: "full replacement of the role's permission set — an empty list revokes every permission"
15
+
16
+ RoleResponse:
17
+ type: object
18
+ properties:
19
+ code: { type: string }
20
+ name: { type: string }
21
+ is_system: { type: boolean, description: "staff/admin — seeded, cannot be deleted" }
22
+ permissions:
23
+ type: array
24
+ items: { type: string }
25
+ created_at: { type: string, format: date-time }
26
+
27
+ PermissionResponse:
28
+ type: object
29
+ properties:
30
+ code: { type: string }
31
+ description: { type: string }
32
+
33
+ SetRoleInput:
34
+ type: object
35
+ required: [role]
36
+ properties:
37
+ role: { type: string, description: "must be an existing role code" }
@@ -0,0 +1,23 @@
1
+ parameters:
2
+ - $ref: '../common/parameters.yaml#/IdParam'
3
+ patch:
4
+ summary: Change a user's role
5
+ operationId: setUserRole
6
+ tags: [rbac]
7
+ security: [{ bearerAuth: [] }]
8
+ requestBody:
9
+ required: true
10
+ content:
11
+ application/json:
12
+ schema: { $ref: './schemas.yaml#/SetRoleInput' }
13
+ responses:
14
+ "200":
15
+ description: updated
16
+ content:
17
+ application/json:
18
+ schema: { $ref: '../auth/schemas.yaml#/MeResponse' }
19
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
20
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
21
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
22
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
23
+ "422": { $ref: '../common/responses.yaml#/UnprocessableEntityError' }
@@ -0,0 +1,16 @@
1
+ parameters:
2
+ - $ref: '../common/parameters.yaml#/IdParam'
3
+ get:
4
+ summary: Get a user (admin)
5
+ operationId: adminGetUser
6
+ tags: [rbac]
7
+ security: [{ bearerAuth: [] }]
8
+ responses:
9
+ "200":
10
+ description: ok
11
+ content:
12
+ application/json:
13
+ schema: { $ref: '../auth/schemas.yaml#/MeResponse' }
14
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
15
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
16
+ "404": { $ref: '../common/responses.yaml#/NotFoundError' }
@@ -0,0 +1,23 @@
1
+ get:
2
+ summary: List users (admin)
3
+ operationId: adminListUsers
4
+ tags: [rbac]
5
+ security: [{ bearerAuth: [] }]
6
+ parameters:
7
+ - $ref: '../common/parameters.yaml#/Limit'
8
+ - $ref: '../common/parameters.yaml#/Offset'
9
+ responses:
10
+ "200":
11
+ description: paginated list
12
+ content:
13
+ application/json:
14
+ schema:
15
+ allOf:
16
+ - $ref: '../common/schemas.yaml#/PageEnvelope'
17
+ - type: object
18
+ properties:
19
+ data:
20
+ type: array
21
+ items: { $ref: '../auth/schemas.yaml#/MeResponse' }
22
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
23
+ "403": { $ref: '../common/responses.yaml#/ForbiddenError' }
@@ -0,0 +1,40 @@
1
+ package role
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/model"
7
+ )
8
+
9
+ // response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
10
+ type response struct {
11
+ Code string `json:"code"`
12
+ Name string `json:"name"`
13
+ IsSystem bool `json:"is_system"`
14
+ Permissions []string `json:"permissions"`
15
+ CreatedAt time.Time `json:"created_at"`
16
+ }
17
+
18
+ func toResponse(m *model.Role, perms []string) response {
19
+ return response{Code: m.Code, Name: m.Name, IsSystem: m.IsSystem, Permissions: perms, CreatedAt: m.CreatedAt}
20
+ }
21
+
22
+ type CreateInput struct {
23
+ Code string `json:"code" binding:"required,max=20"`
24
+ Name string `json:"name" binding:"required"`
25
+ }
26
+
27
+ // SetPermissionsInput.PermissionCodes is deliberately not `binding:"required"`
28
+ // — an empty list is valid (it revokes every permission the role had).
29
+ type SetPermissionsInput struct {
30
+ PermissionCodes []string `json:"permission_codes"`
31
+ }
32
+
33
+ type permissionResponse struct {
34
+ Code string `json:"code"`
35
+ Description string `json:"description"`
36
+ }
37
+
38
+ func toPermissionResponse(m *model.Permission) permissionResponse {
39
+ return permissionResponse{Code: m.Code, Description: m.Description}
40
+ }
@@ -0,0 +1,39 @@
1
+ package role
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/apperror"
7
+ )
8
+
9
+ // error catalog specific to role — codes carry more meaning than the generic apperror ones
10
+ // functions, not vars: the error middleware writes RequestID onto the returned pointer directly
11
+ // (a shared instance would race across concurrent requests).
12
+
13
+ func errNotFound() *apperror.AppError {
14
+ return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
15
+ }
16
+
17
+ func errConflict() *apperror.AppError {
18
+ return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role code already exists")
19
+ }
20
+
21
+ func errHasReferences() *apperror.AppError {
22
+ return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
23
+ }
24
+
25
+ func errInvalidCode() *apperror.AppError {
26
+ return apperror.New(http.StatusUnprocessableEntity, "ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores")
27
+ }
28
+
29
+ func errSystemRole() *apperror.AppError {
30
+ return apperror.New(http.StatusConflict, "ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted")
31
+ }
32
+
33
+ func errUnknownPermission() *apperror.AppError {
34
+ return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
35
+ }
36
+
37
+ func errLastRoleManager() *apperror.AppError {
38
+ return apperror.New(http.StatusConflict, "ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first")
39
+ }