@nakedev/go-scaffold 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
--
|
|
2
|
-
--
|
|
1
|
+
-- Durable recovery tokens for both refresh-store choices. Refresh rotation may
|
|
2
|
+
-- use Redis, but password reset and email verification share this DB contract.
|
|
3
|
+
-- Only the SHA-256 hash is stored; see adapters/outbound/postgres/model.go for what `kind`
|
|
3
4
|
-- means and why the rotation tombstone is a separate row.
|
|
4
5
|
CREATE TABLE user_svc.auth_tokens (
|
|
5
6
|
token_hash TEXT PRIMARY KEY,
|
|
6
7
|
user_id UUID NOT NULL,
|
|
7
8
|
kind VARCHAR(20) NOT NULL,
|
|
8
9
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
10
|
+
absolute_expires_at TIMESTAMPTZ,
|
|
11
|
+
provider VARCHAR(20) NOT NULL DEFAULT '',
|
|
12
|
+
code_challenge TEXT NOT NULL DEFAULT '',
|
|
13
|
+
nonce TEXT NOT NULL DEFAULT '',
|
|
9
14
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
10
15
|
);
|
|
11
16
|
|
|
12
|
-
-- Named to match model.AuthToken's own `index:` tags exactly
|
|
13
|
-
--
|
|
14
|
-
-- migration named differently is what stops the app booting.
|
|
17
|
+
-- Named to match model.AuthToken's own `index:` tags exactly, so the
|
|
18
|
+
-- development bootstrap and production migration describe the same indexes.
|
|
15
19
|
CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
|
|
16
20
|
CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
|
|
21
|
+
CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
|
|
@@ -10,6 +10,6 @@ CREATE TABLE user_svc.identities (
|
|
|
10
10
|
|
|
11
11
|
-- Named unique indexes matching model.Identity's own
|
|
12
12
|
-- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
|
|
13
|
-
-- anonymous UNIQUE constraint breaks
|
|
13
|
+
-- anonymous UNIQUE constraint breaks schema parity.
|
|
14
14
|
CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
|
|
15
15
|
CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- Failed-attempt counter per account. Keyed on a hash of the submitted email
|
|
2
2
|
-- so it works for addresses that have no account (otherwise throttling itself
|
|
3
3
|
-- leaks which addresses are registered) and so this table is not a user list.
|
|
4
|
-
-- See model
|
|
4
|
+
-- See adapters/outbound/postgres/model.go for why this exists separately from the rate limiter.
|
|
5
5
|
CREATE TABLE user_svc.login_throttle (
|
|
6
6
|
email_hash TEXT PRIMARY KEY,
|
|
7
7
|
failures INTEGER NOT NULL DEFAULT 0,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- MFA state is durable in Postgres for both refresh-token store choices. TOTP
|
|
2
|
+
-- secrets are encrypted by the application; challenges and recovery codes are
|
|
3
|
+
-- stored only as hashes and consumed atomically.
|
|
4
|
+
CREATE TABLE user_svc.mfa_enrollments (
|
|
5
|
+
user_id UUID PRIMARY KEY REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
6
|
+
encrypted_secret TEXT NOT NULL,
|
|
7
|
+
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
8
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
9
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE user_svc.mfa_challenges (
|
|
13
|
+
challenge_hash TEXT PRIMARY KEY,
|
|
14
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
15
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
16
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE INDEX idx_mfa_challenges_user ON user_svc.mfa_challenges (user_id);
|
|
20
|
+
CREATE INDEX idx_mfa_challenges_expires_at ON user_svc.mfa_challenges (expires_at);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE user_svc.mfa_recovery_codes (
|
|
23
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
24
|
+
code_hash TEXT NOT NULL,
|
|
25
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
26
|
+
PRIMARY KEY (user_id, code_hash)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX idx_mfa_recovery_codes_user ON user_svc.mfa_recovery_codes (user_id);
|
|
@@ -6,14 +6,15 @@ CREATE TABLE user_svc.users (
|
|
|
6
6
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
7
7
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
8
8
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
9
|
+
role VARCHAR(20) NOT NULL DEFAULT 'staff',
|
|
9
10
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
10
11
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
11
12
|
);
|
|
12
13
|
|
|
13
14
|
-- A unique INDEX, not an inline UNIQUE constraint, and named to match the
|
|
14
|
-
-- `uniqueIndex:idx_users_email` tag on model
|
|
15
|
-
-- inline UNIQUE "users_email_key";
|
|
16
|
-
--
|
|
15
|
+
-- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
|
|
16
|
+
-- inline UNIQUE "users_email_key"; the development bootstrap then sees
|
|
17
|
+
-- uniqueness it didn't create and tries to drop it under its own
|
|
17
18
|
-- name, which fails the boot with
|
|
18
19
|
-- constraint "uni_users_email" of relation "users" does not exist
|
|
19
20
|
-- for anyone who ran `make migrate-up` first — which `add rbac` tells you to.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Package httpadapter is role's inbound HTTP boundary. It owns route,
|
|
2
|
+
// authentication middleware, request binding, response DTOs, and translation
|
|
3
|
+
// from application errors to the API error envelope.
|
|
4
|
+
package httpadapter
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"errors"
|
|
8
|
+
"net/http"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/role/application"
|
|
11
|
+
"{{goModule}}/internal/app/role/domain"
|
|
12
|
+
"{{goModule}}/internal/shared/apperror"
|
|
13
|
+
"{{goModule}}/internal/shared/httpx"
|
|
14
|
+
"{{goModule}}/internal/shared/middleware"
|
|
15
|
+
"{{goModule}}/internal/shared/pagination"
|
|
16
|
+
|
|
17
|
+
"github.com/gin-gonic/gin"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type Handler struct {
|
|
21
|
+
svc application.ServicePort
|
|
22
|
+
jwtSecret string
|
|
23
|
+
authz *middleware.Authz
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func NewHandler(svc application.ServicePort, jwtSecret string, authz *middleware.Authz) *Handler {
|
|
27
|
+
return &Handler{svc: svc, jwtSecret: jwtSecret, authz: authz}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (h *Handler) Register(rg gin.IRouter) {
|
|
31
|
+
roles := rg.Group("/roles", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
|
|
32
|
+
roles.GET("", h.list)
|
|
33
|
+
roles.POST("", h.create)
|
|
34
|
+
roles.PATCH("/:code/permissions", h.setPermissions)
|
|
35
|
+
roles.DELETE("/:code", h.delete)
|
|
36
|
+
|
|
37
|
+
perms := rg.Group("/permissions", middleware.RequireAuth(h.jwtSecret), h.authz.Require(application.PermRoleManage))
|
|
38
|
+
perms.GET("", h.listPermissions)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (h *Handler) list(c *gin.Context) {
|
|
42
|
+
p := pagination.Parse(c)
|
|
43
|
+
items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
|
|
44
|
+
if err != nil {
|
|
45
|
+
c.Error(toHTTPError(err))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
out := make([]application.RoleResponse, len(items))
|
|
49
|
+
for i := range items {
|
|
50
|
+
out[i] = application.ToRoleResponse(items[i])
|
|
51
|
+
}
|
|
52
|
+
c.JSON(http.StatusOK, p.Response(out))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (h *Handler) create(c *gin.Context) {
|
|
56
|
+
var in application.CreateInput
|
|
57
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
58
|
+
c.Error(httpx.BindErr(err))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
item, err := h.svc.Create(c.Request.Context(), in)
|
|
62
|
+
if err != nil {
|
|
63
|
+
c.Error(toHTTPError(err))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (h *Handler) setPermissions(c *gin.Context) {
|
|
70
|
+
var in application.SetPermissionsInput
|
|
71
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
72
|
+
c.Error(httpx.BindErr(err))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if in.PermissionCodes == nil {
|
|
76
|
+
c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
|
|
80
|
+
if err != nil {
|
|
81
|
+
c.Error(toHTTPError(err))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
h.authz.Invalidate(item.Role.Code)
|
|
85
|
+
c.JSON(http.StatusOK, application.ToRoleResponse(*item))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (h *Handler) delete(c *gin.Context) {
|
|
89
|
+
if err := h.svc.Delete(c.Request.Context(), c.Param("code")); err != nil {
|
|
90
|
+
c.Error(toHTTPError(err))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
h.authz.Invalidate(c.Param("code"))
|
|
94
|
+
c.Status(http.StatusNoContent)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (h *Handler) listPermissions(c *gin.Context) {
|
|
98
|
+
p := pagination.Parse(c)
|
|
99
|
+
items, err := h.svc.ListPermissions(c.Request.Context(), p.Limit, p.Offset)
|
|
100
|
+
if err != nil {
|
|
101
|
+
c.Error(toHTTPError(err))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
out := make([]application.PermissionResponse, len(items))
|
|
105
|
+
for i := range items {
|
|
106
|
+
out[i] = application.ToPermissionResponse(items[i])
|
|
107
|
+
}
|
|
108
|
+
c.JSON(http.StatusOK, p.Response(out))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func toHTTPError(err error) error {
|
|
112
|
+
if err == nil {
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
var appErr *apperror.AppError
|
|
116
|
+
if errors.As(err, &appErr) {
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
var rule *domain.RuleError
|
|
120
|
+
if errors.As(err, &rule) {
|
|
121
|
+
status := http.StatusUnprocessableEntity
|
|
122
|
+
switch {
|
|
123
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
124
|
+
status = http.StatusNotFound
|
|
125
|
+
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrHasReferences), errors.Is(err, domain.ErrSystemRole), errors.Is(err, domain.ErrLastRoleManager):
|
|
126
|
+
status = http.StatusConflict
|
|
127
|
+
}
|
|
128
|
+
return apperror.New(status, rule.Code, rule.Message)
|
|
129
|
+
}
|
|
130
|
+
switch {
|
|
131
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
132
|
+
return apperror.New(http.StatusNotFound, "ROLE_NOT_FOUND", "role not found")
|
|
133
|
+
case errors.Is(err, domain.ErrConflict):
|
|
134
|
+
return apperror.New(http.StatusConflict, "ROLE_CONFLICT", "role conflict")
|
|
135
|
+
case errors.Is(err, domain.ErrHasReferences):
|
|
136
|
+
return apperror.New(http.StatusConflict, "ROLE_HAS_REFERENCES", "role still has users assigned to it")
|
|
137
|
+
case errors.Is(err, domain.ErrUnknownPermission):
|
|
138
|
+
return apperror.New(http.StatusUnprocessableEntity, "ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist")
|
|
139
|
+
default:
|
|
140
|
+
return apperror.NewInternal(err)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/shared/middleware"
|
|
9
|
+
|
|
10
|
+
"github.com/gin-gonic/gin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestHandler_Register(t *testing.T) {
|
|
14
|
+
gin.SetMode(gin.TestMode)
|
|
15
|
+
authz := middleware.NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
|
|
16
|
+
return map[string]struct{}{}, nil
|
|
17
|
+
}, time.Minute)
|
|
18
|
+
NewHandler(nil, "", authz).Register(gin.New())
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Package postgres contains role persistence records. They are deliberately
|
|
2
|
+
// separate from role/domain so schema tags cannot leak into use cases.
|
|
3
|
+
package postgres
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/role/domain"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type Role struct {
|
|
12
|
+
Code string `gorm:"type:varchar(20);primaryKey"`
|
|
13
|
+
Name string `gorm:"not null"`
|
|
14
|
+
IsSystem bool `gorm:"not null;default:false"`
|
|
15
|
+
CreatedAt time.Time
|
|
16
|
+
UpdatedAt time.Time
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (Role) TableName() string { return "role_svc.roles" }
|
|
20
|
+
|
|
21
|
+
type Permission struct {
|
|
22
|
+
Code string `gorm:"type:varchar(50);primaryKey"`
|
|
23
|
+
Description string `gorm:"not null"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (Permission) TableName() string { return "role_svc.permissions" }
|
|
27
|
+
|
|
28
|
+
type RolePermission struct {
|
|
29
|
+
RoleCode string `gorm:"primaryKey;type:varchar(20)"`
|
|
30
|
+
PermissionCode string `gorm:"primaryKey;type:varchar(50)"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (RolePermission) TableName() string { return "role_svc.role_permissions" }
|
|
34
|
+
|
|
35
|
+
func toDomainRole(row *Role) domain.Role {
|
|
36
|
+
return domain.Role{Code: row.Code, Name: row.Name, IsSystem: row.IsSystem, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func fromDomainRole(value *domain.Role) *Role {
|
|
40
|
+
if value == nil {
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
return &Role{Code: value.Code, Name: value.Name, IsSystem: value.IsSystem, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func toDomainPermission(row *Permission) domain.Permission {
|
|
47
|
+
return domain.Permission{Code: row.Code, Description: row.Description}
|
|
48
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/app/role/domain"
|
|
8
|
+
"{{goModule}}/internal/app/role/ports"
|
|
9
|
+
"{{goModule}}/internal/shared/dberr"
|
|
10
|
+
"{{goModule}}/internal/shared/tx"
|
|
11
|
+
|
|
12
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
var _ ports.Repository = (*Repository)(nil)
|
|
16
|
+
|
|
17
|
+
type Repository struct {
|
|
18
|
+
db *gorm.DB
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
|
22
|
+
|
|
23
|
+
func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
|
|
24
|
+
var codes []string
|
|
25
|
+
err := tx.From(ctx, r.db).WithContext(ctx).
|
|
26
|
+
Model(&RolePermission{}).Where("role_code = ?", roleCode).Pluck("permission_code", &codes).Error
|
|
27
|
+
return codes, mapDatabaseError(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.Role, error) {
|
|
31
|
+
var rows []Role
|
|
32
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
|
|
33
|
+
if err != nil {
|
|
34
|
+
return nil, mapDatabaseError(err)
|
|
35
|
+
}
|
|
36
|
+
items := make([]domain.Role, len(rows))
|
|
37
|
+
for i := range rows {
|
|
38
|
+
items[i] = toDomainRole(&rows[i])
|
|
39
|
+
}
|
|
40
|
+
return items, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (r *Repository) FindByCode(ctx context.Context, code string) (*domain.Role, error) {
|
|
44
|
+
var row Role
|
|
45
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "code = ?", code).Error; err != nil {
|
|
46
|
+
return nil, mapDatabaseError(err)
|
|
47
|
+
}
|
|
48
|
+
value := toDomainRole(&row)
|
|
49
|
+
return &value, nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (r *Repository) Create(ctx context.Context, role *domain.Role) error {
|
|
53
|
+
return mapDatabaseError(tx.From(ctx, r.db).WithContext(ctx).Create(fromDomainRole(role)).Error)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
|
|
57
|
+
return tx.Do(ctx, r.db, func(txctx context.Context) error {
|
|
58
|
+
db := tx.From(txctx, r.db).WithContext(txctx)
|
|
59
|
+
if err := db.Where("role_code = ?", roleCode).Delete(&RolePermission{}).Error; err != nil {
|
|
60
|
+
return mapDatabaseError(err)
|
|
61
|
+
}
|
|
62
|
+
if len(permissionCodes) == 0 {
|
|
63
|
+
return nil
|
|
64
|
+
}
|
|
65
|
+
grants := make([]RolePermission, len(permissionCodes))
|
|
66
|
+
for i, code := range permissionCodes {
|
|
67
|
+
grants[i] = RolePermission{RoleCode: roleCode, PermissionCode: code}
|
|
68
|
+
}
|
|
69
|
+
return mapDatabaseError(db.Create(&grants).Error)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func (r *Repository) Delete(ctx context.Context, code string) error {
|
|
74
|
+
result := tx.From(ctx, r.db).WithContext(ctx).Where("code = ?", code).Delete(&Role{})
|
|
75
|
+
if result.Error != nil {
|
|
76
|
+
if dberr.IsForeignKey(result.Error) {
|
|
77
|
+
return domain.ErrHasReferences
|
|
78
|
+
}
|
|
79
|
+
return mapDatabaseError(result.Error)
|
|
80
|
+
}
|
|
81
|
+
if result.RowsAffected == 0 {
|
|
82
|
+
return domain.ErrNotFound
|
|
83
|
+
}
|
|
84
|
+
return nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]domain.Permission, error) {
|
|
88
|
+
var rows []Permission
|
|
89
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&rows).Error
|
|
90
|
+
if err != nil {
|
|
91
|
+
return nil, mapDatabaseError(err)
|
|
92
|
+
}
|
|
93
|
+
items := make([]domain.Permission, len(rows))
|
|
94
|
+
for i := range rows {
|
|
95
|
+
items[i] = toDomainPermission(&rows[i])
|
|
96
|
+
}
|
|
97
|
+
return items, nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
|
|
101
|
+
var codes []string
|
|
102
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Model(&Permission{}).Pluck("code", &codes).Error
|
|
103
|
+
return codes, mapDatabaseError(err)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
|
|
107
|
+
var count int64
|
|
108
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Model(&RolePermission{}).
|
|
109
|
+
Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).Count(&count).Error
|
|
110
|
+
return count, mapDatabaseError(err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func mapDatabaseError(err error) error {
|
|
114
|
+
if err == nil {
|
|
115
|
+
return nil
|
|
116
|
+
}
|
|
117
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
118
|
+
return domain.ErrNotFound
|
|
119
|
+
}
|
|
120
|
+
if dberr.IsDuplicate(err) {
|
|
121
|
+
return domain.ErrConflict
|
|
122
|
+
}
|
|
123
|
+
if dberr.IsForeignKey(err) {
|
|
124
|
+
return domain.ErrUnknownPermission
|
|
125
|
+
}
|
|
126
|
+
return err
|
|
127
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"os"
|
|
6
6
|
"testing"
|
|
7
7
|
|
|
8
|
-
"{{goModule}}/internal/app/role/
|
|
8
|
+
"{{goModule}}/internal/app/role/domain"
|
|
9
9
|
|
|
10
10
|
"github.com/google/uuid"
|
|
11
11
|
"gorm.io/driver/postgres"
|
|
@@ -67,11 +67,11 @@ func testCode(t *testing.T) string {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// seedPermission inserts directly rather than through Repository, which has
|
|
70
|
-
// no create method for Permission on purpose (see model
|
|
70
|
+
// no create method for Permission on purpose (see adapters/outbound/postgres/model.go:
|
|
71
71
|
// "seeded by migration, not admin-created"). Tests still need rows to grant.
|
|
72
72
|
func seedPermission(t *testing.T, tx *gorm.DB, code string) {
|
|
73
73
|
t.Helper()
|
|
74
|
-
if err := tx.WithContext(context.Background()).Create(&
|
|
74
|
+
if err := tx.WithContext(context.Background()).Create(&Permission{Code: code, Description: code}).Error; err != nil {
|
|
75
75
|
t.Fatalf("seed permission %s: %v", code, err)
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -87,7 +87,7 @@ func TestRepository_SetPermissions_ReplacesEntireSet(t *testing.T) {
|
|
|
87
87
|
|
|
88
88
|
roleCode := testCode(t)
|
|
89
89
|
permA, permB, permC := testCode(t), testCode(t), testCode(t)
|
|
90
|
-
if err := repo.Create(ctx, &
|
|
90
|
+
if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
|
|
91
91
|
t.Fatalf("seed role: %v", err)
|
|
92
92
|
}
|
|
93
93
|
seedPermission(t, tx, permA)
|
|
@@ -121,7 +121,7 @@ func TestRepository_Delete_CascadesRolePermissions(t *testing.T) {
|
|
|
121
121
|
|
|
122
122
|
roleCode := testCode(t)
|
|
123
123
|
permCode := testCode(t)
|
|
124
|
-
if err := repo.Create(ctx, &
|
|
124
|
+
if err := repo.Create(ctx, &domain.Role{Code: roleCode, Name: roleCode}); err != nil {
|
|
125
125
|
t.Fatalf("seed role: %v", err)
|
|
126
126
|
}
|
|
127
127
|
seedPermission(t, tx, permCode)
|
|
@@ -152,10 +152,10 @@ func TestRepository_CountRolesWithPermission_ExcludesGivenRole(t *testing.T) {
|
|
|
152
152
|
|
|
153
153
|
roleA, roleB := testCode(t), testCode(t)
|
|
154
154
|
permCode := testCode(t)
|
|
155
|
-
if err := repo.Create(ctx, &
|
|
155
|
+
if err := repo.Create(ctx, &domain.Role{Code: roleA, Name: roleA}); err != nil {
|
|
156
156
|
t.Fatalf("seed role a: %v", err)
|
|
157
157
|
}
|
|
158
|
-
if err := repo.Create(ctx, &
|
|
158
|
+
if err := repo.Create(ctx, &domain.Role{Code: roleB, Name: roleB}); err != nil {
|
|
159
159
|
t.Fatalf("seed role b: %v", err)
|
|
160
160
|
}
|
|
161
161
|
seedPermission(t, tx, permCode)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/role/domain"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const PermRoleManage = "role:manage"
|
|
10
|
+
|
|
11
|
+
type CreateInput struct {
|
|
12
|
+
Code string `json:"code"`
|
|
13
|
+
Name string `json:"name"`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type SetPermissionsInput struct {
|
|
17
|
+
PermissionCodes *[]string `json:"permission_codes"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RoleListItem struct {
|
|
21
|
+
Role domain.Role
|
|
22
|
+
Permissions []string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type RoleResponse struct {
|
|
26
|
+
Code string `json:"code"`
|
|
27
|
+
Name string `json:"name"`
|
|
28
|
+
IsSystem bool `json:"is_system"`
|
|
29
|
+
Permissions []string `json:"permissions"`
|
|
30
|
+
CreatedAt time.Time `json:"created_at"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type PermissionResponse struct {
|
|
34
|
+
Code string `json:"code"`
|
|
35
|
+
Description string `json:"description"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func ToRoleResponse(item RoleListItem) RoleResponse {
|
|
39
|
+
return RoleResponse{
|
|
40
|
+
Code: item.Role.Code, Name: item.Role.Name, IsSystem: item.Role.IsSystem,
|
|
41
|
+
Permissions: item.Permissions, CreatedAt: item.Role.CreatedAt,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func ToPermissionResponse(item domain.Permission) PermissionResponse {
|
|
46
|
+
return PermissionResponse{Code: item.Code, Description: item.Description}
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import "{{goModule}}/internal/app/role/domain"
|
|
4
|
+
|
|
5
|
+
func errInvalidCode() error {
|
|
6
|
+
return domain.Rule("ROLE_INVALID_CODE", "code must start with a lowercase letter and contain only lowercase letters, digits, or underscores", domain.ErrInvalidCode)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
func errSystemRole() error {
|
|
10
|
+
return domain.Rule("ROLE_IS_SYSTEM", "staff/admin are system roles and cannot be deleted", domain.ErrSystemRole)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func errUnknownPermission() error {
|
|
14
|
+
return domain.Rule("ROLE_UNKNOWN_PERMISSION", "one or more permission codes do not exist", domain.ErrUnknownPermission)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func errLastRoleManager() error {
|
|
18
|
+
return domain.Rule("ROLE_LAST_MANAGER", "this is the only role that can manage roles/permissions — grant "+PermRoleManage+" to another role first", domain.ErrLastRoleManager)
|
|
19
|
+
}
|