Files
ILLUM1N0X e988839346 A node's per-actor snapshot credential stops working once the actor leaves that node (#2893)
Part of #777

ate-api-server checks that the caller's node hosts the actor when it
mints a worker-for-actor JWT, but the token then lives for an hour and
the credential exchange trusted any valid one. A node that kept a JWT
after the actor resumed elsewhere could still exchange it for a
read-write token on the actor's prefix, and the exchange also answered
for crashed, deleting or reverting actors.

The JWT now carries a `node` claim, the node it was minted for, set by
`MintWorkerActorJwt` from the identity it already verified against
`HostingNode`. The exchange refuses the token unless that node is still
the actor's `status.assignedNode` and the actor is in a state with a
transfer: RESUMING, RUNNING, SUSPENDING or SUSPENDED. `assignedNode` is
set by a resume, stays set while the actor is SUSPENDED with its local
snapshot on that node, and is cleared when the node detaches the actor,
by a revert and by a delete, or replaced by a resume onto another node,
so a stale JWT stops working at that status write rather than at expiry.
An actor's own token never carries a node.

The transfer states are listed once, in `resources.TransferStates`, read
by both the host check and the exchange, with a test that fails when a
new actor state is added without being classified.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
2026-10-19 20:09:11 +00:00

85 lines
3.1 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controlapi
import (
"context"
"errors"
"fmt"
"slices"
"go.ate.dev/substrate/cmd/ateapi/internal/store"
"go.ate.dev/substrate/internal/resources"
"go.ate.dev/substrate/pkg/proto/ateapipb"
)
// HostingNode is the node that holds the actor, and "" when no node does. The
// actor's state decides which status field to trust, since both can outlive
// the hosting they record: pause, suspend, crash and revert release the
// worker (delete its row in the store's worker assignments) before they clear
// status.worker_assignment, and a crash keeps status.assigned_node as a record
// of the last node. The rules:
// - SUSPENDED with no worker: assigned_node. It is set only while a
// snapshot whose locality is that node still holds files there, uploaded
// or not, and cleared once none does.
// - RESUMING, RUNNING and SUSPENDING with status.worker_assignment set: the
// worker's node, but only while the store's worker assignment row binds
// that worker to this actor's UID; "" once it is released or the worker
// is gone.
// - Anything else: "".
//
// WorkerService uses it to refuse a node that asks for credentials for an
// actor it does not hold.
func HostingNode(ctx context.Context, st store.Interface, actor *ateapipb.Actor) (string, error) {
status := actor.GetStatus()
// The branches below pick the field to trust within the states a node
// transfers in.
if !slices.Contains(resources.TransferStates, status.GetState()) {
return "", nil
}
assignment := status.GetWorkerAssignment()
if assignment == nil {
if status.GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
return "", nil
}
return status.GetAssignedNode(), nil
}
switch status.GetState() {
case ateapipb.ActorState_ACTOR_STATE_RESUMING, ateapipb.ActorState_ACTOR_STATE_RUNNING,
ateapipb.ActorState_ACTOR_STATE_SUSPENDING:
default:
return "", nil
}
workerName := assignment.GetWorker().GetName()
// The worker assignment, not the actor's status, binds the worker to the
// actor. The status keeps naming the worker after a release until the
// workflow that released it records the new state, and by then the worker
// may host another actor.
hosted, err := workerHostsActor(ctx, st, workerName, actor.GetMetadata().GetUid())
if err != nil {
return "", err
}
if !hosted {
return "", nil
}
worker, err := st.GetWorker(ctx, workerName)
if errors.Is(err, store.ErrNotFound) {
return "", nil
} else if err != nil {
return "", fmt.Errorf("while retrieving worker %s: %w", workerName, err)
}
return worker.GetNodeName(), nil
}