Skip to content
Merged
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
94 changes: 94 additions & 0 deletions api/ingress/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,97 @@ func (c *Client) DetachOIDCProviderFromRoute(ctx context.Context, route *ingress

return route, nil
}

func (c *Client) CreateWAFProfile(ctx context.Context, level int) (*ingress_v1alpha.WafProfile, error) {
if level < 1 || level > 4 {
return nil, fmt.Errorf("paranoia level must be between 1 and 4, got %d", level)
}

profile := &ingress_v1alpha.WafProfile{
ParanoiaLevel: int64(level),
}

name := fmt.Sprintf("waf-l%d", level)
eid, err := c.ec.CreateOrUpdate(ctx, name, profile)
if err != nil {
return nil, fmt.Errorf("failed to create WAF profile: %w", err)
}

profile.ID = eid
return profile, nil
}

func (c *Client) GetWAFProfileByID(ctx context.Context, id entity.Id) (*ingress_v1alpha.WafProfile, error) {
var profile ingress_v1alpha.WafProfile
err := c.ec.GetById(ctx, id, &profile)
if err != nil {
if errors.Is(err, cond.ErrNotFound{}) {
return nil, nil
}
return nil, fmt.Errorf("failed to lookup WAF profile: %w", err)
}

return &profile, nil
}

func (c *Client) SetRouteWAFLevel(ctx context.Context, host string, level int) (*ingress_v1alpha.HttpRoute, error) {
route, err := c.Lookup(ctx, host)
if err != nil {
return nil, err
}
if route == nil {
return nil, fmt.Errorf("route not found: %s", host)
}

return c.SetRouteWAFLevelOnRoute(ctx, route, level)
}

func (c *Client) SetRouteWAFLevelOnRoute(ctx context.Context, route *ingress_v1alpha.HttpRoute, level int) (*ingress_v1alpha.HttpRoute, error) {
if route == nil {
return nil, fmt.Errorf("route is required")
}
if level < 1 || level > 4 {
return nil, fmt.Errorf("WAF level must be between 1 and 4, got %d", level)
}

profile, err := c.CreateWAFProfile(ctx, level)
if err != nil {
return nil, err
}

route.WafProfile = profile.ID

err = c.ec.Update(ctx, route)
if err != nil {
return nil, fmt.Errorf("failed to set WAF profile on route: %w", err)
}

return route, nil
}

func (c *Client) DetachWAFProfile(ctx context.Context, host string) (*ingress_v1alpha.HttpRoute, error) {
route, err := c.Lookup(ctx, host)
if err != nil {
return nil, err
}
if route == nil {
return nil, fmt.Errorf("route not found: %s", host)
}

return c.DetachWAFProfileFromRoute(ctx, route)
}

func (c *Client) DetachWAFProfileFromRoute(ctx context.Context, route *ingress_v1alpha.HttpRoute) (*ingress_v1alpha.HttpRoute, error) {
if route == nil {
return nil, fmt.Errorf("route is required")
}
err := c.ec.Patch(ctx, route.ID, 0,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
entity.Ref(ingress_v1alpha.HttpRouteWafProfileId, ""),
)
if err != nil {
return nil, fmt.Errorf("failed to detach WAF profile from route: %w", err)
}

route.WafProfile = ""
return route, nil
}
90 changes: 90 additions & 0 deletions api/ingress/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,96 @@ func TestExtractSubdomainLabel(t *testing.T) {
}
}

func TestWAFProfile(t *testing.T) {
ctx := context.Background()

inmem, cleanup := testutils.NewInMemEntityServer(t)
defer cleanup()

ec := entityserver.NewClient(slog.Default(), inmem.EAC)
client := &Client{
log: slog.Default(),
ec: ec,
eac: inmem.EAC,
}

testAppID := entity.Id("test-app-waf")

t.Run("CreateAndGet", func(t *testing.T) {
profile, err := client.CreateWAFProfile(ctx, 2)
require.NoError(t, err)
require.Equal(t, int64(2), profile.ParanoiaLevel)

got, err := client.GetWAFProfileByID(ctx, profile.ID)
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, int64(2), got.ParanoiaLevel)
})

t.Run("InvalidLevels", func(t *testing.T) {
_, err := client.CreateWAFProfile(ctx, 0)
require.Error(t, err)

_, err = client.CreateWAFProfile(ctx, 5)
require.Error(t, err)
})

