Files
ai-gateway-go/internal/identity/management.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

416 lines
15 KiB
Go

package identity
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"aigateway.local/core/internal/platform/apiresponse"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrIdentityConflict = errors.New("identity already exists")
permissionPattern = regexp.MustCompile(`^[a-z][a-z0-9_.:-]{2,127}$`)
)
type ManagementHTTPHandler struct {
service *Service
mux *http.ServeMux
}
type identityInput struct {
Login string `json:"login"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password *string `json:"password"`
Permissions []string `json:"permissions"`
Active *bool `json:"active"`
DepartmentID *string `json:"department_id"`
}
func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler {
h := &ManagementHTTPHandler{service: service, mux: http.NewServeMux()}
h.mux.HandleFunc("GET /api/v1/admin/identities/admins", h.list(KindAdmin))
h.mux.HandleFunc("POST /api/v1/admin/identities/admins", h.create(KindAdmin))
h.mux.HandleFunc("PUT /api/v1/admin/identities/admins/{identity_id}", h.update(KindAdmin))
h.mux.HandleFunc("GET /api/v1/admin/identities/portal-users", h.list(KindPortal))
h.mux.HandleFunc("POST /api/v1/admin/identities/portal-users", h.create(KindPortal))
h.mux.HandleFunc("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal))
h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments)
h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment)
h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment)
h.registerOIDC()
return h
}
func (h *ManagementHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.mux.ServeHTTP(writer, request)
}
func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.requirePermission(writer, request); !ok {
return
}
accounts, err := h.service.repository.ListIdentities(request.Context(), kind)
if err != nil {
h.writeError(writer, err)
return
}
items := make([]map[string]any, 0, len(accounts))
for _, account := range accounts {
items = append(items, managementView(account))
}
apiresponse.OK(writer, items)
}
}
func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, account, password, ok := h.decode(writer, request, kind, true)
if !ok {
return
}
_ = input
passwordHash, err := h.service.hasher.Hash(password)
if err != nil {
h.writeError(writer, err)
return
}
account.PasswordHash = passwordHash
created, err := h.service.repository.CreateIdentity(request.Context(), account, actor.ID)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, managementView(created))
}
}
func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
_, account, password, ok := h.decode(writer, request, kind, false)
if !ok {
return
}
account.ID = request.PathValue("identity_id")
current, err := h.service.findByID(request.Context(), kind, account.ID)
if err != nil {
h.writeError(writer, err)
return
}
if kind == KindAdmin && actor.ID == current.ID && (account.Role != current.Role || !account.Active) {
apiresponse.Error(writer, http.StatusConflict, "不能停用自身账号或修改自身角色")
return
}
var passwordHash *string
if password != "" {
hash, err := h.service.hasher.Hash(password)
if err != nil {
h.writeError(writer, err)
return
}
passwordHash = &hash
}
updated, err := h.service.repository.UpdateIdentity(request.Context(), account, passwordHash, actor.ID)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, managementView(updated))
}
}
func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) {
var input identityInput
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return input, Account{}, "", false
}
input.Login = strings.ToLower(strings.TrimSpace(input.Login))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
if input.Role == "" {
if kind == KindPortal {
input.Role = "member"
} else {
input.Role = "operator"
}
}
if len(input.Login) < 2 || len(input.Login) > 128 || len(input.DisplayName) > 64 {
apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效")
return input, Account{}, "", false
}
if kind == KindAdmin && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" {
apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效")
return input, Account{}, "", false
}
if kind == KindPortal && input.Role != "member" {
apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效")
return input, Account{}, "", false
}
permissions, err := normalizePermissions(input.Permissions)
if err != nil {
apiresponse.Error(writer, http.StatusBadRequest, err.Error())
return input, Account{}, "", false
}
password := ""
if input.Password != nil {
password = *input.Password
}
if creating && len(password) < 12 || password != "" && len(password) < 12 || len(password) > 1024 {
apiresponse.Error(writer, http.StatusBadRequest, "口令长度必须为 12 至 1024 个字符")
return input, Account{}, "", false
}
active := true
if input.Active != nil {
active = *input.Active
}
var departmentID *string
if input.DepartmentID != nil && strings.TrimSpace(*input.DepartmentID) != "" {
value := strings.TrimSpace(*input.DepartmentID)
department, err := h.service.repository.GetDepartment(request.Context(), value)
if err != nil || !department.Active {
apiresponse.Error(writer, http.StatusBadRequest, "所选部门不存在或已停用")
return input, Account{}, "", false
}
departmentID = &value
}
return input, Account{
Kind: kind, Login: input.Login, DisplayName: input.DisplayName,
Role: input.Role, Permissions: permissions, Active: active, DepartmentID: departmentID,
}, password, true
}
func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request) (Account, bool) {
account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
return Account{}, false
}
if !HasPermission(account, PermissionIdentityManage) {
apiresponse.Error(writer, http.StatusForbidden, "缺少身份管理权限")
return Account{}, false
}
return account, true
}
func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
apiresponse.Error(writer, http.StatusNotFound, "账号不存在")
case errors.Is(err, ErrIdentityConflict):
apiresponse.Error(writer, http.StatusConflict, "账号已存在")
case errors.Is(err, ErrUnavailable):
apiresponse.Error(writer, http.StatusServiceUnavailable, "身份管理服务暂不可用")
default:
apiresponse.Error(writer, http.StatusInternalServerError, "身份管理操作失败")
}
}
func normalizePermissions(values []string) ([]string, error) {
seen := make(map[string]struct{})
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "*" && !permissionPattern.MatchString(value) {
return nil, fmt.Errorf("权限字符串 %q 格式无效", value)
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
return result, nil
}
func managementView(account Account) map[string]any {
return map[string]any{
"id": account.ID, "kind": account.Kind, "login": account.Login,
"display_name": account.DisplayName, "role": account.Role,
"permissions": account.Permissions, "effective_permissions": EffectivePermissions(account),
"active": account.Active, "auth_source": account.AuthSource,
"totp_enabled": account.TOTPEnabled, "locked_until": account.LockedUntil,
"created_at": account.CreatedAt, "updated_at": account.UpdatedAt,
"department_id": account.DepartmentID, "department_name": account.DepartmentName,
}
}
func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
query := `
SELECT id::text, username, display_name, role, permissions, active,
totp_enabled, locked_until, 'local', created_at, updated_at
FROM gateway.admin_accounts ORDER BY lower(username)`
if kind == KindPortal {
query = `
SELECT u.id::text, u.account, u.name, u.role, u.permissions, u.active,
u.totp_enabled, u.locked_until, u.auth_source, u.created_at, u.updated_at,
u.department_id::text, COALESCE(d.name, '')
FROM gateway.portal_users u
LEFT JOIN gateway.departments d ON d.id = u.department_id
ORDER BY lower(u.account)`
}
rows, err := r.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
accounts := make([]Account, 0)
for rows.Next() {
account := Account{Kind: kind}
arguments := []any{
&account.ID, &account.Login, &account.DisplayName, &account.Role,
&account.Permissions, &account.Active, &account.TOTPEnabled,
&account.LockedUntil, &account.AuthSource, &account.CreatedAt, &account.UpdatedAt,
}
if kind == KindPortal {
arguments = append(arguments, &account.DepartmentID, &account.DepartmentName)
}
if err := rows.Scan(arguments...); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
accounts = append(accounts, account)
}
return accounts, mapRepositoryError(rows.Err())
}
func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorID string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
eventID, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
account.ID = id
tx, err := r.pool.Begin(ctx)
if err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if account.Kind == KindAdmin {
err = tx.QueryRow(ctx, `
INSERT INTO gateway.admin_accounts
(id, username, display_name, role, permissions, password_hash, active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName,
account.Role, account.Permissions, account.PasswordHash, account.Active,
).Scan(&account.CreatedAt, &account.UpdatedAt)
} else {
account.AuthSource = "local"
err = tx.QueryRow(ctx, `
INSERT INTO gateway.portal_users
(id, account, name, role, permissions, password_hash, auth_source, active, department_id)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8)
RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName,
account.Role, account.Permissions, account.PasswordHash, account.Active, account.DepartmentID,
).Scan(&account.CreatedAt, &account.UpdatedAt)
}
if err != nil {
return Account{}, mapManagementError(err)
}
payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, 'identity.created', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return account, nil
}
func (r *Repository) UpdateIdentity(ctx context.Context, account Account, passwordHash *string, actorID string) (Account, error) {
if r.pool == nil {
return Account{}, ErrUnavailable
}
eventID, err := platformid.NewUUID()
if err != nil {
return Account{}, err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer func() { _ = tx.Rollback(ctx) }()
if account.Kind == KindAdmin {
err = tx.QueryRow(ctx, `
UPDATE gateway.admin_accounts
SET username = $2, display_name = $3, role = $4, permissions = $5,
active = $6, password_hash = COALESCE($7, password_hash),
updated_at = clock_timestamp()
WHERE id = $1
RETURNING totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login,
account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash,
).Scan(&account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt)
} else {
account.AuthSource = "local"
err = tx.QueryRow(ctx, `
UPDATE gateway.portal_users
SET account = $2, name = $3, role = $4, permissions = $5,
active = $6, password_hash = COALESCE($7, password_hash), department_id = $8,
updated_at = clock_timestamp()
WHERE id = $1
RETURNING auth_source, totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login,
account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, account.DepartmentID,
).Scan(&account.AuthSource, &account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt)
}
if err != nil {
return Account{}, mapManagementError(err)
}
payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway.outbox_events
(event_id, event_type, event_version, aggregate_type, aggregate_id, payload)
VALUES ($1, 'identity.updated', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if err := tx.Commit(ctx); err != nil {
return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return account, nil
}
func mapManagementError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
var pgError *pgconn.PgError
if errors.As(err, &pgError) && pgError.Code == "23505" {
return ErrIdentityConflict
}
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}