@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.
- package/README.md +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- 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/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/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- 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 +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- 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 +13 -0
- 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/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- 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
|
+
}
|