Skip to content

Commit ed45314

Browse files
committed
egress policy: add an actor JWT value source to CredentialHeader
Add ActorJWTSource as a second value source for a replace_headers entry, next to credential_uri, so an EgressPolicy can have the egress gateway replace a header with a Substrate-issued JWT for the actor that sent the request. The two fields form a union and exactly one must be set. credential_uri stays a plain string: an empty credential URI is never valid, so the union treats the empty string as unset, and every stored policy stays valid. ActorJWTSource names the audiences the JWT is bound to and its lifetime, which is required and bounded to 300 to 3600 seconds, so the policy author always chooses how long the gateway's JWTs live. The gateway cannot mint actor JWTs yet. Until it can, an https rule that asks for one denies the request with 501 instead of failing on the empty credential URI. Actor JWTs do not come from the credential provider, so the denial applies even when no provider is configured. Like a credential_uri entry, an actor JWT entry is skipped on http rules.
1 parent ce05e5d commit ed45314

6 files changed

Lines changed: 1041 additions & 593 deletions

File tree

‎cmd/ateapi/internal/apivalidation/egress_policy_test.go‎

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package apivalidation
1717
import (
1818
"context"
1919
"fmt"
20+
"strings"
2021
"testing"
2122

2223
"github.com/agent-substrate/substrate/cmd/ateapi/internal/defaults"
@@ -43,6 +44,16 @@ func validEgressPolicy() *ateapipb.EgressPolicy {
4344
}
4445
}
4546

