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:
jackyu66git
2026-08-07 17:19:45 +08:00
co-authored by Cursor
parent 85ed0901bb
commit b5a05941d9
35 changed files with 1518 additions and 49 deletions
+13 -12
View File
@@ -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) {
+4 -4
View File
@@ -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) {
+88
View File
@@ -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
}
+6 -3
View File
@@ -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
}
+135
View File
@@ -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)
}
+5 -17
View File
@@ -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)