t.Run("SetRouteWAFLevel", func(t *testing.T) {
_, err := client.SetRoute(ctx, "waf.example.com", testAppID)
require.NoError(t, err)

route, err := client.SetRouteWAFLevel(ctx, "waf.example.com", 2)
require.NoError(t, err)
require.False(t, entity.Empty(route.WafProfile))

profile, err := client.GetWAFProfileByID(ctx, route.WafProfile)
require.NoError(t, err)
require.Equal(t, int64(2), profile.ParanoiaLevel)
})

t.Run("DetachWAFProfile", func(t *testing.T) {
route, err := client.DetachWAFProfile(ctx, "waf.example.com")
require.NoError(t, err)
require.True(t, entity.Empty(route.WafProfile))

looked, err := client.Lookup(ctx, "waf.example.com")
require.NoError(t, err)
require.True(t, entity.Empty(looked.WafProfile))
})

t.Run("NonExistentRoute", func(t *testing.T) {
_, err := client.SetRouteWAFLevel(ctx, "nonexistent.example.com", 1)
require.Error(t, err)
})

t.Run("SetOnRoute", func(t *testing.T) {
_, err := client.SetRoute(ctx, "waf2.example.com", testAppID)
require.NoError(t, err)

route, err := client.Lookup(ctx, "waf2.example.com")
require.NoError(t, err)

updated, err := client.SetRouteWAFLevelOnRoute(ctx, route, 3)
require.NoError(t, err)
require.False(t, entity.Empty(updated.WafProfile))

profile, err := client.GetWAFProfileByID(ctx, updated.WafProfile)
require.NoError(t, err)
require.Equal(t, int64(3), profile.ParanoiaLevel)
})

t.Run("AllParanoiaLevels", func(t *testing.T) {
for _, level := range []int{1, 2, 3, 4} {
route, err := client.SetRouteWAFLevel(ctx, "waf.example.com", level)
require.NoError(t, err)

profile, err := client.GetWAFProfileByID(ctx, route.WafProfile)
require.NoError(t, err)
require.Equal(t, int64(level), profile.ParanoiaLevel)
}
})
}

