B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
8.2 KiB
Go
217 lines
8.2 KiB
Go
// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
|
||
//
|
||
// 「註冊」語意軸(feature-device-mgmt-tdd §3 / §4,api/api-device-mgmt.md):
|
||
// - register:把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
|
||
// - unregister:把 registered_at 清成 NULL(退回未註冊),**保留裝置列**。
|
||
//
|
||
// 🔴 與 unpair 完全不同(TDD §1 紅線):unpair 軟刪整台 + cascade 撤 token(device 從清單
|
||
// 消失);unregister 只清單欄 registered_at(device 仍在清單、顯示為未註冊)。兩端點各走各的,
|
||
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token,也**不改** devicesUnpairHandler。
|
||
//
|
||
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent),用 UUID `:id` 識別
|
||
// (對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。
|
||
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"visiona-backend/internal/device"
|
||
)
|
||
|
||
// deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5,兩端點一致):
|
||
//
|
||
// 1. 缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough)
|
||
// 2. :id 空 → 400 VALIDATION_FAILED
|
||
// 3. Get device:ErrNotFound → 404;其他 DB error → WriteDBError
|
||
// 4. owner 檢查(IDOR 主防線):d.OwnerUserID != userID → 403 FORBIDDEN
|
||
// 5. representative 檢查:d.IsRepresentative → 409 REPRESENTATIVE_DEVICE
|
||
//
|
||
// 回傳 (device, userID, ok);ok=false 時已寫好回應,caller 直接 return。
|
||
//
|
||
// owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略(TDD §7.2 IDOR)。
|
||
func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) {
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
|
||
return nil, "", false
|
||
}
|
||
|
||
// Phase 0.7 security fix C1:強制要求 UserContext 非空(見既有 devices.go 範式)。
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return nil, "", false
|
||
}
|
||
userID := uc.UserID
|
||
|
||
d, err := deps.DeviceRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return nil, "", false
|
||
}
|
||
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get device", err)
|
||
return nil, "", false
|
||
}
|
||
|
||
// owner 檢查(IDOR 主防線,TDD §7.1/§7.2):沿用既有 handler 慣例(devices.go:197-201)。
|
||
if d.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil)
|
||
return nil, "", false
|
||
}
|
||
|
||
// representative 檢查(TDD §7.3):representative 是 agent 連線佔位、非真 USB,
|
||
// 註冊語意不適用。縱深——即使 List 已濾掉 representative(前端拿不到其 UUID),
|
||
// handler 仍自己擋;repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。
|
||
if d.IsRepresentative {
|
||
WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice,
|
||
"representative device cannot be registered", nil)
|
||
return nil, "", false
|
||
}
|
||
|
||
return d, userID, true
|
||
}
|
||
|
||
// devicesRegisterHandler 實作 POST /api/devices/:id/register。
|
||
//
|
||
// 行為順序(api-device-mgmt.md §1):共用前置(1-5)→ 已註冊檢查(6,409 ALREADY_REGISTERED)
|
||
// → SetRegistered(now())(7)→ 200 + 更新後 DeviceListItem(registered_at 非 null)。
|
||
func devicesRegisterHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.DeviceRepo == nil {
|
||
WriteNotImplemented(c, "device repo not configured")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// 已註冊檢查(TDD §3.2):registered_at 非 nil → 409 ALREADY_REGISTERED。
|
||
// 前端據此顯示「此裝置已註冊」並 refetch。
|
||
if d.RegisteredAt != nil {
|
||
WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered,
|
||
"device already registered", nil)
|
||
return
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
// 競態:Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
WriteDBError(c, deps.Logger, "register device", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("devices: registered",
|
||
"device_id", d.ID,
|
||
"user_id", userID,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||
}
|
||
}
|
||
|
||
// devicesUnregisterHandler 實作 POST /api/devices/:id/unregister(退回未註冊)。
|
||
//
|
||
// 行為順序(api-device-mgmt.md §2):共用前置(1-5)→ SetRegistered(nil)(冪等,未註冊也回 200)
|
||
// → 200 + 更新後 DeviceListItem(registered_at=null)。
|
||
//
|
||
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(TDD §1.2)。與 unpair 各走各的。
|
||
func devicesUnregisterHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.DeviceRepo == nil {
|
||
WriteNotImplemented(c, "device repo not configured")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
// 冪等(TDD §4.1 步驟 2):不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL
|
||
// 的列 UPDATE 到相同值、RowsAffected 仍為 1(WHERE 命中),避免使用者連點兩次第二次報錯。
|
||
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
WriteDBError(c, deps.Logger, "unregister device", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("devices: unregistered",
|
||
"device_id", d.ID,
|
||
"user_id", userID,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||
}
|
||
}
|
||
|
||
// writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。
|
||
//
|
||
// 為什麼重新 Get 而非就地拼裝:SetRegistered 只回 error,最新的 registered_at / updated_at
|
||
// 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。
|
||
//
|
||
// register/unregister 後 device 必然存在(剛剛才 UPDATE 成功),Get 理論上不會 NotFound;
|
||
// 若極端競態下被刪,回 404(不 panic)。
|
||
func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) {
|
||
d, err := deps.DeviceRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
WriteDBError(c, deps.Logger, "get device after register", err)
|
||
return
|
||
}
|
||
|
||
// tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get,避免被前面 DB 呼叫吃掉 → R-3 誤判)。
|
||
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer tunnelCancel()
|
||
tunnelAlive, lastSeen := resolveTunnelStatus(
|
||
tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", RequestIDFrom(c))
|
||
|
||
item := DeviceListItem{
|
||
ID: d.ID,
|
||
Name: d.Name,
|
||
DeviceType: d.DeviceType,
|
||
SerialNumber: d.SerialNumber,
|
||
AgentID: d.AgentID,
|
||
RegisteredAt: d.RegisteredAt,
|
||
RemoteStatus: d.RemoteStatus,
|
||
LastSeenAt: d.LastSeenAt,
|
||
LastConnectedAt: d.LastConnectedAt,
|
||
USBStatus: d.Status,
|
||
TunnelOnline: tunnelAlive,
|
||
CreatedAt: d.CreatedAt,
|
||
UpdatedAt: d.UpdatedAt,
|
||
}
|
||
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
|
||
ls := lastSeen
|
||
item.LastSeenAt = &ls
|
||
}
|
||
|
||
WriteSuccess(c, http.StatusOK, item)
|
||
}
|