Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/pr-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ jobs:
env:
E2E_SANDBOX_CLASS: microvm
run: hack/run-e2e-kind.sh -v -args --no-color
- name: Deploy MITM egress (sdsmint)
# Swap the passthrough egress gateway for the sdsmint variant, which
# mints per-SNI leaves from the egress-mitm-ca-pool (created here if
# missing). Cluster-wide, so it must come AFTER the standard lanes:
# once egress TLS is intercepted, their passthrough assumptions
# (TestActorEgressHTTPS's end-to-end TLS with the origin) no longer hold.
run: hack/install-ate-kind.sh --deploy-ate-system --experimental-use-sdsmint
- name: Run E2E tests (egress MITM trust)
# The consumption half of the trust-bundle chain: an actor does TLS with
# the MITM gateway's minted leaf using ONLY the projected bundle, plus a
# system-roots negative control proving interception is real (see
# internal/e2e/suites/egressmitm).
env:
E2E_EGRESS_MITM: "1"
run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color
- name: Run E2E tests (egress MITM trust, micro-VM)
# The same proof with the probe on the micro-VM runtime. Trust DELIVERY
# differs per sandbox class (gVisor RO bind vs the micro-VM unified
# virtio-fs share), so the handshake is proven on both. Uses the
# micro-VM deps staged earlier in this job.
env:
E2E_EGRESS_MITM: "1"
E2E_SANDBOX_CLASS: microvm
run: hack/run-e2e-kind.sh ./internal/e2e/suites/egressmitm -v -args --no-color
- name: Dump diagnostics on failure
if: failure()
run: |
Expand Down
139 changes: 139 additions & 0 deletions cmd/ateapi/internal/controlapi/actor_identity_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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 (
"crypto/rand"
"fmt"
"os"
"time"

"github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt"
"github.com/agent-substrate/substrate/internal/localjwtauthority"
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
)

// actorIdentityTokenIssuer mirrors the issuer actoridentity.MintJWT stamps;
// the TODO there about making it a real, OIDC-discoverable DNS name applies
// here identically — the two must move together so verifiers see one issuer.
const actorIdentityTokenIssuer = "https://api.ate-system.svc"

// defaultActorIdentityTokenTTL matches the CRD default for
// expirationSeconds; the guard here covers templates admitted before the
// defaulting webhook stamped them.
const defaultActorIdentityTokenTTL = 3600 * time.Second

// mintActorIdentityTokens fills the Token bytes of every actorIdentityToken
// data source in workloadSpec, minting one JWT per source with the
// template's audience and TTL bound into the claims alongside the actor's
// identity (atespace, name, uid — the same claim shape actoridentity.MintJWT
// produces, so verifiers need one code path).
//
// Minting happens here — on the resume path, immediately before the spec is
// sent to atelet — rather than in atelet, because the mint IS part of the
// activation: ateapi is placing this actor at this moment, so the token is
// inherently bound to the activation without a separate authorization
// exchange. Tokens therefore refresh on every Run/Restore; live renewal for
// long-running actors is deliberately out of scope here and lands with the
// live-refresh mechanism (#932 PR 2 tracks the transport).
//
// Fails closed, naming the problem, when the deployment has no signing pool:
// an actor that declared a token must not start without one.
func mintActorIdentityTokens(jwtPoolFile string, template *atev1alpha1.ActorTemplate, workloadSpec *ateletpb.WorkloadSpec, actor *ateapipb.Actor) error {
if template == nil {
return nil
}

// Wire entries indexed by (volume name, path) for filling in place.
type key struct{ volume, path string }
wire := map[key]*ateletpb.ActorIdentityTokenDataSource{}
for _, vol := range workloadSpec.GetVolumes() {
for _, ds := range vol.GetSystemInfo().GetDataSources() {
if tok := ds.GetActorIdentityToken(); tok != nil {
wire[key{vol.GetName(), tok.GetPath()}] = tok
}
}
}
if len(wire) == 0 {
return nil
}

if jwtPoolFile == "" {
return fmt.Errorf("this deployment does not issue actor identity tokens (ateapi runs without --actor-id-jwt-pool), required by this actor's SystemInfo volumes")
}
poolBytes, err := os.ReadFile(jwtPoolFile)
if err != nil {
return fmt.Errorf("while reading the actor JWT signing pool: %w", err)
}
pool, err := localjwtauthority.Unmarshal(poolBytes)
if err != nil {
return fmt.Errorf("while unmarshaling the actor JWT signing pool: %w", err)
}
if len(pool.Authorities) == 0 {
return fmt.Errorf("the actor JWT signing pool contains no authorities")
}
authority := pool.Authorities[0]

meta := actor.GetMetadata()
now := time.Now()
for _, vol := range template.Spec.Volumes {
if vol.VolumeSource.SystemInfo == nil {
continue
}
for _, ds := range vol.VolumeSource.SystemInfo.DataSources {
if ds.ActorIdentityToken == nil {
continue
}

ttl := defaultActorIdentityTokenTTL
if ds.ActorIdentityToken.ExpirationSeconds != nil {
ttl = time.Duration(*ds.ActorIdentityToken.ExpirationSeconds) * time.Second
}
claims := &actoridjwt.Claims{
Issuer: actorIdentityTokenIssuer,
Subject: fmt.Sprintf("atespaces:%s:actors:%s", meta.GetAtespace(), meta.GetName()),
Audiences: []string{ds.ActorIdentityToken.Audience},
Expiration: now.Add(ttl),
NotBefore: now.Add(-5 * time.Minute),
IssuedAt: now,
JTI: rand.Text(),
Substrate: actoridjwt.SubstrateClaims{
Atespace: meta.GetAtespace(),
ActorName: meta.GetName(),
ActorUid: meta.GetUid(),
},
}
wireClaims, err := actoridjwt.ClaimsToWire(claims)
if err != nil {
return fmt.Errorf("while building actor identity token claims (volume %q): %w", vol.Name, err)
}
token, err := actoridjwt.Sign(wireClaims, authority.SigningKey, authority.Algorithm, authority.ID)
if err != nil {
return fmt.Errorf("while signing actor identity token (volume %q): %w", vol.Name, err)
}

entry, ok := wire[key{vol.Name, ds.ActorIdentityToken.Path}]
if !ok {
// The wire spec is built from this same template, so a missing
// entry means the two views diverged — a bug, not user error.
return fmt.Errorf("internal error: no wire entry for actor identity token at volume %q path %q", vol.Name, ds.ActorIdentityToken.Path)
}
entry.Token = []byte(token)
}
}
return nil
}
187 changes: 187 additions & 0 deletions cmd/ateapi/internal/controlapi/actor_identity_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// 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 (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/agent-substrate/substrate/internal/localjwtauthority"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"k8s.io/utils/ptr"
)