func TestValidateWildcardHost(t *testing.T) {
tests := []struct {
host string
Expand Down
64 changes: 63 additions & 1 deletion api/ingress/ingress_v1alpha/schema.gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const (
HttpRouteDefaultId = entity.Id("dev.miren.ingress/http_route.default")
HttpRouteHostId = entity.Id("dev.miren.ingress/http_route.host")
HttpRouteOidcProviderId = entity.Id("dev.miren.ingress/http_route.oidc_provider")
HttpRouteWafProfileId = entity.Id("dev.miren.ingress/http_route.waf_profile")
)

type HttpRoute struct {
Expand All @@ -20,6 +21,7 @@ type HttpRoute struct {
Default bool `cbor:"default,omitempty" json:"default,omitempty"`
Host string `cbor:"host,omitempty" json:"host,omitempty"`
OidcProvider entity.Id `cbor:"oidc_provider,omitempty" json:"oidc_provider,omitempty"`
WafProfile entity.Id `cbor:"waf_profile,omitempty" json:"waf_profile,omitempty"`
}

func (o *HttpRoute) Decode(e entity.AttrGetter) {
Expand All @@ -43,6 +45,9 @@ func (o *HttpRoute) Decode(e entity.AttrGetter) {
if a, ok := e.Get(HttpRouteOidcProviderId); ok && a.Value.Kind() == entity.KindId {
o.OidcProvider = a.Value.Id()
}
if a, ok := e.Get(HttpRouteWafProfileId); ok && a.Value.Kind() == entity.KindId {
o.WafProfile = a.Value.Id()
}
}

func (o *HttpRoute) Is(e entity.AttrGetter) bool {
Expand Down Expand Up @@ -75,6 +80,9 @@ func (o *HttpRoute) Encode() (attrs []entity.Attr) {
if !entity.Empty(o.OidcProvider) {
attrs = append(attrs, entity.Ref(HttpRouteOidcProviderId, o.OidcProvider))
}
if !entity.Empty(o.WafProfile) {
attrs = append(attrs, entity.Ref(HttpRouteWafProfileId, o.WafProfile))
}
attrs = append(attrs, entity.Ref(entity.EntityKind, KindHttpRoute))
return
}
Expand All @@ -95,6 +103,9 @@ func (o *HttpRoute) Empty() bool {
if !entity.Empty(o.OidcProvider) {
return false
}
if !entity.Empty(o.WafProfile) {
return false
}
return true
}

Expand All @@ -105,6 +116,7 @@ func (o *HttpRoute) InitSchema(sb *schema.SchemaBuilder) {
sb.Bool("default", "dev.miren.ingress/http_route.default", schema.Doc("Whether this is the default route for routing"), schema.Indexed)
sb.String("host", "dev.miren.ingress/http_route.host", schema.Doc("The hostname to match on for the application"), schema.Indexed)
sb.Ref("oidc_provider", "dev.miren.ingress/http_route.oidc_provider", schema.Doc("Reference to an OIDC provider for authentication"), schema.Indexed)
sb.Ref("waf_profile", "dev.miren.ingress/http_route.waf_profile", schema.Doc("Reference to a WAF profile for request filtering"))
}

const (
Expand Down Expand Up @@ -250,16 +262,66 @@ func (o *OidcProvider) InitSchema(sb *schema.SchemaBuilder) {
sb.String("scopes", "dev.miren.ingress/oidc_provider.scopes", schema.Doc("Space-separated list of OAuth2 scopes (e.g. \"openid email profile\")"))
}

const (
WafProfileParanoiaLevelId = entity.Id("dev.miren.ingress/waf_profile.paranoia_level")
)

type WafProfile struct {
ID entity.Id `json:"id"`
ParanoiaLevel int64 `cbor:"paranoia_level,omitempty" json:"paranoia_level,omitempty"`
}

func (o *WafProfile) Decode(e entity.AttrGetter) {
o.ID = entity.MustGet(e, entity.DBId).Value.Id()
if a, ok := e.Get(WafProfileParanoiaLevelId); ok && a.Value.Kind() == entity.KindInt64 {
o.ParanoiaLevel = a.Value.Int64()
}
}

func (o *WafProfile) Is(e entity.AttrGetter) bool {
return entity.Is(e, KindWafProfile)
}

func (o *WafProfile) ShortKind() string {
return "waf_profile"
}

func (o *WafProfile) Kind() entity.Id {
return KindWafProfile
}

func (o *WafProfile) EntityId() entity.Id {
return o.ID
}

func (o *WafProfile) Encode() (attrs []entity.Attr) {
if !entity.Empty(o.ParanoiaLevel) {
attrs = append(attrs, entity.Int64(WafProfileParanoiaLevelId, o.ParanoiaLevel))
}
attrs = append(attrs, entity.Ref(entity.EntityKind, KindWafProfile))
return
}

func (o *WafProfile) Empty() bool {
return entity.Empty(o.ParanoiaLevel)
}

func (o *WafProfile) InitSchema(sb *schema.SchemaBuilder) {
sb.Int64("paranoia_level", "dev.miren.ingress/waf_profile.paranoia_level", schema.Doc("OWASP CRS paranoia level (1-4)"))
}

var (
KindHttpRoute = entity.Id("dev.miren.ingress/kind.http_route")
KindOidcProvider = entity.Id("dev.miren.ingress/kind.oidc_provider")
KindWafProfile = entity.Id("dev.miren.ingress/kind.waf_profile")
Schema = entity.Id("dev.miren.ingress/schema.v1alpha")
)

func init() {
schema.Register("dev.miren.ingress", "v1alpha", func(sb *schema.SchemaBuilder) {
(&HttpRoute{}).InitSchema(sb)
(&OidcProvider{}).InitSchema(sb)
(&WafProfile{}).InitSchema(sb)
})
schema.RegisterEncodedSchema("dev.miren.ingress", "v1alpha", []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\x94\x94\xdbN\x840\x10\x86_\xc4DM\x8c\xc6\x13\xc6'\"]f\x80q{\xb2\xed\x92\xdd[\x13_\xc4\xd3\x1b\xea\xb5a\x80\xb0\x05\x04\xbc\xd9\f\xed\xcc\xf7\xf7\xef\xce\xf4\x03\xb4P\xf8\fX%\x8a\x1c\xea\x84t\xe1\xd0{ܒ\x06\xff\xb6?\x1f\xed<\xd4;I\x19\x82M\x9d\xd9\x05\xfcb\xc2\xfed\x9c\xd8\xe74\xb4\x9f\x1c\x8c\x12\xa4\xc7jyN(\xc1\xbf\xbeo\b\xf6gs\xa4DX˂Y\x1d\x84\x83\xc5\r\xc1g]v;[\x96IA*U\xc2Z҅\a%\xf4\xe1\x9b9z\xb0S#)3\xca\x1a\x8d:\xf4Qks\xac\x92\xfc\xa9\xb2\xd2\xf5\v\xbb\xbe\x1c\x1f?\xa65p>\x056a}\xd4\xdc\aG\xba`\xc4\xd5\"\xa2D\x01蘑\xb7\xf1\x11\xa4\xa8\xd0y2\xba\xa8\x1e\x85\xb4\xa5\x90֑\x12\xee\x90\xd6>\x14\xa3:\x12\xeb]\xcc\xde8`.v2\xb0X\xd1}\xd4j\xb01F2`\xa2\xb9\x8e\x00\xa5\xf1M5p4t{3[l\b\xb2\xd4:SQgX\xc5Km\xeb\xccz~\xea\x81Sfy\x10\"j\xdb$\xa7\xe3\xdc(\xed_\xe3p\xbd\x00K2I\xa8CJ\xc0\xe2\xd4\x7f\x0eo\xec~%\xc9c氹z\x15/\r\x89\x13\x97\x12\x13\xf9\xef\xeb\x7f\x86\xf5wK\xf5]\x90\xee\x9cd\x84\x8cV\x86\xbc\x89!\x8ay>3\x16}3\x00m\xbcz\x00\"\xd20u\xebK\xe3Bڼ\x9a\xc7}\xb3\xfc\x80\xc6\xe0u\x8d\xf6\v\x00\x00\xff\xff\x01\x00\x00\xff\xff+\xdeU\"\xb7\x05\x00\x00"))
schema.RegisterEncodedSchema("dev.miren.ingress", "v1alpha", []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x00\xff\x8c\x95\xdb\xf2\xd3 \x10\xc6_D\xc7\xc38\x9e\x8d\xe3\x13ehX\x92\xb5\x9c\x04\x1a\xdbK\x9d\xd1\x17\xf9\xabo\xa8\xd7\x0eK:\t$\xa5\xb9\xe9la\xf7\xb7|\xf0A~q\xcd\x14|\xe106\n\x1d\xe8\x06u\xef\xc0{8\xa2\xe6\xfe\xe1\xfcl5\xf31\xce4C\b\xb6u\xe6\x14\xe0\x0f\x11Ώ։sN\xa2\xfd\x13\xdc(\x86z\xddM\b\x04\xc9\xfdχ\x03\xf2\xf3\xd3\x1a\xa9a\xd6R\xc3.\x06\xe1b\xe1\x80\xfcw,{W-\xeb$C\xd5*f-\xea\xdes\xc5\xf4\xe5/qt1\x13\x91\xd8\x19e\x8d\x06\x1d\xe6h\x92\xb9\xee\xd2\xdc\xec\xb2S\xf5wR\xfdr\xbd\xfc\x9c\x96\xe0\xb4\nHa\\\xaa\xf0\xc1\xa1\xee\t\xf1\xea.b\x00\xc6\xc1\x11CL\xf1\x02ҏ\xe0<\x1aݏ\x9f\x98\xb4\x03\x93֡b\xee\xd2F\x1d\x8aPW\x12\xf5{Q\xddq\x0e\x82\x9dd\xa0f\xfd\xf5O\xec\xc6\x0f\xc6H\x02l\x98k\x01\x18\x8cO՜\xa2R\xed\xdbj\xb1A\u07b5֙\x11\xaf\x82U>4Y\x87P\xaf\xab\xa8\xafL\xc42\x81\x12\bt\\\x0eL\x98\xea\xd6}\x9ea\xe7\xe77\xeeӂ99\xed\xf1:s\x91\xb4\xd3[\xdfH\xdf\xfb*\xaa\xb1\xcc1m\x90\xb5\x12F\x90\xe9V\x14cQf\x87:Tu.7f\xcb\x1c$4;\x85I\xea\x93un\x96\xb6S\xec\x0f\x12\xfb\xe6\x0e\xac\xe9$\x82\x0e-rj\x8e\xf3\xdf\xd2a\x1fv\x92<t\x0e\x92UU>T\x1276%'\x92\xdd矲~\xe3 \xf3\xfakО\\:H\x99\x8d\x94\xbc\x8dG'\xe7\xf9\xceX\xf0\xe9\xc1\x98\xe2\xdd\x0fFF*S\x8f~0.\xb4\xe9+\xb3\xbc \xf7?8\x99\xcdvܧ|!\xfb\x8c\xf9\x1f\x00\x00\xff\xff\x01\x00\x00\xff\xff\xe5\x12\x94\f\x17\a\x00\x00"))
}
7 changes: 7 additions & 0 deletions api/ingress/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ kinds:
type: bool
indexed: true
doc: Whether this is the default route for routing
waf_profile:
type: ref
doc: Reference to a WAF profile for request filtering
oidc_provider:
type: ref
doc: Reference to an OIDC provider for authentication
Expand Down Expand Up @@ -48,3 +51,7 @@ kinds:
scopes:
type: string
doc: Space-separated list of OAuth2 scopes (e.g. "openid email profile")
waf_profile:
paranoia_level:
type: int
doc: OWASP CRS paranoia level (1-4)
Loading
Loading