From e767b07b23957de4255060e21a1bc821dbe6e830 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 02:25:16 +0300 Subject: [PATCH] feat(backend): implement user, ACL, and audit stacks Add UserService (GetMe, UpdateMe, admin CRUD with block/unblock), UserHandler (/users, /users/me), ACLHandler (GET/PUT /acl/:type/:id), AuditHandler (GET /audit with all filters). Fix UserRepo.Update to include is_blocked. Wire all remaining routes. Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/main.go | 10 +- backend/internal/db/postgres/user_repo.go | 4 +- backend/internal/handler/acl_handler.go | 144 ++++++++++++ backend/internal/handler/audit_handler.go | 120 ++++++++++ backend/internal/handler/router.go | 34 +++ backend/internal/handler/user_handler.go | 258 ++++++++++++++++++++++ backend/internal/service/user_service.go | 156 +++++++++++++ 7 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 backend/internal/handler/acl_handler.go create mode 100644 backend/internal/handler/audit_handler.go create mode 100644 backend/internal/handler/user_handler.go create mode 100644 backend/internal/service/user_service.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e5c2c02..7eb4543 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -92,6 +92,7 @@ func main() { transactor, cfg.ImportPath, ) + userSvc := service.NewUserService(userRepo, auditSvc) // Handlers authMiddleware := handler.NewAuthMiddleware(authSvc) @@ -100,8 +101,15 @@ func main() { tagHandler := handler.NewTagHandler(tagSvc, fileSvc) categoryHandler := handler.NewCategoryHandler(categorySvc) poolHandler := handler.NewPoolHandler(poolSvc) + userHandler := handler.NewUserHandler(userSvc) + aclHandler := handler.NewACLHandler(aclSvc) + auditHandler := handler.NewAuditHandler(auditSvc) - r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler, categoryHandler, poolHandler) + r := handler.NewRouter( + authMiddleware, authHandler, + fileHandler, tagHandler, categoryHandler, poolHandler, + userHandler, aclHandler, auditHandler, + ) slog.Info("starting server", "addr", cfg.ListenAddr) if err := r.Run(cfg.ListenAddr); err != nil { diff --git a/backend/internal/db/postgres/user_repo.go b/backend/internal/db/postgres/user_repo.go index 49363f0..959b345 100644 --- a/backend/internal/db/postgres/user_repo.go +++ b/backend/internal/db/postgres/user_repo.go @@ -162,12 +162,12 @@ func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, er func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) { const sql = ` UPDATE core.users - SET name = $2, password = $3, is_admin = $4, can_create = $5 + SET name = $2, password = $3, is_admin = $4, can_create = $5, is_blocked = $6 WHERE id = $1 RETURNING id, name, password, is_admin, can_create, is_blocked` q := connOrTx(ctx, r.pool) - rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate) + rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate, u.IsBlocked) if err != nil { return nil, fmt.Errorf("UserRepo.Update: %w", err) } diff --git a/backend/internal/handler/acl_handler.go b/backend/internal/handler/acl_handler.go new file mode 100644 index 0000000..3076cd8 --- /dev/null +++ b/backend/internal/handler/acl_handler.go @@ -0,0 +1,144 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/service" +) + +// objectTypeIDs maps the URL segment to the object_type PK in core.object_types. +// Row order matches 007_seed_data.sql: file=1, tag=2, category=3, pool=4. +var objectTypeIDs = map[string]int16{ + "file": 1, + "tag": 2, + "category": 3, + "pool": 4, +} + +// ACLHandler handles GET/PUT /acl/:object_type/:object_id. +type ACLHandler struct { + aclSvc *service.ACLService +} + +// NewACLHandler creates an ACLHandler. +func NewACLHandler(aclSvc *service.ACLService) *ACLHandler { + return &ACLHandler{aclSvc: aclSvc} +} + +// --------------------------------------------------------------------------- +// Response type +// --------------------------------------------------------------------------- + +type permissionJSON struct { + UserID int16 `json:"user_id"` + UserName string `json:"user_name"` + CanView bool `json:"can_view"` + CanEdit bool `json:"can_edit"` +} + +func toPermissionJSON(p domain.Permission) permissionJSON { + return permissionJSON{ + UserID: p.UserID, + UserName: p.UserName, + CanView: p.CanView, + CanEdit: p.CanEdit, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func parseACLPath(c *gin.Context) (objectTypeID int16, objectID uuid.UUID, ok bool) { + typeStr := c.Param("object_type") + id, exists := objectTypeIDs[typeStr] + if !exists { + respondError(c, domain.ErrValidation) + return 0, uuid.UUID{}, false + } + + objectID, err := uuid.Parse(c.Param("object_id")) + if err != nil { + respondError(c, domain.ErrValidation) + return 0, uuid.UUID{}, false + } + + return id, objectID, true +} + +// --------------------------------------------------------------------------- +// GET /acl/:object_type/:object_id +// --------------------------------------------------------------------------- + +func (h *ACLHandler) GetPermissions(c *gin.Context) { + objectTypeID, objectID, ok := parseACLPath(c) + if !ok { + return + } + + perms, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID) + if err != nil { + respondError(c, err) + return + } + + out := make([]permissionJSON, len(perms)) + for i, p := range perms { + out[i] = toPermissionJSON(p) + } + respondJSON(c, http.StatusOK, out) +} + +// --------------------------------------------------------------------------- +// PUT /acl/:object_type/:object_id +// --------------------------------------------------------------------------- + +func (h *ACLHandler) SetPermissions(c *gin.Context) { + objectTypeID, objectID, ok := parseACLPath(c) + if !ok { + return + } + + var body struct { + Permissions []struct { + UserID int16 `json:"user_id" binding:"required"` + CanView bool `json:"can_view"` + CanEdit bool `json:"can_edit"` + } `json:"permissions" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, domain.ErrValidation) + return + } + + perms := make([]domain.Permission, len(body.Permissions)) + for i, p := range body.Permissions { + perms[i] = domain.Permission{ + UserID: p.UserID, + CanView: p.CanView, + CanEdit: p.CanEdit, + } + } + + if err := h.aclSvc.SetPermissions(c.Request.Context(), objectTypeID, objectID, perms); err != nil { + respondError(c, err) + return + } + + // Re-read to return the stored permissions (with UserName denormalized). + stored, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID) + if err != nil { + respondError(c, err) + return + } + + out := make([]permissionJSON, len(stored)) + for i, p := range stored { + out[i] = toPermissionJSON(p) + } + respondJSON(c, http.StatusOK, out) +} \ No newline at end of file diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go new file mode 100644 index 0000000..6dad2fc --- /dev/null +++ b/backend/internal/handler/audit_handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/service" +) + +// AuditHandler handles GET /audit. +type AuditHandler struct { + auditSvc *service.AuditService +} + +// NewAuditHandler creates an AuditHandler. +func NewAuditHandler(auditSvc *service.AuditService) *AuditHandler { + return &AuditHandler{auditSvc: auditSvc} +} + +// --------------------------------------------------------------------------- +// Response type +// --------------------------------------------------------------------------- + +type auditEntryJSON struct { + ID int64 `json:"id"` + UserID int16 `json:"user_id"` + UserName string `json:"user_name"` + Action string `json:"action"` + ObjectType *string `json:"object_type"` + ObjectID *string `json:"object_id"` + PerformedAt string `json:"performed_at"` +} + +func toAuditEntryJSON(e domain.AuditEntry) auditEntryJSON { + j := auditEntryJSON{ + ID: e.ID, + UserID: e.UserID, + UserName: e.UserName, + Action: e.Action, + ObjectType: e.ObjectType, + PerformedAt: e.PerformedAt.UTC().Format(time.RFC3339), + } + if e.ObjectID != nil { + s := e.ObjectID.String() + j.ObjectID = &s + } + return j +} + +// --------------------------------------------------------------------------- +// GET /audit (admin) +// --------------------------------------------------------------------------- + +func (h *AuditHandler) List(c *gin.Context) { + if !requireAdmin(c) { + return + } + + filter := domain.AuditFilter{} + + if s := c.Query("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + filter.Limit = n + } + } + if s := c.Query("offset"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + filter.Offset = n + } + } + if s := c.Query("user_id"); s != "" { + if n, err := strconv.ParseInt(s, 10, 16); err == nil { + id := int16(n) + filter.UserID = &id + } + } + if s := c.Query("action"); s != "" { + filter.Action = s + } + if s := c.Query("object_type"); s != "" { + filter.ObjectType = s + } + if s := c.Query("object_id"); s != "" { + if id, err := uuid.Parse(s); err == nil { + filter.ObjectID = &id + } + } + if s := c.Query("from"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + filter.From = &t + } + } + if s := c.Query("to"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + filter.To = &t + } + } + + page, err := h.auditSvc.Query(c.Request.Context(), filter) + if err != nil { + respondError(c, err) + return + } + + items := make([]auditEntryJSON, len(page.Items)) + for i, e := range page.Items { + items[i] = toAuditEntryJSON(e) + } + respondJSON(c, http.StatusOK, gin.H{ + "items": items, + "total": page.Total, + "offset": page.Offset, + "limit": page.Limit, + }) +} \ No newline at end of file diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index eff36a7..74134a0 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -14,6 +14,9 @@ func NewRouter( tagHandler *TagHandler, categoryHandler *CategoryHandler, poolHandler *PoolHandler, + userHandler *UserHandler, + aclHandler *ACLHandler, + auditHandler *AuditHandler, ) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) @@ -128,5 +131,36 @@ func NewRouter( pools.POST("/:pool_id/files", poolHandler.AddFiles) } + // ------------------------------------------------------------------------- + // Users (auth required; admin checks enforced in handler) + // ------------------------------------------------------------------------- + users := v1.Group("/users", auth.Handle()) + { + // /users/me must be registered before /:user_id to avoid param capture. + users.GET("/me", userHandler.GetMe) + users.PATCH("/me", userHandler.UpdateMe) + + users.GET("", userHandler.List) + users.POST("", userHandler.Create) + + users.GET("/:user_id", userHandler.Get) + users.PATCH("/:user_id", userHandler.UpdateAdmin) + users.DELETE("/:user_id", userHandler.Delete) + } + + // ------------------------------------------------------------------------- + // ACL (auth required) + // ------------------------------------------------------------------------- + acl := v1.Group("/acl", auth.Handle()) + { + acl.GET("/:object_type/:object_id", aclHandler.GetPermissions) + acl.PUT("/:object_type/:object_id", aclHandler.SetPermissions) + } + + // ------------------------------------------------------------------------- + // Audit (auth required; admin check enforced in handler) + // ------------------------------------------------------------------------- + v1.GET("/audit", auth.Handle(), auditHandler.List) + return r } \ No newline at end of file diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go new file mode 100644 index 0000000..18d4580 --- /dev/null +++ b/backend/internal/handler/user_handler.go @@ -0,0 +1,258 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/port" + "tanabata/backend/internal/service" +) + +// UserHandler handles all /users endpoints. +type UserHandler struct { + userSvc *service.UserService +} + +// NewUserHandler creates a UserHandler. +func NewUserHandler(userSvc *service.UserService) *UserHandler { + return &UserHandler{userSvc: userSvc} +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +type userJSON struct { + ID int16 `json:"id"` + Name string `json:"name"` + IsAdmin bool `json:"is_admin"` + CanCreate bool `json:"can_create"` + IsBlocked bool `json:"is_blocked"` +} + +func toUserJSON(u domain.User) userJSON { + return userJSON{ + ID: u.ID, + Name: u.Name, + IsAdmin: u.IsAdmin, + CanCreate: u.CanCreate, + IsBlocked: u.IsBlocked, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func requireAdmin(c *gin.Context) bool { + _, isAdmin, _ := domain.UserFromContext(c.Request.Context()) + if !isAdmin { + respondError(c, domain.ErrForbidden) + return false + } + return true +} + +func parseUserID(c *gin.Context) (int16, bool) { + n, err := strconv.ParseInt(c.Param("user_id"), 10, 16) + if err != nil { + respondError(c, domain.ErrValidation) + return 0, false + } + return int16(n), true +} + +// --------------------------------------------------------------------------- +// GET /users/me +// --------------------------------------------------------------------------- + +func (h *UserHandler) GetMe(c *gin.Context) { + u, err := h.userSvc.GetMe(c.Request.Context()) + if err != nil { + respondError(c, err) + return + } + respondJSON(c, http.StatusOK, toUserJSON(*u)) +} + +// --------------------------------------------------------------------------- +// PATCH /users/me +// --------------------------------------------------------------------------- + +func (h *UserHandler) UpdateMe(c *gin.Context) { + var body struct { + Name string `json:"name"` + Password *string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, domain.ErrValidation) + return + } + + updated, err := h.userSvc.UpdateMe(c.Request.Context(), service.UpdateMeParams{ + Name: body.Name, + Password: body.Password, + }) + if err != nil { + respondError(c, err) + return + } + respondJSON(c, http.StatusOK, toUserJSON(*updated)) +} + +// --------------------------------------------------------------------------- +// GET /users (admin) +// --------------------------------------------------------------------------- + +func (h *UserHandler) List(c *gin.Context) { + if !requireAdmin(c) { + return + } + + params := port.OffsetParams{ + Sort: c.DefaultQuery("sort", "id"), + Order: c.DefaultQuery("order", "asc"), + } + if s := c.Query("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + params.Limit = n + } + } + if s := c.Query("offset"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + params.Offset = n + } + } + + page, err := h.userSvc.List(c.Request.Context(), params) + if err != nil { + respondError(c, err) + return + } + + items := make([]userJSON, len(page.Items)) + for i, u := range page.Items { + items[i] = toUserJSON(u) + } + respondJSON(c, http.StatusOK, gin.H{ + "items": items, + "total": page.Total, + "offset": page.Offset, + "limit": page.Limit, + }) +} + +// --------------------------------------------------------------------------- +// POST /users (admin) +// --------------------------------------------------------------------------- + +func (h *UserHandler) Create(c *gin.Context) { + if !requireAdmin(c) { + return + } + + var body struct { + Name string `json:"name" binding:"required"` + Password string `json:"password" binding:"required"` + IsAdmin bool `json:"is_admin"` + CanCreate bool `json:"can_create"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, domain.ErrValidation) + return + } + + created, err := h.userSvc.Create(c.Request.Context(), service.CreateUserParams{ + Name: body.Name, + Password: body.Password, + IsAdmin: body.IsAdmin, + CanCreate: body.CanCreate, + }) + if err != nil { + respondError(c, err) + return + } + respondJSON(c, http.StatusCreated, toUserJSON(*created)) +} + +// --------------------------------------------------------------------------- +// GET /users/:user_id (admin) +// --------------------------------------------------------------------------- + +func (h *UserHandler) Get(c *gin.Context) { + if !requireAdmin(c) { + return + } + + id, ok := parseUserID(c) + if !ok { + return + } + + u, err := h.userSvc.Get(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + respondJSON(c, http.StatusOK, toUserJSON(*u)) +} + +// --------------------------------------------------------------------------- +// PATCH /users/:user_id (admin) +// --------------------------------------------------------------------------- + +func (h *UserHandler) UpdateAdmin(c *gin.Context) { + if !requireAdmin(c) { + return + } + + id, ok := parseUserID(c) + if !ok { + return + } + + var body struct { + IsAdmin *bool `json:"is_admin"` + CanCreate *bool `json:"can_create"` + IsBlocked *bool `json:"is_blocked"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, domain.ErrValidation) + return + } + + updated, err := h.userSvc.UpdateAdmin(c.Request.Context(), id, service.UpdateAdminParams{ + IsAdmin: body.IsAdmin, + CanCreate: body.CanCreate, + IsBlocked: body.IsBlocked, + }) + if err != nil { + respondError(c, err) + return + } + respondJSON(c, http.StatusOK, toUserJSON(*updated)) +} + +// --------------------------------------------------------------------------- +// DELETE /users/:user_id (admin) +// --------------------------------------------------------------------------- + +func (h *UserHandler) Delete(c *gin.Context) { + if !requireAdmin(c) { + return + } + + id, ok := parseUserID(c) + if !ok { + return + } + + if err := h.userSvc.Delete(c.Request.Context(), id); err != nil { + respondError(c, err) + return + } + c.Status(http.StatusNoContent) +} \ No newline at end of file diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go new file mode 100644 index 0000000..923ea16 --- /dev/null +++ b/backend/internal/service/user_service.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "fmt" + + "golang.org/x/crypto/bcrypt" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/port" +) + +// UserService handles user CRUD and profile management. +type UserService struct { + users port.UserRepo + audit *AuditService +} + +// NewUserService creates a UserService. +func NewUserService(users port.UserRepo, audit *AuditService) *UserService { + return &UserService{users: users, audit: audit} +} + +// --------------------------------------------------------------------------- +// Self-service +// --------------------------------------------------------------------------- + +// GetMe returns the profile of the currently authenticated user. +func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) { + userID, _, _ := domain.UserFromContext(ctx) + return s.users.GetByID(ctx, userID) +} + +// UpdateMeParams holds fields a user may change on their own profile. +type UpdateMeParams struct { + Name string // empty = no change + Password *string // nil = no change +} + +// UpdateMe allows a user to change their own name and/or password. +func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) { + userID, _, _ := domain.UserFromContext(ctx) + + current, err := s.users.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + patch := *current + if p.Name != "" { + patch.Name = p.Name + } + if p.Password != nil { + hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err) + } + patch.Password = string(hash) + } + + return s.users.Update(ctx, userID, &patch) +} + +// --------------------------------------------------------------------------- +// Admin CRUD +// --------------------------------------------------------------------------- + +// List returns a paginated list of users (admin only — caller must enforce). +func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) { + return s.users.List(ctx, params) +} + +// Get returns a user by ID (admin only). +func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) { + return s.users.GetByID(ctx, id) +} + +// CreateParams holds fields for creating a new user. +type CreateUserParams struct { + Name string + Password string + IsAdmin bool + CanCreate bool +} + +// Create inserts a new user with a bcrypt-hashed password (admin only). +func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("UserService.Create hash: %w", err) + } + + u := &domain.User{ + Name: p.Name, + Password: string(hash), + IsAdmin: p.IsAdmin, + CanCreate: p.CanCreate, + } + created, err := s.users.Create(ctx, u) + if err != nil { + return nil, err + } + + _ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID}) + return created, nil +} + +// UpdateAdminParams holds fields an admin may change on any user. +type UpdateAdminParams struct { + IsAdmin *bool + CanCreate *bool + IsBlocked *bool +} + +// UpdateAdmin applies an admin-level patch to a user. +func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) { + current, err := s.users.GetByID(ctx, id) + if err != nil { + return nil, err + } + + patch := *current + if p.IsAdmin != nil { + patch.IsAdmin = *p.IsAdmin + } + if p.CanCreate != nil { + patch.CanCreate = *p.CanCreate + } + if p.IsBlocked != nil { + patch.IsBlocked = *p.IsBlocked + } + + updated, err := s.users.Update(ctx, id, &patch) + if err != nil { + return nil, err + } + + // Log block/unblock specifically. + if p.IsBlocked != nil { + action := "user_unblock" + if *p.IsBlocked { + action = "user_block" + } + _ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id}) + } + return updated, nil +} + +// Delete removes a user by ID (admin only). +func (s *UserService) Delete(ctx context.Context, id int16) error { + if err := s.users.Delete(ctx, id); err != nil { + return err + } + _ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id}) + return nil +} \ No newline at end of file