This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
package opsdevice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccessDenied = errors.New("operator access denied")
|
||||
ErrInvalid = errors.New("invalid device management input")
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrVersionConflict = errors.New("device version conflict")
|
||||
ErrClaimUnavailable = errors.New("device claim is unavailable")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
credential *deviceauth.DatabaseSecretResolver
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision,omitempty"`
|
||||
Status string `json:"status"`
|
||||
MinimumSecurityVersion *string `json:"minimum_security_version,omitempty"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
|
||||
HouseholdID *string `json:"household_id,omitempty"`
|
||||
HouseholdName *string `json:"household_name,omitempty"`
|
||||
SubjectID *string `json:"subject_id,omitempty"`
|
||||
SubjectName *string `json:"subject_name,omitempty"`
|
||||
CredentialStatus string `json:"credential_status"`
|
||||
KeyVersion int64 `json:"key_version"`
|
||||
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type AssignmentOption struct {
|
||||
HouseholdID string `json:"household_id"`
|
||||
HouseholdName string `json:"household_name"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
SubjectName string `json:"subject_name"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Model string
|
||||
HardwareRevision string
|
||||
MinimumSecurityVersion string
|
||||
MinimumFirmwareVersion string
|
||||
HouseholdID string
|
||||
SubjectID string
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Model *string
|
||||
HardwareRevision *string
|
||||
MinimumSecurityVersion *string
|
||||
MinimumFirmwareVersion *string
|
||||
Status *string
|
||||
HouseholdID *string
|
||||
SubjectID *string
|
||||
}
|
||||
|
||||
type CredentialIssue struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Secret string `json:"secret"`
|
||||
KeyVersion int64 `json:"key_version"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
type CreateResult struct {
|
||||
Device Device `json:"device"`
|
||||
Credential CredentialIssue `json:"credential"`
|
||||
Claim *DeviceClaimIssue `json:"claim,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceClaimIssue struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ClaimCode string `json:"claim_code"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type operatorAccess struct {
|
||||
tenantID, tenantPublicID, userID, userPublicID, role string
|
||||
}
|
||||
|
||||
type queryRow interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, credential *deviceauth.DatabaseSecretResolver, auditRepository *audit.Repository) (*Service, error) {
|
||||
if pool == nil || credential == nil || auditRepository == nil {
|
||||
return nil, errors.New("operations device service dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, credential: credential, audit: auditRepository}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Device, error) {
|
||||
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, deviceSelectSQL+` WHERE d.tenant_id=$1::uuid ORDER BY d.registered_at DESC,d.public_id`, access.tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operations devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Device, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanDevice(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Options(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]AssignmentOption, error) {
|
||||
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name
|
||||
FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.tenant_id=$1::uuid AND h.status='active' AND sp.status IN ('active','suspended')
|
||||
ORDER BY h.name,sp.preferred_name`, access.tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device assignment options: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AssignmentOption, 0)
|
||||
for rows.Next() {
|
||||
var item AssignmentOption
|
||||
if err := rows.Scan(&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName); err != nil {
|
||||
return nil, fmt.Errorf("scan device assignment option: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input CreateInput) (CreateResult, error) {
|
||||
input.Model = strings.TrimSpace(input.Model)
|
||||
input.HardwareRevision = strings.TrimSpace(input.HardwareRevision)
|
||||
input.MinimumSecurityVersion = strings.TrimSpace(input.MinimumSecurityVersion)
|
||||
input.MinimumFirmwareVersion = strings.TrimSpace(input.MinimumFirmwareVersion)
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 || len([]rune(input.HardwareRevision)) > 80 ||
|
||||
len([]rune(input.MinimumSecurityVersion)) > 80 || len([]rune(input.MinimumFirmwareVersion)) > 80 ||
|
||||
((input.HouseholdID == "") != (input.SubjectID == "")) {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
secretBytes := make([]byte, 32)
|
||||
serialBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("generate device secret: %w", err)
|
||||
}
|
||||
if _, err := rand.Read(serialBytes); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("generate device serial material: %w", err)
|
||||
}
|
||||
secret := hex.EncodeToString(secretBytes)
|
||||
serialDigest := sha256.Sum256(serialBytes)
|
||||
publicID := uuid.NewString()
|
||||
credentialRef := "device-secret://" + publicID
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return CreateResult{}, fmt.Errorf("begin device registration: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if input.HouseholdID != "" {
|
||||
if err := validateAssignment(ctx, tx, access.tenantID, input.HouseholdID, input.SubjectID); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
}
|
||||
var deviceID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO device(public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,NULLIF($5,''),'registered',$6,NULLIF($7,''),NULLIF($8,'')) RETURNING id::text`,
|
||||
publicID, access.tenantID, hex.EncodeToString(serialDigest[:]), input.Model, input.HardwareRevision, credentialRef,
|
||||
input.MinimumSecurityVersion, input.MinimumFirmwareVersion).Scan(&deviceID)
|
||||
if err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id) VALUES($1::uuid,$2::uuid)`, deviceID, access.tenantID); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device twin: %w", err)
|
||||
}
|
||||
if input.HouseholdID != "" {
|
||||
if err := insertBinding(ctx, tx, access, deviceID, input.HouseholdID, input.SubjectID, "运维端登记设备"); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
}
|
||||
keyVersion, err := s.credential.ProvisionTx(ctx, tx, publicID, []byte(secret), false)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.create", ResourceType: "device", ResourceID: &publicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"model": input.Model, "assigned": input.HouseholdID != "", "key_version": keyVersion}}); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
var claim *DeviceClaimIssue
|
||||
if input.HouseholdID == "" {
|
||||
issued, err := s.issueDeviceClaimTx(ctx, tx, access, publicID, deviceID, requestID)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
claim = &issued
|
||||
}
|
||||
item, err := readDevice(ctx, tx, access.tenantID, publicID)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("commit device registration: %w", err)
|
||||
}
|
||||
return CreateResult{Device: item, Credential: CredentialIssue{DeviceID: publicID, Secret: secret, KeyVersion: keyVersion, IssuedAt: time.Now().UTC()}, Claim: claim}, nil
|
||||
}
|
||||
|
||||
func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string) (DeviceClaimIssue, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("begin device claim issue: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return DeviceClaimIssue{}, err
|
||||
}
|
||||
var deviceID, status string
|
||||
var bound bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT d.id::text,d.status,EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL)
|
||||
FROM device d WHERE d.public_id=$1::uuid AND d.tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, &status, &bound); errors.Is(err, pgx.ErrNoRows) {
|
||||
return DeviceClaimIssue{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("lock device for claim issue: %w", err)
|
||||
}
|
||||
if status != "registered" || bound {
|
||||
return DeviceClaimIssue{}, ErrClaimUnavailable
|
||||
}
|
||||
issued, err := s.issueDeviceClaimTx(ctx, tx, access, devicePublicID, deviceID, requestID)
|
||||
if err != nil {
|
||||
return DeviceClaimIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("commit device claim issue: %w", err)
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
func (s *Service) issueDeviceClaimTx(ctx context.Context, tx pgx.Tx, access operatorAccess, devicePublicID, deviceID, requestID string) (DeviceClaimIssue, error) {
|
||||
randomBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("generate device claim: %w", err)
|
||||
}
|
||||
rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
|
||||
claimCode := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-")
|
||||
digest := sha256.Sum256([]byte(claimCode))
|
||||
issuedAt := time.Now().UTC()
|
||||
expiresAt := issuedAt.Add(24 * time.Hour)
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=$1 WHERE device_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`, issuedAt, deviceID); err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("revoke previous device claim: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_claim(tenant_id,device_id,token_digest,expires_at,created_at) VALUES($1::uuid,$2::uuid,$3,$4,$5)`, access.tenantID, deviceID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil {
|
||||
return DeviceClaimIssue{}, fmt.Errorf("insert device claim: %w", err)
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.claim.issue", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"expires_at": expiresAt}}); err != nil {
|
||||
return DeviceClaimIssue{}, err
|
||||
}
|
||||
return DeviceClaimIssue{DeviceID: devicePublicID, ClaimCode: claimCode, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, expectedVersion int64, input UpdateInput) (Device, error) {
|
||||
if expectedVersion < 1 || (input.Model == nil && input.HardwareRevision == nil && input.MinimumSecurityVersion == nil && input.MinimumFirmwareVersion == nil && input.Status == nil && input.HouseholdID == nil && input.SubjectID == nil) {
|
||||
return Device{}, ErrInvalid
|
||||
}
|
||||
if (input.HouseholdID == nil) != (input.SubjectID == nil) {
|
||||
return Device{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Device{}, fmt.Errorf("begin device update: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
var deviceID string
|
||||
var currentVersion int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,version FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, ¤tVersion); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return Device{}, fmt.Errorf("lock device: %w", err)
|
||||
}
|
||||
if currentVersion != expectedVersion {
|
||||
return Device{}, ErrVersionConflict
|
||||
}
|
||||
model, modelSet := pointerValue(input.Model)
|
||||
hardware, hardwareSet := pointerValue(input.HardwareRevision)
|
||||
security, securitySet := pointerValue(input.MinimumSecurityVersion)
|
||||
firmware, firmwareSet := pointerValue(input.MinimumFirmwareVersion)
|
||||
status, statusSet := pointerValue(input.Status)
|
||||
if (modelSet && (model == "" || len([]rune(model)) > 120)) || len([]rune(hardware)) > 80 || len([]rune(security)) > 80 || len([]rune(firmware)) > 80 ||
|
||||
(statusSet && status != "registered" && status != "quarantined") {
|
||||
return Device{}, ErrInvalid
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET
|
||||
model=CASE WHEN $3 THEN $4 ELSE model END,
|
||||
hardware_revision=CASE WHEN $5 THEN NULLIF($6,'') ELSE hardware_revision END,
|
||||
minimum_security_version=CASE WHEN $7 THEN NULLIF($8,'') ELSE minimum_security_version END,
|
||||
minimum_firmware_version=CASE WHEN $9 THEN NULLIF($10,'') ELSE minimum_firmware_version END,
|
||||
status=CASE WHEN $11 THEN $12 ELSE status END,version=version+1,updated_at=now()
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid`, deviceID, access.tenantID, modelSet, model, hardwareSet, hardware, securitySet, security, firmwareSet, firmware, statusSet, status); err != nil {
|
||||
return Device{}, fmt.Errorf("update device: %w", err)
|
||||
}
|
||||
if input.HouseholdID != nil {
|
||||
householdID := strings.TrimSpace(*input.HouseholdID)
|
||||
subjectID := strings.TrimSpace(*input.SubjectID)
|
||||
if (householdID == "") != (subjectID == "") {
|
||||
return Device{}, ErrInvalid
|
||||
}
|
||||
if householdID != "" {
|
||||
if err := validateAssignment(ctx, tx, access.tenantID, householdID, subjectID); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='运维端变更归属' WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID, access.userID); err != nil {
|
||||
return Device{}, fmt.Errorf("close device binding: %w", err)
|
||||
}
|
||||
if householdID != "" {
|
||||
if err := insertBinding(ctx, tx, access, deviceID, householdID, subjectID, "运维端变更归属"); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke device claims after assignment: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.update", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"from_version": currentVersion, "to_version": currentVersion + 1}}); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Device{}, fmt.Errorf("commit device update: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string, expectedVersion int64) (Device, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Device{}, fmt.Errorf("begin device retirement: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
var deviceID string
|
||||
var version int64
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,version FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&deviceID, &version); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return Device{}, fmt.Errorf("lock device for retirement: %w", err)
|
||||
}
|
||||
if version != expectedVersion {
|
||||
return Device{}, ErrVersionConflict
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET status='revoked',revoked_at=COALESCE(revoked_at,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("retire device: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_credential SET status='revoked',revoked_at=COALESCE(revoked_at,now()) WHERE device_id=$1::uuid`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke device credential: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='运维端删除设备' WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID, access.userID); err != nil {
|
||||
return Device{}, fmt.Errorf("close retired device binding: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke retired device claims: %w", err)
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.delete", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"soft_delete": true, "credential_revoked": true}}); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
item, err := readDevice(ctx, tx, access.tenantID, devicePublicID)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Device{}, fmt.Errorf("commit device retirement: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) RotateCredential(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string) (CredentialIssue, error) {
|
||||
secretBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("generate device credential: %w", err)
|
||||
}
|
||||
secret := hex.EncodeToString(secretBytes)
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("begin credential rotation: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
|
||||
if err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
version, err := s.credential.ProvisionTx(ctx, tx, devicePublicID, []byte(secret), true)
|
||||
if errors.Is(err, deviceauth.ErrDeviceNotFound) {
|
||||
return CredentialIssue{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.credential.rotate", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"key_version": version}}); err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("commit credential rotation: %w", err)
|
||||
}
|
||||
return CredentialIssue{DeviceID: devicePublicID, Secret: secret, KeyVersion: version, IssuedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
func requireAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (operatorAccess, error) {
|
||||
var access operatorAccess
|
||||
err := q.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role
|
||||
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' AND ua.account_kind='operator'
|
||||
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).
|
||||
Scan(&access.tenantID, &access.tenantPublicID, &access.userID, &access.userPublicID, &access.role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return operatorAccess{}, ErrAccessDenied
|
||||
}
|
||||
if err != nil {
|
||||
return operatorAccess{}, fmt.Errorf("resolve operations device access: %w", err)
|
||||
}
|
||||
if mutate && access.role != "ops_operator" && access.role != "ops_admin" {
|
||||
return operatorAccess{}, ErrAccessDenied
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
func validateAssignment(ctx context.Context, q queryRow, tenantID, householdPublicID, subjectPublicID string) error {
|
||||
var exists bool
|
||||
err := q.QueryRow(ctx, `SELECT true FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id=$1::uuid AND sp.public_id=$2::uuid AND h.tenant_id=$3::uuid AND h.status='active' AND sp.status IN ('active','suspended')`,
|
||||
householdPublicID, subjectPublicID, tenantID).Scan(&exists)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate device assignment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertBinding(ctx context.Context, tx pgx.Tx, access operatorAccess, deviceID, householdPublicID, subjectPublicID, reason string) error {
|
||||
_, err := tx.Exec(ctx, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason)
|
||||
SELECT $1::uuid,$2::uuid,h.id,sp.id,$5::uuid,$6 FROM household h JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id
|
||||
WHERE h.public_id=$3::uuid AND sp.public_id=$4::uuid AND h.tenant_id=$1::uuid`,
|
||||
access.tenantID, deviceID, householdPublicID, subjectPublicID, access.userID, reason)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert device binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pointerValue(value *string) (string, bool) {
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(*value), true
|
||||
}
|
||||
|
||||
const deviceSelectSQL = `
|
||||
SELECT d.public_id::text,t.public_id::text,d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,
|
||||
h.public_id::text,h.name,sp.public_id::text,sp.preferred_name,COALESCE(dc.status,'missing'),COALESCE(dc.key_version,0),
|
||||
dt.last_reported_at,d.registered_at,d.updated_at,d.version
|
||||
FROM device d JOIN tenant t ON t.id=d.tenant_id
|
||||
LEFT JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
LEFT JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL
|
||||
LEFT JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
|
||||
LEFT JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
|
||||
LEFT JOIN device_credential dc ON dc.device_id=d.id AND dc.tenant_id=d.tenant_id`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDevice(row scanner) (Device, error) {
|
||||
var item Device
|
||||
if err := row.Scan(&item.ID, &item.TenantID, &item.Model, &item.HardwareRevision, &item.Status, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion,
|
||||
&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName, &item.CredentialStatus, &item.KeyVersion,
|
||||
&item.LastReportedAt, &item.RegisteredAt, &item.UpdatedAt, &item.Version); err != nil {
|
||||
return Device{}, fmt.Errorf("scan operations device: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func readDevice(ctx context.Context, q queryRow, tenantID, publicID string) (Device, error) {
|
||||
item, err := scanDevice(q.QueryRow(ctx, deviceSelectSQL+` WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid`, tenantID, publicID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package opsdevice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestOperationsDeviceLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(pool, resolver, audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := integrationOperatorPrincipal(t, ctx, pool)
|
||||
const tenantID = "10000000-0000-0000-0000-000000000010"
|
||||
created, err := service.Create(ctx, principal, tenantID, "ops-device-integration-create", CreateInput{
|
||||
Model: "integration-terminal", HardwareRevision: "rev-test",
|
||||
HouseholdID: "10000000-0000-0000-0000-000000000211", SubjectID: "10000000-0000-0000-0000-000000000411",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceID := created.Device.ID
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE public_id=$1::uuid`, deviceID)
|
||||
}()
|
||||
if len(created.Credential.Secret) != 64 || created.Credential.KeyVersion != 1 || created.Device.SubjectName == nil || *created.Device.SubjectName != "测试长者" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
if created.Claim != nil {
|
||||
t.Fatalf("assigned device unexpectedly received claim: %+v", created.Claim)
|
||||
}
|
||||
if _, err := service.IssueDeviceClaim(ctx, principal, tenantID, deviceID, "ops-device-integration-bound-claim"); !errors.Is(err, ErrClaimUnavailable) {
|
||||
t.Fatalf("bound device claim error=%v", err)
|
||||
}
|
||||
resolved, err := resolver.Resolve(ctx, "device-secret://"+deviceID)
|
||||
if err != nil || !bytes.Equal(resolved, []byte(created.Credential.Secret)) {
|
||||
t.Fatalf("created credential did not resolve: err=%v", err)
|
||||
}
|
||||
model := "integration-terminal-v2"
|
||||
status := "quarantined"
|
||||
updated, err := service.Update(ctx, principal, tenantID, deviceID, "ops-device-integration-update", created.Device.Version, UpdateInput{Model: &model, Status: &status})
|
||||
if err != nil || updated.Model != model || updated.Status != status || updated.Version != created.Device.Version+1 {
|
||||
t.Fatalf("unexpected update: %+v err=%v", updated, err)
|
||||
}
|
||||
rotated, err := service.RotateCredential(ctx, principal, tenantID, deviceID, "ops-device-integration-rotate")
|
||||
if err != nil || rotated.KeyVersion != 2 || rotated.Secret == created.Credential.Secret {
|
||||
t.Fatalf("unexpected credential rotation: %+v err=%v", rotated, err)
|
||||
}
|
||||
resolved, err = resolver.Resolve(ctx, "device-secret://"+deviceID)
|
||||
if err != nil || !bytes.Equal(resolved, []byte(rotated.Secret)) {
|
||||
t.Fatalf("rotated credential did not resolve: err=%v", err)
|
||||
}
|
||||
retired, err := service.Delete(ctx, principal, tenantID, deviceID, "ops-device-integration-delete", updated.Version)
|
||||
if err != nil || retired.Status != "revoked" || retired.CredentialStatus != "revoked" || retired.HouseholdID != nil {
|
||||
t.Fatalf("unexpected delete result: %+v err=%v", retired, err)
|
||||
}
|
||||
if _, err := resolver.Resolve(ctx, "device-secret://"+deviceID); err == nil {
|
||||
t.Fatal("retired device credential remained active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsDeviceClaimIssueAndRotation(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
resolver, err := deviceauth.NewDatabaseSecretResolver(pool, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(pool, resolver, audit.NewRepository())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := integrationOperatorPrincipal(t, ctx, pool)
|
||||
const tenantID = "10000000-0000-0000-0000-000000000010"
|
||||
created, err := service.Create(ctx, principal, tenantID, "ops-device-claim-integration-create", CreateInput{Model: "unbound-integration-terminal"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceID := created.Device.ID
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_credential WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE public_id=$1::uuid`, deviceID)
|
||||
}()
|
||||
if created.Claim == nil || len(created.Claim.ClaimCode) != 19 || !created.Claim.ExpiresAt.After(created.Claim.IssuedAt) {
|
||||
t.Fatalf("unexpected initial claim: %+v", created.Claim)
|
||||
}
|
||||
firstDigest := sha256.Sum256([]byte(created.Claim.ClaimCode))
|
||||
var storedDigest string
|
||||
if err := pool.QueryRow(ctx, `SELECT token_digest FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid) AND revoked_at IS NULL`, deviceID).Scan(&storedDigest); err != nil || storedDigest != hex.EncodeToString(firstDigest[:]) {
|
||||
t.Fatalf("stored initial claim digest=%s err=%v", storedDigest, err)
|
||||
}
|
||||
rotated, err := service.IssueDeviceClaim(ctx, principal, tenantID, deviceID, "ops-device-claim-integration-rotate")
|
||||
if err != nil || rotated.ClaimCode == created.Claim.ClaimCode {
|
||||
t.Fatalf("rotated claim=%+v err=%v", rotated, err)
|
||||
}
|
||||
var activeClaims, revokedClaims int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FILTER (WHERE revoked_at IS NULL),count(*) FILTER (WHERE revoked_at IS NOT NULL) FROM device_claim WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&activeClaims, &revokedClaims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activeClaims != 1 || revokedClaims != 1 {
|
||||
t.Fatalf("claim counts active=%d revoked=%d", activeClaims, revokedClaims)
|
||||
}
|
||||
}
|
||||
|
||||
func integrationOperatorPrincipal(t *testing.T, ctx context.Context, pool *pgxpool.Pool) identity.Principal {
|
||||
t.Helper()
|
||||
var principal identity.Principal
|
||||
if err := pool.QueryRow(ctx, `SELECT oidc_issuer,oidc_subject FROM user_account WHERE public_id='10000000-0000-0000-0000-000000000114'::uuid AND status='active' AND account_kind='operator'`).Scan(&principal.Issuer, &principal.Subject); err != nil {
|
||||
t.Fatalf("resolve integration operator: %v", err)
|
||||
}
|
||||
return principal
|
||||
}
|
||||
Reference in New Issue
Block a user