@@ -0,0 +1,361 @@
|
||||
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()))
|
||||
}
|
||||
Reference in New Issue
Block a user