// writeTestJWTPool generates a single-authority signing pool on disk, the
// shape `kubectl-ate admin make-jwt-pool` provisions for ateapi.
func writeTestJWTPool(t *testing.T) string {
t.Helper()
authority, err := localjwtauthority.GenerateECDSAP256Authority("test-authority")
if err != nil {
t.Fatalf("generating JWT authority: %v", err)
}
poolBytes, err := localjwtauthority.Marshal(&localjwtauthority.Pool{Authorities: []*localjwtauthority.Authority{authority}})
if err != nil {
t.Fatalf("marshaling JWT pool: %v", err)
}
path := filepath.Join(t.TempDir(), "pool.json")
if err := os.WriteFile(path, poolBytes, 0o600); err != nil {
t.Fatalf("writing JWT pool: %v", err)
}
return path
}

func tokenTemplate(volumeName, audience, path string, expirationSeconds *int64) *atev1alpha1.ActorTemplate {
return &atev1alpha1.ActorTemplate{
Spec: atev1alpha1.ActorTemplateSpec{
Volumes: []atev1alpha1.Volume{{
Name: volumeName,
VolumeSource: atev1alpha1.VolumeSource{
SystemInfo: &atev1alpha1.SystemInfoVolumeSource{
DataSources: []atev1alpha1.SystemInfoDataSource{
{ActorIdentityToken: &atev1alpha1.ActorIdentityTokenDataSource{
Audience: audience,
ExpirationSeconds: expirationSeconds,
Path: path,
}},
},
},
},
}},
},
}
}

// decodeJWTPayload returns the (unverified) claims of a compact JWS. The mint
// test asserts claim contents; signature correctness is actoridjwt's own
// test territory.
func decodeJWTPayload(t *testing.T, token string) map[string]any {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token is not a compact JWS (%d parts)", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decoding JWT payload: %v", err)
}
claims := map[string]any{}
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatalf("unmarshaling JWT claims: %v", err)
}
return claims
}

func TestMintActorIdentityTokens(t *testing.T) {
poolFile := writeTestJWTPool(t)
template := tokenTemplate("system-info", "verifier.example.com", "identity/token", ptr.To(int64(900)))
actor := &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{
Atespace: "team-a", Name: "actor-1", Uid: "uid-1",
}}

spec, err := workloadSpecFromActorTemplate(template, nil)
if err != nil {
t.Fatalf("workloadSpecFromActorTemplate: %v", err)
}

