Files
story/internal/hardwarelicense/service.go
T

362 lines
18 KiB
Go
Raw Normal View History

2026-09-06 17:04:11 +08:00
package hardwarelicense
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalid = errors.New("invalid hardware license input")
ErrAccessDenied = errors.New("hardware license access denied")
ErrNotFound = errors.New("hardware license not found")
ErrUnavailable = errors.New("no hardware license is available")
ErrAssignment = errors.New("hardware license assignment mismatch")
ErrExpired = errors.New("hardware license expired")
ErrAcknowledgement = errors.New("hardware license acknowledgement invalid")
ErrVersionConflict = errors.New("hardware license version conflict")
)
var referencePattern = regexp.MustCompile(`^[a-zA-Z0-9_\-.:@/]{1,200}$`)
type Service struct {
pool *pgxpool.Pool
audit *audit.Repository
aead cipher.AEAD
}
type ImportInput struct {
LicenseRef string
Payload string
InstanceID string
ProductKey string
ExpiresAt time.Time
}
type Item struct {
ID string `json:"id"`
LicenseRef string `json:"license_ref"`
Provider string `json:"provider"`
InstanceID string `json:"instance_id"`
ProductKey string `json:"product_key"`
Status string `json:"status"`
AllocatedDeviceID *string `json:"allocated_device_id,omitempty"`
AllocatedAt *time.Time `json:"allocated_at,omitempty"`
ActivatedAt *time.Time `json:"activated_at,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
LastErrorCode *string `json:"last_error_code,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
type Delivery struct {
LicenseRef string `json:"license_ref"`
License string `json:"license"`
Digest string `json:"digest"`
ExpiresAt time.Time `json:"expires_at"`
Status string `json:"status"`
}
type AcknowledgeInput struct {
LicenseRef string
Digest string
Result string
ErrorCode string
}
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, masterKey string) (*Service, error) {
if pool == nil || auditRepository == nil {
return nil, errors.New("hardware license service dependencies are required")
}
key, err := hex.DecodeString(strings.TrimSpace(masterKey))
if err != nil || len(key) != 32 {
return nil, errors.New("hardware license master key must be 32 bytes encoded as 64 hexadecimal characters")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create hardware license cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create hardware license AEAD: %w", err)
}
return &Service{pool: pool, audit: auditRepository, aead: aead}, nil
}
func (s *Service) Import(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input ImportInput) (Item, error) {
input.LicenseRef = strings.TrimSpace(input.LicenseRef)
input.Payload = strings.TrimSpace(input.Payload)
input.InstanceID = strings.TrimSpace(input.InstanceID)
input.ProductKey = strings.TrimSpace(input.ProductKey)
if !referencePattern.MatchString(input.LicenseRef) || input.InstanceID == "" || len(input.InstanceID) > 200 || input.ProductKey == "" || len(input.ProductKey) > 200 ||
len(input.Payload) < 8 || len(input.Payload) > 65536 || !input.ExpiresAt.After(time.Now().UTC()) {
return Item{}, ErrInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Item{}, err
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return Item{}, err
}
digestBytes := sha256.Sum256([]byte(input.Payload))
digest := hex.EncodeToString(digestBytes[:])
nonce := make([]byte, s.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return Item{}, err
}
encrypted := s.aead.Seal(nil, nonce, []byte(input.Payload), licenseAAD(input.LicenseRef, input.InstanceID, input.ProductKey, input.ExpiresAt))
var item Item
err = tx.QueryRow(ctx, `INSERT INTO hardware_agent_license(
tenant_id,provider,instance_id,product_key,license_ref,payload_digest,encrypted_payload,nonce,status,expires_at
) VALUES($1::uuid,'volcengine',$2,$3,$4,$5,$6,$7,'AVAILABLE',$8)
RETURNING public_id::text,license_ref,provider,instance_id,product_key,status,NULL::text,allocated_at,activated_at,expires_at,last_error_code,created_at,updated_at,version`,
access.tenantID, input.InstanceID, input.ProductKey, input.LicenseRef, digest, encrypted, nonce, input.ExpiresAt).
Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version)
if err != nil {
return Item{}, fmt.Errorf("import hardware license: %w", err)
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.hardware_license.import", ResourceType: "hardware_agent_license", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": item.LicenseRef, "product_key": item.ProductKey, "expires_at": item.ExpiresAt, "payload_digest": digest}}); err != nil {
return Item{}, err
}
if err := tx.Commit(ctx); err != nil {
return Item{}, err
}
return item, nil
}
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Item, error) {
access, err := requireAccess(ctx, s.pool, principal, tenantPublicID, false)
if err != nil {
return nil, err
}
_, _ = s.pool.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE tenant_id=$1::uuid AND expires_at<=now() AND status IN ('AVAILABLE','ALLOCATED','ACTIVE','SUSPENDED')`, access.tenantID)
rows, err := s.pool.Query(ctx, `SELECT l.public_id::text,l.license_ref,l.provider,l.instance_id,l.product_key,l.status,d.public_id::text,l.allocated_at,l.activated_at,l.expires_at,l.last_error_code,l.created_at,l.updated_at,l.version FROM hardware_agent_license l LEFT JOIN device d ON d.id=l.allocated_device_id WHERE l.tenant_id=$1::uuid ORDER BY l.created_at DESC`, access.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Item, 0)
for rows.Next() {
var item Item
if err := rows.Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64) (Item, error) {
return s.transitionInventory(ctx, principal, tenantPublicID, licensePublicID, requestID, expectedVersion, "REVOKED")
}
func (s *Service) Release(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64) (Item, error) {
return s.transitionInventory(ctx, principal, tenantPublicID, licensePublicID, requestID, expectedVersion, "AVAILABLE")
}
func (s *Service) transitionInventory(ctx context.Context, principal identity.Principal, tenantPublicID, licensePublicID, requestID string, expectedVersion int64, target string) (Item, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return Item{}, err
}
defer tx.Rollback(ctx)
access, err := requireAccess(ctx, tx, principal, tenantPublicID, true)
if err != nil {
return Item{}, err
}
var internalID, status string
var version int64
var deviceID *string
if err := tx.QueryRow(ctx, `SELECT id::text,status,version,allocated_device_id::text FROM hardware_agent_license WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, licensePublicID, access.tenantID).Scan(&internalID, &status, &version, &deviceID); errors.Is(err, pgx.ErrNoRows) {
return Item{}, ErrNotFound
} else if err != nil {
return Item{}, err
}
if version != expectedVersion {
return Item{}, ErrVersionConflict
}
if target == "AVAILABLE" && (status != "SUSPENDED" || deviceID == nil) {
return Item{}, ErrAssignment
}
if target == "AVAILABLE" && deviceID != nil {
var bound bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL)`, *deviceID).Scan(&bound); err != nil {
return Item{}, err
}
if bound {
return Item{}, ErrAssignment
}
}
if target == "REVOKED" && status == "REVOKED" {
return Item{}, ErrAssignment
}
if target == "REVOKED" && deviceID != nil {
var activationState string
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, *deviceID, access.tenantID).Scan(&activationState); err != nil {
return Item{}, err
}
if activationState == string(deviceactivation.Active) {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: *deviceID, TenantID: access.tenantID,
Target: deviceactivation.Provisioning, Reason: "硬件智能体License已吊销", Source: "license-revoke", ActorType: "user",
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
return Item{}, err
}
}
}
var item Item
query := `UPDATE hardware_agent_license SET status=$3,
allocated_device_id=CASE WHEN $3='AVAILABLE' THEN NULL ELSE allocated_device_id END,
allocated_at=CASE WHEN $3='AVAILABLE' THEN NULL ELSE allocated_at END,
activated_at=CASE WHEN $3='AVAILABLE' THEN NULL ELSE activated_at END,
last_error_code=CASE WHEN $3='AVAILABLE' THEN NULL ELSE last_error_code END,
released_at=CASE WHEN $3='AVAILABLE' THEN now() ELSE released_at END,
revoked_at=CASE WHEN $3='REVOKED' THEN now() ELSE revoked_at END,
updated_at=now(),version=version+1
WHERE id=$1::uuid AND tenant_id=$2::uuid
RETURNING public_id::text,license_ref,provider,instance_id,product_key,status,NULL::text,allocated_at,activated_at,expires_at,last_error_code,created_at,updated_at,version`
if err := tx.QueryRow(ctx, query, internalID, access.tenantID, target).Scan(&item.ID, &item.LicenseRef, &item.Provider, &item.InstanceID, &item.ProductKey, &item.Status, &item.AllocatedDeviceID, &item.AllocatedAt, &item.ActivatedAt, &item.ExpiresAt, &item.LastErrorCode, &item.CreatedAt, &item.UpdatedAt, &item.Version); err != nil {
return Item{}, err
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "ops.hardware_license." + strings.ToLower(target), ResourceType: "hardware_agent_license", ResourceID: &item.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": item.LicenseRef, "previous_status": status}}); err != nil {
return Item{}, err
}
if err := tx.Commit(ctx); err != nil {
return Item{}, err
}
return item, nil
}
func (s *Service) AcquireForDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID, instanceID, productKey string) (Delivery, error) {
_, _ = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE tenant_id=$1::uuid AND expires_at<=now() AND status IN ('AVAILABLE','ALLOCATED','ACTIVE','SUSPENDED')`, tenantID)
var internalID, ref, digest, status string
var encrypted, nonce []byte
var expires time.Time
err := tx.QueryRow(ctx, `SELECT id::text,license_ref,payload_digest,status,encrypted_payload,nonce,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id=$2::uuid AND instance_id=$3 AND product_key=$4 AND status IN ('ALLOCATED','ACTIVE','SUSPENDED') FOR UPDATE`, tenantID, deviceID, instanceID, productKey).Scan(&internalID, &ref, &digest, &status, &encrypted, &nonce, &expires)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, `SELECT id::text,license_ref,payload_digest,status,encrypted_payload,nonce,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id IS NULL AND instance_id=$2 AND product_key=$3 AND status='AVAILABLE' AND expires_at>now() ORDER BY expires_at,id FOR UPDATE SKIP LOCKED LIMIT 1`, tenantID, instanceID, productKey).Scan(&internalID, &ref, &digest, &status, &encrypted, &nonce, &expires)
}
if errors.Is(err, pgx.ErrNoRows) {
return Delivery{}, ErrUnavailable
}
if err != nil {
return Delivery{}, err
}
payload, err := s.aead.Open(nil, nonce, encrypted, licenseAAD(ref, instanceID, productKey, expires))
if err != nil {
return Delivery{}, errors.New("decrypt hardware license")
}
if status != "ACTIVE" {
status = "ALLOCATED"
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_license SET allocated_device_id=$2::uuid,status=$3,
allocated_at=COALESCE(allocated_at,now()),released_at=NULL,last_error_code=NULL,updated_at=now(),version=version+1
WHERE id=$1::uuid`, internalID, deviceID, status); err != nil {
return Delivery{}, err
}
return Delivery{LicenseRef: ref, License: string(payload), Digest: digest, ExpiresAt: expires, Status: status}, nil
}
func (s *Service) Acknowledge(ctx context.Context, identity deviceauth.Identity, requestID string, input AcknowledgeInput) error {
input.LicenseRef = strings.TrimSpace(input.LicenseRef)
input.Digest = strings.ToLower(strings.TrimSpace(input.Digest))
input.Result = strings.ToUpper(strings.TrimSpace(input.Result))
input.ErrorCode = strings.TrimSpace(input.ErrorCode)
if !referencePattern.MatchString(input.LicenseRef) || !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(input.Digest) || (input.Result != "INSTALLED" && input.Result != "FAILED") || len(input.ErrorCode) > 100 {
return ErrAcknowledgement
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var id, publicID, digest, status string
var expires time.Time
if err := tx.QueryRow(ctx, `SELECT id::text,public_id::text,payload_digest,status,expires_at FROM hardware_agent_license WHERE tenant_id=$1::uuid AND allocated_device_id=$2::uuid AND license_ref=$3 FOR UPDATE`, identity.TenantID, identity.ID, input.LicenseRef).Scan(&id, &publicID, &digest, &status, &expires); errors.Is(err, pgx.ErrNoRows) {
return ErrAssignment
} else if err != nil {
return err
}
if !expires.After(time.Now().UTC()) {
_, _ = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='EXPIRED',updated_at=now(),version=version+1 WHERE id=$1::uuid`, id)
if err := tx.Commit(ctx); err != nil {
return err
}
return ErrExpired
}
if digest != input.Digest {
return ErrAcknowledgement
}
if input.Result == "FAILED" {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='SUSPENDED',last_error_code=NULLIF($2,''),updated_at=now(),version=version+1 WHERE id=$1::uuid`, id, input.ErrorCode)
if err != nil {
return err
}
} else {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_license SET status='ACTIVE',activated_at=COALESCE(activated_at,now()),last_error_code=NULL,updated_at=now(),version=version+1 WHERE id=$1::uuid`, id)
if err != nil {
return err
}
var providerReady, bindingReady bool
var activationState string
if err := tx.QueryRow(ctx, `SELECT d.activation_state,EXISTS(SELECT 1 FROM hardware_agent_provider_credential pc WHERE pc.device_id=d.id AND pc.status='ACTIVE'),EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL) FROM device d WHERE d.id=$1::uuid AND d.tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, &providerReady, &bindingReady); err != nil {
return err
}
if !providerReady || !bindingReady {
return ErrAssignment
}
if activationState == string(deviceactivation.Provisioning) {
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: identity.ID, TenantID: identity.TenantID, Target: deviceactivation.Active, Reason: "设备已安全保存并确认有效License", Source: "license-ack", ActorType: "device", ActorPublicID: &identity.PublicID, RequestID: requestID}); err != nil {
return err
}
}
}
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &identity.PublicID, Action: "hardware_agent.license.ack", ResourceType: "hardware_agent_license", ResourceID: &publicID, RequestID: requestID, Result: "success", Detail: map[string]any{"license_ref": input.LicenseRef, "result": input.Result, "error_code": input.ErrorCode}}); err != nil {
return err
}
return tx.Commit(ctx)
}
type access struct{ tenantID, userPublicID, role string }
type queryRow interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
func requireAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (access, error) {
var a access
err := q.QueryRow(ctx, `SELECT t.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(&a.tenantID, &a.userPublicID, &a.role)
if errors.Is(err, pgx.ErrNoRows) {
return access{}, ErrAccessDenied
}
if err != nil {
return access{}, err
}
if mutate && a.role != "ops_operator" && a.role != "ops_admin" {
return access{}, ErrAccessDenied
}
return a, nil
}
func licenseAAD(ref, instanceID, productKey string, expires time.Time) []byte {
return []byte(fmt.Sprintf("hardware-license-v1\n%s\n%s\n%s\n%d", ref, instanceID, productKey, expires.UTC().Unix()))
}