Node records, and the right to join once
The next step after the schema: inventory now holds node records and enrolment tokens, and mesh-control has the commands to work with them. A token is issued for a node record, which is where re-enrolment gets decided -- what an identity binds to is settled when the token is made, not when it is presented, so the machine presenting one does not need to know whether it is joining or returning. What the token guarantees, each with a test confirmed to fail when the behaviour is removed: the secret is 256 random bits, shown once and stored only as a hash; it works exactly once; it stops working when it expires; issuing again for a node invalidates the outstanding one, because two live tokens are two machines able to join as the same node. Redemption is a single statement that finds and spends together, so eight concurrent attempts on one secret produce exactly one winner rather than a race between a check and a write. Refusals are deliberately identical for unknown, spent and expired. Somebody guessing must not learn which guess was a real token that had merely aged out. SHA-256 rather than a password hash, and that is a choice not a shortcut: the secret is high-entropy random, so there is nothing to guess and a slow hash would buy nothing while making every redemption expensive. It stops before what a node receives in exchange. What a machine presents afterwards to prove it is that node is not decided anywhere, and a migration is the most expensive place here to guess. So a token carries one of the four things ADR 0004 requires. The command prints the secret and then says exactly that -- the broker's address, its certificate fingerprint and the control plane's signing identity do not exist yet. Better than emitting something that looks complete and silently cannot be used.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
)
|
||||
|
||||
// Against a real PostgreSQL, for the reason novox/hq ADR 0017 gives: what is being tested here is
|
||||
// that the database enforces what this code relies on it enforcing — a unique name, a token that
|
||||
// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would
|
||||
// assert that the fake enforces them.
|
||||
|
||||
func fresh(t *testing.T) *Inventory {
|
||||
t.Helper()
|
||||
admin := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if admin == "" {
|
||||
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
|
||||
}
|
||||
name := fmt.Sprintf("inv_%d_%s", time.Now().UnixNano()%1_000_000,
|
||||
strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name())))
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
|
||||
conn, err := pgx.Connect(t.Context(), admin)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
|
||||
}
|
||||
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
|
||||
t.Fatalf("cannot create %s: %v", name, err)
|
||||
}
|
||||
conn.Close(t.Context())
|
||||
|
||||
cut := strings.LastIndex(admin, "/")
|
||||
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
|
||||
|
||||
inv, err := Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
inv.Close()
|
||||
c, err := pgx.Connect(context.Background(), admin)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close(context.Background())
|
||||
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
|
||||
})
|
||||
if err := inv.Ready(t.Context(), 20*time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
migrations, err := Migrations()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.store.Migrate(t.Context(), migrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
func TestANodeRecordRoundTrips(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
made, err := inv.AddNode(t.Context(), "workstation")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := inv.NodeByName(t.Context(), "workstation")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.ID != made.ID {
|
||||
t.Errorf("added %s and found %s", made.ID, found.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoNodesCannotShareAName(t *testing.T) {
|
||||
// A name is how a token is issued for a node. Two records with one name makes that command
|
||||
// ambiguous at the moment it grants access to the mesh.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "workstation"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := inv.AddNode(t.Context(), "workstation")
|
||||
if !errors.Is(err, ErrNameTaken) {
|
||||
t.Fatalf("a duplicate name gave %v; it must be a plain answer a person can act on", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownNodeIsNotAnEmptyRecord(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
_, err := inv.NodeByName(t.Context(), "never-existed")
|
||||
if !errors.Is(err, ErrNoSuchNode) {
|
||||
t.Fatalf("expected ErrNoSuchNode, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATokenIsRedeemableExactlyOnce(t *testing.T) {
|
||||
// "Useless once used" (novox/hq ADR 0004). Without it a token that leaked after a successful
|
||||
// join is a second machine's way in, and nothing would have noticed the first.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
node, err := inv.Redeem(t.Context(), issued.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("a fresh token was refused: %v", err)
|
||||
}
|
||||
if node.Name != "laptop" {
|
||||
t.Errorf("redeemed a token for %q", node.Name)
|
||||
}
|
||||
|
||||
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
|
||||
t.Fatal("the same token was redeemed twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredTokenIsRefused(t *testing.T) {
|
||||
// "Useless after it expires" — the other half, and the one nothing notices, because a token
|
||||
// ages out with nobody watching. It has to be read from the row rather than from a status
|
||||
// something would have had to write.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(t.Context(), "laptop", 40*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) {
|
||||
t.Fatal("an expired token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATokenWithNoLifetimeIsRefused(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.IssueToken(t.Context(), "laptop", 0); err == nil {
|
||||
t.Fatal("a token that never expires was issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuingAgainInvalidatesTheOutstandingToken(t *testing.T) {
|
||||
// Two live tokens for one node record are two machines able to join as the same node, with
|
||||
// nothing downstream able to tell which was meant.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := inv.Redeem(t.Context(), first.Secret); !errors.Is(err, ErrTokenRefused) {
|
||||
t.Error("the first token still worked after a second was issued")
|
||||
}
|
||||
if _, err := inv.Redeem(t.Context(), second.Secret); err != nil {
|
||||
t.Errorf("the newest token was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSecretIsNotStored(t *testing.T) {
|
||||
// A copy of this database must not be a set of working credentials.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := inv.store.Pool().QueryRow(t.Context(),
|
||||
`select secret from enrolment_token limit 1`).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == issued.Secret {
|
||||
t.Fatal("the token secret is stored verbatim; this table would be a set of live credentials")
|
||||
}
|
||||
if strings.Contains(stored, issued.Secret) {
|
||||
t.Fatal("the stored value contains the secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoRedemptionsOfOneSecretCannotBothWin(t *testing.T) {
|
||||
// The check and the spend are one statement for this reason. Reading first and writing second
|
||||
// leaves a window where two machines both pass the check and both join as the same node.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 8)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, results[i] = inv.Redeem(context.Background(), issued.Secret)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
won := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
won++
|
||||
}
|
||||
}
|
||||
if won != 1 {
|
||||
t.Errorf("%d of 8 concurrent redemptions succeeded; exactly one may", won)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovingANodeTakesItsTokensWithIt(t *testing.T) {
|
||||
// A token outliving the record it was issued for is a right to join as nobody.
|
||||
inv := fresh(t)
|
||||
node, err := inv.AddNode(t.Context(), "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.IssueToken(t.Context(), "laptop", time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var left int
|
||||
if err := inv.store.Pool().QueryRow(t.Context(),
|
||||
`select count(*) from enrolment_token`).Scan(&left); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left != 0 {
|
||||
t.Errorf("%d token(s) outlived the node record they were issued for", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownSecretIsRefusedTheSameWayAsAnExpiredOne(t *testing.T) {
|
||||
// One error for every reason. Somebody guessing must not learn which of their guesses was a
|
||||
// real token that had merely expired.
|
||||
inv := fresh(t)
|
||||
if _, err := inv.AddNode(t.Context(), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expired, err := inv.IssueToken(t.Context(), "laptop", 30*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
_, unknownErr := inv.Redeem(t.Context(), "not-a-token-at-all")
|
||||
_, expiredErr := inv.Redeem(t.Context(), expired.Secret)
|
||||
|
||||
if unknownErr == nil || expiredErr == nil {
|
||||
t.Fatal("one of them was accepted")
|
||||
}
|
||||
if unknownErr.Error() != expiredErr.Error() {
|
||||
t.Errorf("the two are distinguishable:\n unknown: %v\n expired: %v", unknownErr, expiredErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
-- The right to join, once.
|
||||
--
|
||||
-- novox/hq ADR 0004: a token carries the broker's address, the fingerprint to expect, the control
|
||||
-- plane's signing identity, and a one-time secret. Only the last of those is stored here -- the
|
||||
-- other three are facts about the mesh, the same in every token, and belong wherever the mesh's
|
||||
-- own configuration lives rather than copied into each row.
|
||||
|
||||
create table enrolment_token (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
|
||||
-- A token is issued FOR a node record, and that is where re-enrolment is decided
|
||||
-- (novox/hq 09-the-node-lifecycle). The host presenting it does not need to know whether it
|
||||
-- is joining as a new node or returning as an existing one; what the identity binds to was
|
||||
-- settled when the token was made.
|
||||
--
|
||||
-- Cascading: a node record removed takes its unused tokens with it. A token outliving the
|
||||
-- record it was issued for is a right to join as nobody.
|
||||
node uuid not null references node(id) on delete cascade,
|
||||
|
||||
-- The secret is never stored. What is stored is a hash of it, so a copy of this table is not
|
||||
-- a set of working credentials -- the same reason a password is not kept.
|
||||
--
|
||||
-- Unique because a collision would make two tokens redeem as one, and because it lets the
|
||||
-- lookup at redemption be by hash rather than a scan.
|
||||
secret text not null unique,
|
||||
|
||||
issued timestamptz not null default now(),
|
||||
|
||||
-- "Useless once used and useless after it expires" is two conditions, so it is two columns.
|
||||
-- Neither is a status field: a status has to be written by something noticing, and nothing
|
||||
-- notices a token quietly ageing out. Both are read from what is already here.
|
||||
expires timestamptz not null,
|
||||
redeemed timestamptz
|
||||
);
|
||||
|
||||
-- Redemption looks a token up by the hash of what was presented, and it is the one query on the
|
||||
-- path where a node is waiting.
|
||||
create index enrolment_token_node on enrolment_token (node);
|
||||
@@ -0,0 +1,210 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
)
|
||||
|
||||
// Inventory is this context, holding the store it exclusively owns.
|
||||
type Inventory struct{ store *store.Store }
|
||||
|
||||
// Open connects to the inventory store.
|
||||
func Open(ctx context.Context) (*Inventory, error) {
|
||||
s, err := store.Open(ctx, Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Inventory{store: s}, nil
|
||||
}
|
||||
|
||||
func (i *Inventory) Close() { i.store.Close() }
|
||||
|
||||
// Ready waits for the database to answer.
|
||||
func (i *Inventory) Ready(ctx context.Context, within time.Duration) error {
|
||||
return i.store.Ready(ctx, within)
|
||||
}
|
||||
|
||||
// Node is a machine the mesh knows about.
|
||||
type Node struct {
|
||||
ID string
|
||||
Name string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// ErrNoSuchNode is returned when a name matches no record.
|
||||
var ErrNoSuchNode = errors.New("no node of that name")
|
||||
|
||||
// ErrNameTaken is returned when a node of that name already exists.
|
||||
//
|
||||
// Its own error rather than the driver's, because "that name is taken" is an ordinary answer a
|
||||
// person can act on, and a unique-violation from PostgreSQL is not.
|
||||
var ErrNameTaken = errors.New("a node of that name already exists")
|
||||
|
||||
// AddNode creates a node record.
|
||||
//
|
||||
// The record comes first and the machine second: a token is issued *for* a node record
|
||||
// (novox/hq 09-the-node-lifecycle), so the record is what a token binds to and must exist before
|
||||
// there is anything to join.
|
||||
func (i *Inventory) AddNode(ctx context.Context, name string) (Node, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Node{}, errors.New("a node needs a name: it is how a token is issued for it")
|
||||
}
|
||||
|
||||
var n Node
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`insert into node (name) values ($1) returning id, name, created`,
|
||||
name).Scan(&n.ID, &n.Name, &n.Created)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "node_name_key") {
|
||||
return Node{}, fmt.Errorf("%w: %s", ErrNameTaken, name)
|
||||
}
|
||||
return Node{}, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Nodes are every node record, oldest first.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select id, name, created from node order by created, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var nodes []Node
|
||||
for rows.Next() {
|
||||
var n Node
|
||||
if err := rows.Scan(&n.ID, &n.Name, &n.Created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
// NodeByName finds one node record.
|
||||
func (i *Inventory) NodeByName(ctx context.Context, name string) (Node, error) {
|
||||
var n Node
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select id, name, created from node where name = $1`, name).Scan(&n.ID, &n.Name, &n.Created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Issued is a token that has just been made. The secret is in it exactly once.
|
||||
type Issued struct {
|
||||
Node Node
|
||||
Secret string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// hashSecret is what gets stored in place of the secret.
|
||||
//
|
||||
// SHA-256 rather than a password hash, and that is deliberate rather than a shortcut. bcrypt and
|
||||
// its relatives are slow on purpose because a password is low-entropy and guessable; this secret
|
||||
// is 256 bits from the system's random source, so there is nothing to guess and the slowness would
|
||||
// buy nothing while making every redemption expensive.
|
||||
func hashSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// IssueToken mints a one-time right to join, for a node record.
|
||||
//
|
||||
// The secret is returned once and never again. What is stored is its hash, so a copy of this
|
||||
// database is not a set of working credentials.
|
||||
//
|
||||
// Any outstanding token for the same node is expired first. Two live tokens for one node record
|
||||
// are two machines able to join as the same node, and nothing downstream could tell which was
|
||||
// meant — novox/hq ADR 0004's stolen-laptop case arriving before enrolment rather than after.
|
||||
func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor time.Duration) (Issued, error) {
|
||||
if validFor <= 0 {
|
||||
return Issued{}, errors.New("a token needs a lifetime: one that never expires is a " +
|
||||
"permanent credential, which is the thing this is designed not to be")
|
||||
}
|
||||
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Issued{}, fmt.Errorf("cannot generate a token secret: %w", err)
|
||||
}
|
||||
secret := base64.RawURLEncoding.EncodeToString(raw)
|
||||
expires := time.Now().Add(validFor)
|
||||
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
|
||||
// Expired rather than deleted: what was issued and then withdrawn is worth being able to see.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update enrolment_token set expires = now()
|
||||
where node = $1 and redeemed is null and expires > now()`, node.ID); err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into enrolment_token (node, secret, expires) values ($1, $2, $3)`,
|
||||
node.ID, hashSecret(secret), expires); err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Issued{}, err
|
||||
}
|
||||
|
||||
return Issued{Node: node, Secret: secret, Expires: expires}, nil
|
||||
}
|
||||
|
||||
// ErrTokenRefused is what redemption returns for anything that is not a live token.
|
||||
//
|
||||
// One error for every reason — unknown, already used, expired — and deliberately so. Whoever is
|
||||
// presenting a token that does not work is either a machine whose operator can be told out of
|
||||
// band, or somebody guessing, and the second must not learn which of their guesses was a real
|
||||
// token that had expired.
|
||||
var ErrTokenRefused = errors.New("that token cannot be used")
|
||||
|
||||
// Redeem spends a token and reports which node it was for.
|
||||
//
|
||||
// It does not issue an identity. What a node presents afterwards to prove it is that node is not
|
||||
// decided anywhere (novox/hq ADR 0004 names the property, not the mechanism), and guessing at it
|
||||
// in a migration is the most expensive guess available here.
|
||||
//
|
||||
// The update is the check: one statement that both finds a live token and marks it used, so two
|
||||
// simultaneous redemptions of one secret cannot both succeed. Reading first and writing second
|
||||
// would leave exactly that gap.
|
||||
func (i *Inventory) Redeem(ctx context.Context, secret string) (Node, error) {
|
||||
var id string
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`update enrolment_token set redeemed = now()
|
||||
where secret = $1 and redeemed is null and expires > now()
|
||||
returning node`, hashSecret(secret)).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Node{}, ErrTokenRefused
|
||||
}
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
|
||||
var n Node
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select id, name, created from node where id = $1`, id).Scan(&n.ID, &n.Name, &n.Created)
|
||||
return n, err
|
||||
}
|
||||
Reference in New Issue
Block a user