feat(ECR-013A): Admin RBAC 实现并 Closed
角色权限、RequirePermission、/me permissions 与 migration 000015; Reviewer Approve → Closed。Next:ECR-013B Contract Definition。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,19 +29,20 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/stats", h.Stats)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
authed.GET("/audit-logs", h.ListAudit)
|
||||
authed.GET("/analytics/overview", h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
authed.GET("/stats", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.Stats)
|
||||
authed.GET("/users", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUsers)
|
||||
authed.GET("/users/:id", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
|
||||
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
|
||||
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
|
||||
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsFunnel)
|
||||
h.registerContent(authed)
|
||||
h.registerRBAC(authed)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||
authed.GET("/home/tools", h.ListHomeTools)
|
||||
authed.PUT("/home/tools", h.ReplaceHomeTools)
|
||||
authed.GET("/scales", h.ListScales)
|
||||
authed.PATCH("/scales/:id", h.PatchScale)
|
||||
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
|
||||
authed.PUT("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ReplaceHomeTools)
|
||||
authed.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListScales)
|
||||
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListHomeTools(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) registerRBAC(authed *gin.RouterGroup) {
|
||||
authed.GET("/roles", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.ListRoles)
|
||||
authed.GET("/roles/:id", middleware.RequireAdminPermission(h.Svc, admin.PermRolesRead), h.GetRole)
|
||||
authed.PUT("/roles/:id/permissions", middleware.RequireAdminPermission(h.Svc, admin.PermRolesWrite), h.PutRolePermissions)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListRoles(c *gin.Context) {
|
||||
items, err := h.Svc.ListRoles(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetRole(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) || role == nil {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PutRolePermissions(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
roleID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||
return
|
||||
}
|
||||
if body.Permissions == nil {
|
||||
body.Permissions = []string{}
|
||||
}
|
||||
err = h.Svc.ReplaceRolePermissions(c.Request.Context(), adminID, roleID, body.Permissions)
|
||||
if errors.Is(err, admin.ErrRoleNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40400, "role not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidPerm) {
|
||||
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
role, err := h.Svc.GetRole(c.Request.Context(), roleID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, role)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/httpserver"
|
||||
)
|
||||
|
||||
func setupAPIPool(t *testing.T) (*gin.Engine, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.Admin.BootstrapUsername = "admin"
|
||||
cfg.Admin.BootstrapPassword = "change-me"
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
migDir := filepath.Join("..", "..", "migrations")
|
||||
if err := db.Migrate(ctx, pool, migDir); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return httpserver.NewRouter(pool, cfg), pool
|
||||
}
|
||||
|
||||
func adminLogin(t *testing.T, r http.Handler, user, pass string) string {
|
||||
t.Helper()
|
||||
env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": user, "password": pass,
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("login %s failed http=%d code=%d msg=%s", user, code, env.Code, env.Message)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||
}
|
||||
return login.Token
|
||||
}
|
||||
|
||||
func TestAdminRBAC(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
superTok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
// AC-S-03: no admin session → 401
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, "")
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 without admin token, got http=%d", code)
|
||||
}
|
||||
|
||||
// AC-F-03: /me includes permissions
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("me failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
var me struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &me); err != nil || len(me.Permissions) == 0 {
|
||||
t.Fatalf("expected permissions on me: %v %s", err, env.Data)
|
||||
}
|
||||
if me.Role != "super_admin" {
|
||||
t.Fatalf("expected super_admin role, got %q", me.Role)
|
||||
}
|
||||
|
||||
// AC-F-01: list roles
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list roles failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var roles struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roles); err != nil || len(roles.Items) == 0 {
|
||||
t.Fatalf("expected roles: %v %s", err, env.Data)
|
||||
}
|
||||
var superRoleID string
|
||||
for _, it := range roles.Items {
|
||||
if it.Name == "super_admin" {
|
||||
superRoleID = it.ID
|
||||
}
|
||||
}
|
||||
if superRoleID == "" {
|
||||
t.Fatal("super_admin role missing")
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+superRoleID, nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("get role failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// Seed limited role + account for deny ACs
|
||||
limitedRoleID := uuid.New()
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)
|
||||
ON CONFLICT (name) DO NOTHING`, limitedRoleID, "rbac_limited_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert role: %v", err)
|
||||
}
|
||||
// resolve actual id if conflict
|
||||
var roleName string
|
||||
err = pool.QueryRow(ctx, `SELECT id, name FROM admin_roles WHERE id=$1`, limitedRoleID).Scan(&limitedRoleID, &roleName)
|
||||
if err != nil {
|
||||
t.Fatalf("load limited role: %v", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limitedUser := fmt.Sprintf("limited_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES ($1, $2, $3)`, limitedUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert limited admin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
|
||||
limitedTok := adminLogin(t, r, limitedUser, "limited-pass")
|
||||
|
||||
// AC-S-01: no roles.write → 403
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": []string{"admin.users.read"}}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 roles.write, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
// AC-S-02: no membership.grant → 403
|
||||
_ = mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("list users: %d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
if len(list.Items) == 0 {
|
||||
t.Fatal("need a user for grant deny")
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+list.Items[0].ID+"/membership/grant",
|
||||
map[string]string{"plan": "month"}, limitedTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 membership.grant, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
// AC-F-02 + AC-O-01: super replaces permissions
|
||||
want := []string{"admin.users.read", "admin.roles.read"}
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/roles/"+limitedRoleID.String()+"/permissions",
|
||||
map[string]any{"permissions": want}, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("put permissions failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles/"+limitedRoleID.String(), nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("get after put: %d", code)
|
||||
}
|
||||
var roleDetail struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &roleDetail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roleDetail.Permissions) != 2 {
|
||||
t.Fatalf("expected 2 perms, got %#v", roleDetail.Permissions)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok)
|
||||
if code != 200 {
|
||||
t.Fatalf("audit: %d", code)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &audit)
|
||||
foundUpdate, foundDeny := false, false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "roles.permissions.update" {
|
||||
foundUpdate = true
|
||||
}
|
||||
if it.Action == "permission.denied" {
|
||||
foundDeny = true
|
||||
}
|
||||
}
|
||||
if !foundUpdate {
|
||||
t.Fatal("expected roles.permissions.update audit")
|
||||
}
|
||||
if !foundDeny {
|
||||
t.Fatal("expected permission.denied audit")
|
||||
}
|
||||
|
||||
// AC-S-04: system role cannot be deleted (FK + system seed; no Delete API)
|
||||
tag, err := pool.Exec(ctx, `DELETE FROM admin_roles WHERE name='super_admin'`)
|
||||
if err == nil && tag.RowsAffected() > 0 {
|
||||
t.Fatal("expected delete super_admin to fail or affect 0 rows")
|
||||
}
|
||||
|
||||
// AC-P-01: list roles under 500ms locally
|
||||
start := time.Now()
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/roles", nil, superTok)
|
||||
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||
t.Fatalf("AC-P-01 list roles slow or failed: http=%d dur=%v", code, time.Since(start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AdminPermissionChecker validates admin permission codes.
|
||||
type AdminPermissionChecker interface {
|
||||
HasPermission(ctx context.Context, adminID uuid.UUID, code string) (bool, error)
|
||||
DenyPermission(ctx context.Context, adminID uuid.UUID, code, path string)
|
||||
}
|
||||
|
||||
// RequireAdminPermission aborts with 403 when the admin lacks code.
|
||||
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
adminID, ok := AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !okPerm {
|
||||
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AdminRole is an ops RBAC role.
|
||||
type AdminRole struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
System bool `json:"system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAdminRoles returns all roles.
|
||||
func (r *AdminRepo) ListAdminRoles(ctx context.Context) ([]AdminRole, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, name, system, created_at FROM admin_roles ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminRole
|
||||
for rows.Next() {
|
||||
var a AdminRole
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAdminRole loads one role.
|
||||
func (r *AdminRepo) GetAdminRole(ctx context.Context, id uuid.UUID) (*AdminRole, error) {
|
||||
var a AdminRole
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, system, created_at FROM admin_roles WHERE id=$1`, id,
|
||||
).Scan(&a.ID, &a.Name, &a.System, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// ListRolePermissions returns permission codes for a role.
|
||||
func (r *AdminRepo) ListRolePermissions(ctx context.Context, roleID uuid.UUID) ([]string, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT code FROM admin_role_permissions WHERE role_id=$1 ORDER BY code`, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceRolePermissions replaces the full permission set for a role.
|
||||
func (r *AdminRepo) ReplaceRolePermissions(ctx context.Context, roleID uuid.UUID, codes []string) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM admin_role_permissions WHERE role_id=$1`, roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, code := range codes {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,$2)`, roleID, code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListPermissionsForAdmin returns permission codes for an admin account.
|
||||
func (r *AdminRepo) ListPermissionsForAdmin(ctx context.Context, adminID uuid.UUID) ([]string, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT p.code
|
||||
FROM admin_accounts a
|
||||
JOIN admin_role_permissions p ON p.role_id = a.role_id
|
||||
WHERE a.id=$1 AND a.deleted_at IS NULL AND a.role_id IS NOT NULL
|
||||
ORDER BY p.code`, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetAdminRoleMeta returns role id/name for an account.
|
||||
func (r *AdminRepo) GetAdminRoleMeta(ctx context.Context, adminID uuid.UUID) (roleID *uuid.UUID, name string, err error) {
|
||||
var id uuid.UUID
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT r.id, r.name
|
||||
FROM admin_accounts a
|
||||
JOIN admin_roles r ON r.id = a.role_id
|
||||
WHERE a.id=$1 AND a.deleted_at IS NULL`, adminID,
|
||||
).Scan(&id, &name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &id, name, nil
|
||||
}
|
||||
@@ -32,12 +32,15 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account.
|
||||
// CreateAccount inserts an admin account with seeded super_admin role.
|
||||
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash)
|
||||
VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id)
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id)
|
||||
VALUES (
|
||||
$1, $2,
|
||||
(SELECT id FROM admin_roles WHERE name = 'super_admin' LIMIT 1)
|
||||
) RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Permission catalog frozen in ECR-013A Spec.
|
||||
const (
|
||||
PermUsersRead = "admin.users.read"
|
||||
PermMembershipGrant = "admin.users.membership.grant"
|
||||
PermAskQuotaGrant = "admin.users.ask_quota.grant"
|
||||
PermOrdersRead = "admin.orders.read"
|
||||
PermAuditRead = "admin.audit.read"
|
||||
PermAnalyticsRead = "admin.analytics.read"
|
||||
PermContentWrite = "admin.content.write"
|
||||
PermRolesRead = "admin.roles.read"
|
||||
PermRolesWrite = "admin.roles.write"
|
||||
)
|
||||
|
||||
var knownPermissions = map[string]struct{}{
|
||||
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
|
||||
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||
}
|
||||
|
||||
var (
|
||||
ErrForbidden = errString("forbidden")
|
||||
ErrRoleNotFound = errString("role not found")
|
||||
ErrInvalidPerm = errString("invalid permission code")
|
||||
ErrProtectSystem = errString("system role protected")
|
||||
)
|
||||
|
||||
// AdminMe is the public admin profile (with RBAC).
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// RoleDTO is list/detail payload.
|
||||
type RoleDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
System bool `json:"system"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// HasPermission reports whether admin holds code.
|
||||
func (s *Service) HasPermission(ctx context.Context, adminID uuid.UUID, code string) (bool, error) {
|
||||
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, p := range perms {
|
||||
if p == code {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DenyPermission audits a forbidden attempt.
|
||||
func (s *Service) DenyPermission(ctx context.Context, adminID uuid.UUID, code, path string) {
|
||||
meta, _ := json.Marshal(map[string]string{"permission": code, "path": path})
|
||||
_ = s.Repo.InsertAudit(ctx, adminID, "permission.denied", "permission", code, meta)
|
||||
}
|
||||
|
||||
// Me returns the current admin profile with permissions.
|
||||
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
perms, err := s.Repo.ListPermissionsForAdmin(ctx, adminID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
_, roleName, _ := s.Repo.GetAdminRoleMeta(ctx, adminID)
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: roleName, Permissions: perms}, nil
|
||||
}
|
||||
|
||||
// ListRoles returns roles without permissions.
|
||||
func (s *Service) ListRoles(ctx context.Context) ([]RoleDTO, error) {
|
||||
roles, err := s.Repo.ListAdminRoles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RoleDTO, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
out = append(out, RoleDTO{ID: r.ID, Name: r.Name, System: r.System})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRole returns role + permissions.
|
||||
func (s *Service) GetRole(ctx context.Context, id uuid.UUID) (*RoleDTO, error) {
|
||||
role, err := s.Repo.GetAdminRole(ctx, id)
|
||||
if err != nil || role == nil {
|
||||
return nil, ErrRoleNotFound
|
||||
}
|
||||
perms, err := s.Repo.ListRolePermissions(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
return &RoleDTO{ID: role.ID, Name: role.Name, System: role.System, Permissions: perms}, nil
|
||||
}
|
||||
|
||||
// ReplaceRolePermissions updates permissions with audit.
|
||||
func (s *Service) ReplaceRolePermissions(ctx context.Context, adminID, roleID uuid.UUID, codes []string) error {
|
||||
role, err := s.Repo.GetAdminRole(ctx, roleID)
|
||||
if err != nil || role == nil {
|
||||
return ErrRoleNotFound
|
||||
}
|
||||
for _, c := range codes {
|
||||
if _, ok := knownPermissions[c]; !ok {
|
||||
return ErrInvalidPerm
|
||||
}
|
||||
}
|
||||
if err := s.Repo.ReplaceRolePermissions(ctx, roleID, codes); err != nil {
|
||||
return err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"permissions": codes, "role": role.Name})
|
||||
return s.Repo.InsertAudit(ctx, adminID, "roles.permissions.update", "admin_role", roleID.String(), meta)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -54,12 +53,6 @@ type LoginResult struct {
|
||||
Admin AdminMe `json:"admin"`
|
||||
}
|
||||
|
||||
// AdminMe is the public admin profile.
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadCredentials = errString("invalid credentials")
|
||||
ErrInvalidPlan = errString("invalid plan")
|
||||
@@ -90,10 +83,14 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
|
||||
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
me, err := s.Me(ctx, acc.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||
Admin: *me,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -110,15 +107,6 @@ func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
return s.Repo.DeleteSession(ctx, token)
|
||||
}
|
||||
|
||||
// Me returns the current admin profile.
|
||||
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
|
||||
return s.Repo.ListUsers(ctx, q, limit, offset)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE admin_accounts SET role_id = NULL WHERE role_id IS NOT NULL;
|
||||
ALTER TABLE admin_accounts DROP COLUMN IF EXISTS role_id;
|
||||
DROP TABLE IF EXISTS admin_role_permissions;
|
||||
DROP TABLE IF EXISTS admin_roles;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- ECR-013A Admin RBAC
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_roles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name varchar(64) NOT NULL UNIQUE,
|
||||
system boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_role_permissions (
|
||||
role_id uuid NOT NULL REFERENCES admin_roles(id) ON DELETE CASCADE,
|
||||
code varchar(64) NOT NULL,
|
||||
PRIMARY KEY (role_id, code)
|
||||
);
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
ADD COLUMN IF NOT EXISTS role_id uuid REFERENCES admin_roles(id);
|
||||
|
||||
INSERT INTO admin_roles(id, name, system)
|
||||
VALUES ('00000000-0000-4000-8000-000000000001', 'super_admin', true)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO admin_role_permissions(role_id, code)
|
||||
SELECT r.id, p.code
|
||||
FROM admin_roles r
|
||||
CROSS JOIN (VALUES
|
||||
('admin.users.read'),
|
||||
('admin.users.membership.grant'),
|
||||
('admin.users.ask_quota.grant'),
|
||||
('admin.orders.read'),
|
||||
('admin.audit.read'),
|
||||
('admin.analytics.read'),
|
||||
('admin.content.write'),
|
||||
('admin.roles.read'),
|
||||
('admin.roles.write')
|
||||
) AS p(code)
|
||||
WHERE r.name = 'super_admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
UPDATE admin_accounts
|
||||
SET role_id = (SELECT id FROM admin_roles WHERE name = 'super_admin')
|
||||
WHERE role_id IS NULL AND deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user