Skip to content

Commit e22503e

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 becomes optional with explicit presence, as the API style guide requires of a primitive union member. Every stored policy already sets it, so existing policies stay valid and the wire format is unchanged. ActorJWTSource names the audiences the JWT is bound to and its lifetime in seconds. The lifetime has the same 300 to 3600 bounds as MintActorJWTRequest, and the server fills in 900 when it is unset. 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. Like a credential_uri entry, an actor JWT entry is skipped on http rules.
1 parent 5b7417e commit e22503e

11 files changed

Lines changed: 1102 additions & 611 deletions

File tree

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

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

2223
"github.com/agent-substrate/substrate/cmd/ateapi/internal/defaults"
@@ -40,14 +41,24 @@ func validEgressPolicy() *ateapipb.EgressPolicy {
4041
ReplaceHeaders: []*ateapipb.CredentialHeader{{
4142
Header: "Authorization",
4243
Prefix: "Bearer ",
43-
CredentialUri: "ate-secret://kubernetes.io/provider/ns/name",
44+
CredentialUri: proto.String("ate-secret://kubernetes.io/provider/ns/name"),
4445
}},
4546
},
4647
},
4748
}},
4849
}
4950
}
5051

52+
// actorJWTHeader is an Authorization header replaced with an actor JWT for
53+
// audiences.
54+
func actorJWTHeader(audiences ...string) *ateapipb.CredentialHeader {
55+
return &ateapipb.CredentialHeader{
56+
Header: "Authorization",
57+
Prefix: "Bearer ",
58+
ActorJwt: &ateapipb.ActorJWTSource{Audiences: audiences},
59+
}
60+
}
61+
5162
func TestValidateCreateActorEgressPolicyRequest(t *testing.T) {
5263
validReq := func() *ateapipb.CreateActorEgressPolicyRequest {
5364
return &ateapipb.CreateActorEgressPolicyRequest{
@@ -757,7 +768,7 @@ func TestValidateEgressPolicyRules(t *testing.T) {
757768
mutate: func(p *ateapipb.EgressPolicy) {
758769
p.Rules[0].Http.Effects.ReplaceHeaders = append(
759770
p.Rules[0].Http.Effects.ReplaceHeaders,
760-
&ateapipb.CredentialHeader{Header: "authorization", CredentialUri: "ate-secret://example.com/provider/secret"},
771+
&ateapipb.CredentialHeader{Header: "authorization", CredentialUri: proto.String("ate-secret://example.com/provider/secret")},
761772
)
762773
},
763774
want: field.ErrorList{
@@ -777,7 +788,7 @@ func TestValidateEgressPolicyRules(t *testing.T) {
777788
for i := range 16 {
778789
injections = append(injections, &ateapipb.CredentialHeader{
779790
Header: fmt.Sprintf("X-Header-%d", i),
780-
CredentialUri: "ate-secret://example.com/provider/secret",
791+
CredentialUri: proto.String("ate-secret://example.com/provider/secret"),
781792
})
782793
}
783794
p.Rules[0].Http.Effects.ReplaceHeaders = injections
@@ -789,7 +800,7 @@ func TestValidateEgressPolicyRules(t *testing.T) {
789800
for i := range 17 {
790801
injections = append(injections, &ateapipb.CredentialHeader{
791802
Header: fmt.Sprintf("X-Header-%d", i),
792-
CredentialUri: "ate-secret://example.com/provider/secret",
803+
CredentialUri: proto.String("ate-secret://example.com/provider/secret"),
793804
})
794805
}
795806
p.Rules[0].Http.Effects.ReplaceHeaders = injections
@@ -806,21 +817,127 @@ func TestValidateEgressPolicyRules(t *testing.T) {
806817
field.Invalid(staticHeader.Child("prefix"), "Bearer\r", "must be a valid HTTP field value prefix"),
807818
},
808819
}, {
809-
name: "missing credential URI",
820+
name: "no credential source",
821+
mutate: func(p *ateapipb.EgressPolicy) {
822+
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = nil
823+
},
824+
want: field.ErrorList{
825+
field.Invalid(staticHeader, nil, "one of").WithOrigin("union"),
826+
},
827+
}, {
828+
name: "empty credential URI",
810829
mutate: func(p *ateapipb.EgressPolicy) {
811-
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = ""
830+
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = proto.String("")
812831
},
813832
want: field.ErrorList{
814-
field.Required(staticHeader.Child("credential_uri"), ""),
833+
field.Invalid(staticHeader.Child("credential_uri"), "", "must be ate-secret://<provider-class>/<provider-name>/<provider-specific-tail>"),
815834
},
816835
}, {
817836
name: "invalid credential URI",
818837
mutate: func(p *ateapipb.EgressPolicy) {
819-
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = "https://example.com/secret"
838+
p.Rules[0].Http.Effects.ReplaceHeaders[0].CredentialUri = proto.String("https://example.com/secret")
820839
},
821840
want: field.ErrorList{
822841
field.Invalid(staticHeader.Child("credential_uri"), "https://example.com/secret", "must be ate-secret://<provider-class>/<provider-name>/<provider-specific-tail>"),
823842
},
843+
}, {
844+
name: "actor JWT on an http rule",
845+
mutate: func(p *ateapipb.EgressPolicy) {
846+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("https://api.example.com")
847+
},
848+
}, {
849+
name: "actor JWT on an https rule",
850+
mutate: func(p *ateapipb.EgressPolicy) {
851+
p.Rules[0] = &ateapipb.EgressRule{Https: &ateapipb.HTTPSRule{
852+
Hostnames: []string{"api.example.com"},
853+
Effects: &ateapipb.HttpRuleEffects{ReplaceHeaders: []*ateapipb.CredentialHeader{actorJWTHeader("https://api.example.com")}},
854+
}}
855+
},
856+
}, {
857+
name: "actor JWT next to a credential URI",
858+
mutate: func(p *ateapipb.EgressPolicy) {
859+
jwt := actorJWTHeader("https://api.example.com")
860+
jwt.Header = "X-Actor-Token"
861+
p.Rules[0].Http.Effects.ReplaceHeaders = append(p.Rules[0].Http.Effects.ReplaceHeaders, jwt)
862+
},
863+
}, {
864+
name: "credential URI and actor JWT in one header",
865+
mutate: func(p *ateapipb.EgressPolicy) {
866+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt = &ateapipb.ActorJWTSource{Audiences: []string{"https://api.example.com"}}
867+
},
868+
want: field.ErrorList{
869+
field.Invalid(staticHeader, nil, "one of").WithOrigin("union"),
870+
},
871+
}, {
872+
name: "actor JWT without audiences",
873+
mutate: func(p *ateapipb.EgressPolicy) {
874+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader()
875+
},
876+
want: field.ErrorList{
877+
field.Required(staticHeader.Child("actor_jwt", "audiences"), ""),
878+
},
879+
}, {
880+
name: "actor JWT with an empty audience",
881+
mutate: func(p *ateapipb.EgressPolicy) {
882+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("")
883+
},
884+
want: field.ErrorList{
885+
field.TooShort(staticHeader.Child("actor_jwt", "audiences").Index(0), "", 1).WithOrigin("minLength"),
886+
},
887+
}, {
888+
name: "actor JWT with a long audience",
889+
mutate: func(p *ateapipb.EgressPolicy) {
890+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader(strings.Repeat("a", 513))
891+
},
892+
want: field.ErrorList{
893+
field.TooLong(staticHeader.Child("actor_jwt", "audiences").Index(0), "", 512).WithOrigin("maxLength"),
894+
},
895+
}, {
896+
name: "actor JWT with a repeated audience",
897+
mutate: func(p *ateapipb.EgressPolicy) {
898+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a", "a")
899+
},
900+
want: field.ErrorList{
901+
field.Duplicate(staticHeader.Child("actor_jwt", "audiences").Index(1), "a"),
902+
},
903+
}, {
904+
name: "actor JWT with too many audiences",
905+
mutate: func(p *ateapipb.EgressPolicy) {
906+
var audiences []string
907+
for i := range 17 {
908+
audiences = append(audiences, fmt.Sprintf("aud-%d", i))
909+
}
910+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader(audiences...)
911+
},
912+
want: field.ErrorList{
913+
field.TooMany(staticHeader.Child("actor_jwt", "audiences"), 17, 16).WithOrigin("maxItems"),
914+
},
915+
}, {
916+
name: "actor JWT lifetime at its bounds",
917+
mutate: func(p *ateapipb.EgressPolicy) {
918+
short, long := actorJWTHeader("a"), actorJWTHeader("a")
919+
short.ActorJwt.ExpirationSeconds = 300
920+
long.Header, long.ActorJwt.ExpirationSeconds = "X-Actor-Token", 3600
921+
p.Rules[0].Http.Effects.ReplaceHeaders = []*ateapipb.CredentialHeader{short, long}
922+
},
923+
}, {
924+
name: "actor JWT lifetime too short",
925+
mutate: func(p *ateapipb.EgressPolicy) {
926+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a")
927+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt.ExpirationSeconds = 299
928+
},
929+
want: field.ErrorList{
930+
field.Invalid(staticHeader.Child("actor_jwt", "expiration_seconds"), 299, "").WithOrigin("minimum"),
931+
},
932+
}, {
933+
name: "actor JWT lifetime too long",
934+
mutate: func(p *ateapipb.EgressPolicy) {
935+
p.Rules[0].Http.Effects.ReplaceHeaders[0] = actorJWTHeader("a")
936+
p.Rules[0].Http.Effects.ReplaceHeaders[0].ActorJwt.ExpirationSeconds = 3601
937+
},
938+
want: field.ErrorList{
939+
field.Invalid(staticHeader.Child("actor_jwt", "expiration_seconds"), 3601, "").WithOrigin("maximum"),
940+
},
824941
}, {
825942
name: "empty effects",
826943
mutate: func(p *ateapipb.EgressPolicy) {
@@ -893,7 +1010,7 @@ func TestActorEgressPolicy(t *testing.T) {
8931010
ReplaceHeaders: []*ateapipb.CredentialHeader{{
8941011
Header: "Authorization",
8951012
Prefix: "Bearer ",
896-
CredentialUri: "ate-secret://kubernetes.io/provider/ns/name",
1013+
CredentialUri: proto.String("ate-secret://kubernetes.io/provider/ns/name"),
8971014
}},
8981015
},
8991016
},

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

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

0 commit comments

Comments
 (0)