t.Run("mints a token into the wire spec with the requested binding", func(t *testing.T) {
before := time.Now()
if err := mintActorIdentityTokens(poolFile, template, spec, actor); err != nil {
t.Fatalf("mintActorIdentityTokens: %v", err)
}
entry := spec.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken()
if entry.GetPath() != "identity/token" {
t.Errorf("path = %q, want %q", entry.GetPath(), "identity/token")
}
if len(entry.GetToken()) == 0 {
t.Fatal("token is empty")
}

claims := decodeJWTPayload(t, string(entry.GetToken()))
if got := claims["sub"]; got != "atespaces:team-a:actors:actor-1" {
t.Errorf("sub = %v, want atespaces:team-a:actors:actor-1", got)
}
aud, _ := claims["aud"].([]any)
if len(aud) != 1 || aud[0] != "verifier.example.com" {
t.Errorf("aud = %v, want [verifier.example.com]", claims["aud"])
}
exp, _ := claims["exp"].(float64)
iat, _ := claims["iat"].(float64)
if got := exp - iat; got != 900 {
t.Errorf("exp-iat = %v, want the requested 900s TTL", got)
}
if got := time.Unix(int64(iat), 0); got.Before(before.Add(-time.Minute)) || got.After(time.Now().Add(time.Minute)) {
t.Errorf("iat = %v, want approximately now", got)
}
// The substrate claims ride under the "ate.dev" key (see
// actoridjwt.WireClaims), the same shape MintJWT produces, so
// verifiers need one code path for both.
sub, _ := claims["ate.dev"].(map[string]any)
if sub == nil {
t.Fatalf("no ate.dev claims object found in %v", claims)
}
if sub["actorUid"] != "uid-1" {
t.Errorf("ate.dev actorUid = %v, want uid-1", sub["actorUid"])
}
if sub["atespace"] != "team-a" || sub["actorName"] != "actor-1" {
t.Errorf("ate.dev identity = %v/%v, want team-a/actor-1", sub["atespace"], sub["actorName"])
}
})

t.Run("two mints for the same actor differ (fresh JTI per activation)", func(t *testing.T) {
specA, _ := workloadSpecFromActorTemplate(template, nil)
specB, _ := workloadSpecFromActorTemplate(template, nil)
if err := mintActorIdentityTokens(poolFile, template, specA, actor); err != nil {
t.Fatalf("mint A: %v", err)
}
if err := mintActorIdentityTokens(poolFile, template, specB, actor); err != nil {
t.Fatalf("mint B: %v", err)
}
a := specA.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken().GetToken()
b := specB.GetVolumes()[0].GetSystemInfo().GetDataSources()[0].GetActorIdentityToken().GetToken()
if string(a) == string(b) {
t.Error("two mints produced identical tokens; JTI should make every mint unique")
}
})

t.Run("no token sources is a no-op even without a pool", func(t *testing.T) {
plain := &atev1alpha1.ActorTemplate{}
spec, _ := workloadSpecFromActorTemplate(plain, nil)
if err := mintActorIdentityTokens("", plain, spec, actor); err != nil {
t.Fatalf("mintActorIdentityTokens: %v", err)
}
})

t.Run("token sources without a pool fail closed naming the flag", func(t *testing.T) {
spec, _ := workloadSpecFromActorTemplate(template, nil)
err := mintActorIdentityTokens("", template, spec, actor)
if err == nil || !strings.Contains(err.Error(), "actor-id-jwt-pool") {
t.Errorf("error = %v, want no-signing-pool error naming the flag", err)
}
})

t.Run("unreadable pool fails closed", func(t *testing.T) {
spec, _ := workloadSpecFromActorTemplate(template, nil)
err := mintActorIdentityTokens(filepath.Join(t.TempDir(), "missing.json"), template, spec, actor)
if err == nil || !strings.Contains(err.Error(), "signing pool") {
t.Errorf("error = %v, want unreadable-pool error", err)
}
})
}
3 changes: 2 additions & 1 deletion cmd/ateapi/internal/controlapi/functionaltest/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu
ateletFactory, ateletInformer := controlapi.AteletInformer(k8sClient)
scFactory := informers.NewSharedInformerFactory(k8sClient, 0)
scLister := scFactory.Storage().V1().StorageClasses().Lister()
ctbLister := scFactory.Certificates().V1beta1().ClusterTrustBundles().Lister()

substrateInformerFactory := externalversions.NewSharedInformerFactory(substrateClient, 0)
actorTemplateLister := substrateInformerFactory.Api().V1alpha1().ActorTemplates().Lister()
Expand Down Expand Up @@ -191,7 +192,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu
mockDriverName: mockPlugin,
}
}
service := controlapi.NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, scLister, dialer, instruments, "", volPlugins)
service := controlapi.NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, scLister, ctbLister, dialer, instruments, "", "", volPlugins)

// 5. Start REAL gRPC Server for ATE API
grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor))
Expand Down
Loading
Loading