47+
// actorJWTHeader is an Authorization header replaced with a 900-second actor
48+
// JWT for audiences.
49+
func actorJWTHeader(audiences ...string) *ateapipb.CredentialHeader {
50+
return &ateapipb.CredentialHeader{
51+
Header: "Authorization",
52+
Prefix: "Bearer ",
53+
ActorJwt: &ateapipb.ActorJWTSource{Audiences: audiences, ExpirationSeconds: 900},
54+
}
55+
}
56+
4657
func TestValidateCreateActorEgressPolicyRequest(t *testing.T) {
4758
validReq := func() *ateapipb.CreateActorEgressPolicyRequest {
4859
return &ateapipb.CreateActorEgressPolicyRequest{
@@ -801,12 +812,12 @@ func TestValidateEgressPolicyRules(t *testing.T) {
801812
field.Invalid(staticHeader.Child("prefix"), "Bearer\r", "must be a valid HTTP field value prefix"),
802813
},
803814
}, {
804-
name: "missing credential URI",
815+
name: "no credential source",
805816
mutate: func(p *ateapipb.EgressPolicy) {
806817
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = ""
807818
},
808819
want: field.ErrorList{
809-
field.Required(staticHeader.Child("credential_uri"), ""),
820+
field.Invalid(staticHeader, nil, "one of").WithOrigin("union"),
810821
},
811822
}, {
812823
name: "invalid credential URI",
@@ -816,6 +827,113 @@ func TestValidateEgressPolicyRules(t *testing.T) {
816827
want: field.ErrorList{
817828
field.Invalid(staticHeader.Child("credential_uri"), "https://example.com/secret", "must be ate-secret://<provider-class>/<provider-name>/<provider-specific-tail>"),
818829
},
830+
}, {
831+
name: "actor JWT on an http rule",
832+
mutate: func(p *ateapipb.EgressPolicy) {
833+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("https://api.example.com")
834+
},
835+
}, {
836+
name: "actor JWT on an https rule",
837+
mutate: func(p *ateapipb.EgressPolicy) {
838+
p.Rules[0] = &ateapipb.EgressRule{Https: &ateapipb.HTTPSRule{
839+
Hostnames: []string{"api.example.com"},
840+
Effects: &ateapipb.HttpRuleEffects{ReplaceHeaders: []*ateapipb.CredentialHeader{actorJWTHeader("https://api.example.com")}},
841+
}}
842+
},
843+
}, {
844+
name: "actor JWT next to a credential URI",
845+
mutate: func(p *ateapipb.EgressPolicy) {
846+
jwt := actorJWTHeader("https://api.example.com")
847+
jwt.Header = "X-Actor-Token"
848+
p.Rules[0].Http.Effects.ReplaceHeaders = append(p.Rules[0].Http.Effects.ReplaceHeaders, jwt)
849+
},
850+
}, {
851+
name: "credential URI and actor JWT in one header",
852+
mutate: func(p *ateapipb.EgressPolicy) {
853+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt = &ateapipb.ActorJWTSource{Audiences: []string{"https://api.example.com"}, ExpirationSeconds: 900}
854+
},
855+
want: field.ErrorList{
856+
field.Invalid(staticHeader, nil, "one of").WithOrigin("union"),
857+
},
858+
}, {
859+
name: "actor JWT without audiences",
860+
mutate: func(p *ateapipb.EgressPolicy) {
861+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader()
862+
},
863+
want: field.ErrorList{
864+
field.Required(staticHeader.Child("actor_jwt", "audiences"), ""),
865+
},
866+
}, {
867+
name: "actor JWT with an empty audience",
868+
mutate: func(p *ateapipb.EgressPolicy) {
869+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("")
870+
},
871+
want: field.ErrorList{
872+
field.TooShort(staticHeader.Child("actor_jwt", "audiences").Index(0), "", 1).WithOrigin("minLength"),
873+
},
874+
}, {
875+
name: "actor JWT with a long audience",
876+
mutate: func(p *ateapipb.EgressPolicy) {
877+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader(strings.Repeat("a", 513))
878+
},
879+
want: field.ErrorList{
880+
field.TooLong(staticHeader.Child("actor_jwt", "audiences").Index(0), "", 512).WithOrigin("maxLength"),
881+
},
882+
}, {
883+
name: "actor JWT with a repeated audience",
884+
mutate: func(p *ateapipb.EgressPolicy) {
885+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a", "a")
886+
},
887+
want: field.ErrorList{
888+
field.Duplicate(staticHeader.Child("actor_jwt", "audiences").Index(1), "a"),
889+
},
890+
}, {
891+
name: "actor JWT with too many audiences",
892+
mutate: func(p *ateapipb.EgressPolicy) {
893+
var audiences []string
894+
for i := range 17 {
895+
audiences = append(audiences, fmt.Sprintf("aud-%d", i))
896+
}
897+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader(audiences...)
898+
},
899+
want: field.ErrorList{
900+
field.TooMany(staticHeader.Child("actor_jwt", "audiences"), 17, 16).WithOrigin("maxItems"),
901+
},
902+
}, {
903+
name: "actor JWT without a lifetime",
904+
mutate: func(p *ateapipb.EgressPolicy) {
905+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a")
906+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt.ExpirationSeconds = 0
907+
},
908+
want: field.ErrorList{
909+
field.Required(staticHeader.Child("actor_jwt", "expiration_seconds"), ""),
910+
},
911+
}, {
912+
name: "actor JWT lifetime at its bounds",
913+
mutate: func(p *ateapipb.EgressPolicy) {
914+
short, long := actorJWTHeader("a"), actorJWTHeader("a")
915+
short.ActorJwt.ExpirationSeconds = 300
916+
long.Header, long.ActorJwt.ExpirationSeconds = "X-Actor-Token", 3600
917+
p.Rules[0].Http.Effects.ReplaceHeaders = []*ateapipb.CredentialHeader{short, long}
918+
},
919+
}, {
920+
name: "actor JWT lifetime too short",
921+
mutate: func(p *ateapipb.EgressPolicy) {
922+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a")
923+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt.ExpirationSeconds = 299
924+
},
925+
want: field.ErrorList{
926+
field.Invalid(staticHeader.Child("actor_jwt", "expiration_seconds"), 299, "").WithOrigin("minimum"),
927+
},
928+
}, {
929+
name: "actor JWT lifetime too long",
930+
mutate: func(p *ateapipb.EgressPolicy) {
931+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a")
932+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt.ExpirationSeconds = 3601
933+
},
934+
want: field.ErrorList{
935+
field.Invalid(staticHeader.Child("actor_jwt", "expiration_seconds"), 3601, "").WithOrigin("maximum"),
936+
},
819937
}, {
820938
name: "empty effects",
821939
mutate: func(p *ateapipb.EgressPolicy) {

‎cmd/ateapi/internal/apivalidation/zz_generated.validation.go‎

Lines changed: 143 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/atenet/internal/router/egress/credentials.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ func mapCredentialProviderError(err error) error {
5959
//
6060
// Once injection is actually attempted (TLS leg, provider present), any failure
6161
// to produce the credential the policy required fails closed.
62+
//
63+
// This gateway cannot mint actor JWTs yet, so on the MITM leg a rule that asks
64+
// for one is denied. Actor JWTs don't come from the credential provider, so
65+
// that holds with no provider configured.
6266
func (h *Handler) applyEffects(ctx context.Context, ref resources.ActorRef, dest egresspolicy.Destination, leg string, effects *ateapipb.HttpRuleEffects) ([]*corev3.HeaderValueOption, error) {
6367
injections := effects.GetReplaceHeaders()
6468
if len(injections) == 0 {
@@ -70,6 +74,14 @@ func (h *Handler) applyEffects(ctx context.Context, ref resources.ActorRef, dest
7074
slog.Any("actor", ref), slog.String("host", dest.Hostname), slog.String("leg", leg))
7175
return nil, nil
7276
}
77+
// TODO(identity): mint actor JWTs through Control.MintActorJWT.
78+
for _, inj := range injections {
79+
if inj.GetActorJwt() != nil {
80+
slog.ErrorContext(ctx, "egress denied: this gateway cannot inject actor JWTs yet",
81+
slog.Any("actor", ref), slog.String("host", dest.Hostname), slog.String("header", inj.GetHeader()))
82+
return nil, extproc.NewReqError(envoy_type.StatusCode_NotImplemented, deniedBody)
83+
}
84+
}
7385
if h.provider == nil {
7486
slog.WarnContext(ctx, "egress: skipping credential injection because no credential provider is configured; the request proceeds without the credential",
7587
slog.Any("actor", ref), slog.String("host", dest.Hostname))

0 commit comments

Comments
 (0)