chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package saas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type DeviceClaimResult struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
HouseholdID string `json:"household_id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
BoundAt time.Time `json:"bound_at"`
|
||||
}
|
||||
|
||||
func (s *Service) ClaimDevice(ctx context.Context, principal identity.Principal, claimCode, householdPublicID, subjectPublicID, requestID string) (DeviceClaimResult, bool, error) {
|
||||
var result DeviceClaimResult
|
||||
var replayed bool
|
||||
digestBytes := sha256.Sum256([]byte(claimCode))
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityDeviceManage, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var subjectID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM subject_profile
|
||||
WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND household_id=$3::uuid AND status <> 'deleted'
|
||||
FOR SHARE`, subjectPublicID, access.tenantID, access.householdID).Scan(&subjectID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrAccessDenied
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("resolve claim subject: %w", err)
|
||||
}
|
||||
|
||||
var claimID, deviceID, devicePublicID string
|
||||
var expiresAt time.Time
|
||||
var revokedAt, usedAt *time.Time
|
||||
var usedBindingID *string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT dc.id::text,dc.device_id::text,d.public_id::text,dc.expires_at,dc.revoked_at,dc.used_at,dc.used_binding_id::text
|
||||
FROM device_claim dc JOIN device d ON d.id=dc.device_id AND d.tenant_id=dc.tenant_id
|
||||
WHERE dc.token_digest=$1 AND dc.tenant_id=$2::uuid AND d.status='registered'
|
||||
FOR UPDATE OF dc`, digest, access.tenantID).Scan(&claimID, &deviceID, &devicePublicID, &expiresAt, &revokedAt, &usedAt, &usedBindingID); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrDeviceClaimInvalid
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("resolve device claim: %w", err)
|
||||
}
|
||||
if revokedAt != nil || !expiresAt.After(time.Now()) {
|
||||
return ErrDeviceClaimInvalid
|
||||
}
|
||||
if usedAt != nil {
|
||||
if usedBindingID == nil {
|
||||
return ErrDeviceClaimInvalid
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT b.public_id::text,d.public_id::text,h.public_id::text,sp.public_id::text,b.bound_at
|
||||
FROM device_binding b
|
||||
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
|
||||
JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
|
||||
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
|
||||
WHERE b.id=$1::uuid`, *usedBindingID).Scan(&result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
|
||||
return fmt.Errorf("read used device claim binding: %w", err)
|
||||
}
|
||||
if result.HouseholdID != householdPublicID || result.SubjectID != subjectPublicID {
|
||||
return ErrDeviceClaimUsed
|
||||
}
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
var activeBinding 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(&activeBinding); err != nil {
|
||||
return fmt.Errorf("check active device binding: %w", err)
|
||||
}
|
||||
if activeBinding {
|
||||
return ErrDeviceAlreadyBound
|
||||
}
|
||||
var bindingInternalID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason)
|
||||
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'one_time_claim')
|
||||
RETURNING id::text,public_id::text,$6::text,$7::text,$8::text,bound_at`,
|
||||
access.tenantID, deviceID, access.householdID, subjectID, access.userID,
|
||||
devicePublicID, householdPublicID, subjectPublicID).Scan(&bindingInternalID, &result.BindingID, &result.DeviceID, &result.HouseholdID, &result.SubjectID, &result.BoundAt); err != nil {
|
||||
return fmt.Errorf("insert claimed device binding: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET used_at=now(),used_binding_id=$1::uuid WHERE id=$2::uuid`, bindingInternalID, claimID); err != nil {
|
||||
return fmt.Errorf("mark device claim used: %w", err)
|
||||
}
|
||||
return s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
|
||||
Action: "device.claim", ResourceType: "device_binding", ResourceID: strPtr(result.BindingID),
|
||||
RequestID: requestID, Result: "success", Detail: map[string]any{"device_id": devicePublicID, "household_id": householdPublicID, "subject_id": subjectPublicID},
|
||||
})
|
||||
})
|
||||
return result, replayed, err
|
||||
}
|
||||
Reference in New Issue
Block a user