Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e6d5ae0
OSAC-3609: make FilterTranslator descriptor-driven instead of generic…
wgordon17 Aug 5, 2026
e98cec7
OSAC-3609: thread SetFilterDesc through GenericDAO/GenericServer and …
wgordon17 Aug 5, 2026
74d5d70
OSAC-3609: classify map-typed spec/status fields correctly in filter …
wgordon17 Aug 5, 2026
20f426f
OSAC-3609: add regression tests for map-typed filter field translation
wgordon17 Aug 5, 2026
76f9336
OSAC-3609: wire public filter descriptors through the 26 PR #921 reso…
wgordon17 Aug 5, 2026
d1d40c5
OSAC-3609: add SetFilterDesc to the 3 private-only server builders fo…
wgordon17 Aug 5, 2026
7de24f4
OSAC-3609: fix filter oracle in secrets, cluster-versions, and bare-m…
wgordon17 Aug 5, 2026
aac4eac
OSAC-3609: extract shared resource-server registration for testability
wgordon17 Aug 5, 2026
63fd108
OSAC-3609: add reflection-driven regression test for private-field fi…
wgordon17 Aug 5, 2026
c45a260
OSAC-3609: add Events Watch regression test for the private-only hub …
wgordon17 Aug 5, 2026
107c8fc
OSAC-3609: add targeted filter regression test for the Users server
wgordon17 Aug 5, 2026
8df30f8
OSAC-3609: restore vault CLI flags and health check dropped during re…
wgordon17 Aug 7, 2026
fcc922f
OSAC-3609: address CodeRabbit review feedback
wgordon17 Aug 7, 2026
d797ab7
OSAC-3609: address second round of CodeRabbit feedback
wgordon17 Aug 7, 2026
5fee0e0
OSAC-3609: quote JSON field names in filter SQL instead of interpolating
wgordon17 Aug 7, 2026
03136ea
OSAC-3609: guard translateSelectJsonField against unknown field names
wgordon17 Aug 8, 2026
d6df36d
OSAC-3609: sort discovered filter-oracle cases for determinism
wgordon17 Aug 8, 2026
8d03424
Fix CI regressions left by rebase onto latest main
wgordon17 Aug 13, 2026
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions fulfillment-service/internal/database/dao/dao_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ func (e *ErrImmutable) Error() string {
return fmt.Sprintf("fields %s are immutable", english.WordSeries(quoted, "and"))
}

// ErrInvalidFilter indicates that a CEL filter expression failed to translate — either a syntax error, a reference
// to a field that doesn't exist on the type visible to the caller, or an unsupported operation on a field kind. The
// reason string is derived from the caller's own filter expression and CEL diagnostics, so it is safe to return as
// part of the error response, for example as the message of a gRPC status error — it must never be built by
// interpolating internal implementation details, such as generated SQL text, into the message.
type ErrInvalidFilter struct {
Reason string
}

// Error returns the error message.
func (e *ErrInvalidFilter) Error() string {
return e.Reason
}

// ErrReference indicates that an operation failed because it references an entity that doesn't exist, for example a
// tenant or a project.
type ErrReference struct {
Expand Down
17 changes: 17 additions & 0 deletions fulfillment-service/internal/database/dao/dao_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ var _ = Describe("Errors", func() {
})
})

Describe("ErrInvalidFilter", func() {
It("Implements the error interface", func() {
var err error = &ErrInvalidFilter{Reason: "field doesn't exist"}
Expect(err).To(HaveOccurred())
})

It("Returns the Reason field as the error message", func() {
err := &ErrInvalidFilter{Reason: "field 'my_field' doesn't exist"}
Expect(err.Error()).To(Equal("field 'my_field' doesn't exist"))
})

It("Returns empty string when Reason is empty", func() {
err := &ErrInvalidFilter{}
Expect(err.Error()).To(BeEmpty())
})
})

Describe("ErrReference", func() {
It("Implements the error interface", func() {
var err error = &ErrReference{
Expand Down
189 changes: 113 additions & 76 deletions fulfillment-service/internal/database/dao/filter_translator.go

Large diffs are not rendered by default.

156 changes: 118 additions & 38 deletions fulfillment-service/internal/database/dao/filter_translator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,52 @@ import (
. "github.com/onsi/ginkgo/v2/dsl/table"
. "github.com/onsi/gomega"

"google.golang.org/protobuf/reflect/protoreflect"

privatev1 "github.com/osac-project/osac/fulfillment-service/internal/api/osac/private/v1"
publicv1 "github.com/osac-project/osac/fulfillment-service/internal/api/osac/public/v1"
testsv1 "github.com/osac-project/osac/fulfillment-service/internal/api/osac/tests/v1"
)

var _ = Describe("Filter translator", func() {
Describe("Construction", func() {
It("Fails without a descriptor", func() {
_, err := NewFilterTranslator().
SetLogger(logger).
Build()
Expect(err).To(HaveOccurred())
})

It("Succeeds with a descriptor", func() {
_, err := NewFilterTranslator().
SetLogger(logger).
SetDescriptor((*testsv1.Object)(nil).ProtoReflect().Descriptor()).
Build()
Expect(err).ToNot(HaveOccurred())
})
})

Describe("Object translation", func() {
var translator *FilterTranslator[*testsv1.Object]
var translator *FilterTranslator

BeforeEach(func() {
var err error

translator, err = NewFilterTranslator[*testsv1.Object]().
translator, err = NewFilterTranslator().
SetLogger(logger).
SetDescriptor((*testsv1.Object)(nil).ProtoReflect().Descriptor()).
Build()
Expect(err).ToNot(HaveOccurred())
})

It("Rejects a filter referencing a field absent from the configured descriptor", func(ctx context.Context) {
restricted, err := NewFilterTranslator().
SetLogger(logger).
SetDescriptor((*testsv1.Status)(nil).ProtoReflect().Descriptor()).
Build()
Expect(err).ToNot(HaveOccurred())
_, err = restricted.Translate(ctx, "this.my_bool")
Expect(err).To(HaveOccurred())
})

DescribeTable(
Expand Down Expand Up @@ -374,6 +405,16 @@ var _ = Describe("Filter translator", func() {
`this.metadata.project.contains('my')`,
`cast(project as text) like '%my%'`,
),
Entry(
"Bracket-index on a map field nested under spec",
`this.spec.spec_map["key"] == null`,
`data->'spec'->'spec_map'->>'key' is null`,
),
Entry(
"Key presence in a map field nested under spec",
`'key' in this.spec.spec_map`,
`data->'spec'->'spec_map' ? 'key'`,
),
)

DescribeTable(
Expand All @@ -395,49 +436,88 @@ var _ = Describe("Filter translator", func() {
`this.spec.spec_enum != (1 + 1)`,
),
)
})

// Projects need special translation because the type of the 'name' column is 'ltree', and that can't be
// compared directly to strings using the 'like' operator.
Describe("Project translation", func() {
var translator *FilterTranslator[*privatev1.Project]

BeforeEach(func() {
var err error

translator, err = NewFilterTranslator[*privatev1.Project]().
SetLogger(logger).
Build()
Expect(err).ToNot(HaveOccurred())
})

// These two cases are currently rejected by CEL's own type checker at Compile() time — the 'in' operator
// has no overload for a plain string/message operand — so they don't exercise translateInField's
// default branch directly. They're kept as regression tests of the externally observable contract
// (translate-time error, never broken SQL for an unsupported 'in' target), which also covers that
// branch if CEL's checking behavior ever changes.
DescribeTable(
"Project translation",
func(ctx context.Context, filter, expected string) {
actual, err := translator.Translate(ctx, filter)
Expect(err).ToNot(HaveOccurred())
Expect(actual).To(Equal(expected))
"'in' operator: unsupported target kind errors",
func(ctx context.Context, filter string) {
_, err := translator.Translate(ctx, filter)
Expect(err).To(HaveOccurred())
},
Entry(
"Compare name to string",
`this.metadata.name == 'my_project'`,
`name = 'my_project'`,
"'in' operator against a plain string field",
`'x' in this.spec.spec_string`,
),
Entry(
"Name starts with string",
`this.metadata.name.startsWith('my_project.')`,
`cast(name as text) like 'my\_project.%'`,
),
Entry(
"Name ends with string",
`this.metadata.name.endsWith('.my_project')`,
`cast(name as text) like '%.my\_project'`,
),
Entry(
"Name contains string",
`this.metadata.name.contains('my')`,
`cast(name as text) like '%my%'`,
"'in' operator against a nested message field",
`'x' in this.spec.spec_msg`,
),
)

It("Does not leak the JSON operand path in the unsupported-field-kind error", func(ctx context.Context) {
_, err := translator.Translate(ctx, `this.spec.spec_bytes == this.spec.spec_bytes`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal(
"select of JSON field 'spec_bytes' of type 'osac.tests.v1.Spec' of kind 'bytes' isn't supported",
))
})
})

// Projects need special translation because the type of the 'name' column is 'ltree', and that can't be
// compared directly to strings using the 'like' operator. Both the public and private Project descriptors
// must be recognized, since a public-fronted ProjectsServer now configures the translator with the public
// descriptor while PrivateProjectsServer keeps using the private one.
Describe("Project translation", func() {
for _, desc := range []protoreflect.MessageDescriptor{
(*publicv1.Project)(nil).ProtoReflect().Descriptor(),
(*privatev1.Project)(nil).ProtoReflect().Descriptor(),
} {
Describe(string(desc.FullName()), func() {
var translator *FilterTranslator

BeforeEach(func() {
var err error

translator, err = NewFilterTranslator().
SetLogger(logger).
SetDescriptor(desc).
Build()
Expect(err).ToNot(HaveOccurred())
})

DescribeTable(
"Project translation",
func(ctx context.Context, filter, expected string) {
actual, err := translator.Translate(ctx, filter)
Expect(err).ToNot(HaveOccurred())
Expect(actual).To(Equal(expected))
},
Entry(
"Compare name to string",
`this.metadata.name == 'my_project'`,
`name = 'my_project'`,
),
Entry(
"Name starts with string",
`this.metadata.name.startsWith('my_project.')`,
`cast(name as text) like 'my\_project.%'`,
),
Entry(
"Name ends with string",
`this.metadata.name.endsWith('.my_project')`,
`cast(name as text) like '%.my\_project'`,
),
Entry(
"Name contains string",
`this.metadata.name.contains('my')`,
`cast(name as text) like '%my%'`,
),
)
})
}
})
})
21 changes: 18 additions & 3 deletions fulfillment-service/internal/database/dao/generic_dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type GenericDAOBuilder[O Object] struct {
eventCallbacks []EventCallback
tenancyLogic auth.TenancyLogic
metricsRegisterer prometheus.Registerer
filterDesc protoreflect.MessageDescriptor
}

// GenericDAO provides generic data access operations for protocol buffers messages. It assumes that objects will be
Expand Down Expand Up @@ -79,7 +80,7 @@ type GenericDAO[O Object] struct {
jsonEncoder *json.Encoder
marshalOptions protojson.MarshalOptions
unmarshalOptions protojson.UnmarshalOptions
filterTranslator *FilterTranslator[O]
filterTranslator *FilterTranslator
tenancyLogic auth.TenancyLogic

// Metrics:
Expand Down Expand Up @@ -189,6 +190,14 @@ func (b *GenericDAOBuilder[O]) SetTableName(value string) *GenericDAOBuilder[O]
return b
}

// SetFilterDesc sets the protobuf message descriptor used to validate and translate CEL filter expressions. This is
// optional. When unset, the descriptor of the O generic parameter is used. Pass a different descriptor to restrict
// which fields clients may reference in filters — public servers over private storage pass the public descriptor.
func (b *GenericDAOBuilder[O]) SetFilterDesc(value protoreflect.MessageDescriptor) *GenericDAOBuilder[O] {
b.filterDesc = value
return b
}

// Build creates a new generic DAO using the configuration stored in the builder.
func (b *GenericDAOBuilder[O]) Build() (result *GenericDAO[O], err error) {
// Check parameters:
Expand Down Expand Up @@ -271,9 +280,15 @@ func (b *GenericDAOBuilder[O]) Build() (result *GenericDAO[O], err error) {
DiscardUnknown: true,
}

// Create the filter translator:
filterTranslator, err := NewFilterTranslator[O]().
// Create the filter translator. The filter descriptor defaults to the object's own descriptor, but callers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] defense in depth

When SetFilterDesc is not called (filterDesc is nil), the fallback is objectDesc -- the private proto descriptor. No compile-time enforcement ensures public-facing servers call SetFilterDesc. A future resource registered outside RegisterResourceServers without a manual test would silently use the private descriptor.

Suggested fix: Consider making SetFilterDesc mandatory (no nil fallback) so all servers must explicitly set it.

// may override it via SetFilterDesc to restrict which fields are visible to filter expressions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] fail-open

When GenericDAOBuilder.filterDesc is nil (the default), Build() falls back to the private object descriptor. A public server that forgets to call SetFilterDesc silently uses the private descriptor and exposes private fields through filters. Mitigated by the reflection-driven regression test in register_servers_test.go.

filterDesc := b.filterDesc
if filterDesc == nil {
filterDesc = objectDesc
}
filterTranslator, err := NewFilterTranslator().
SetLogger(b.logger).
SetDescriptor(filterDesc).
Build()
if err != nil {
err = fmt.Errorf("failed to create filter translator: %w", err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func (r *ListRequest[O]) do(ctx context.Context) (response *ListResponse[O], err
var filter string
filter, err = r.dao.filterTranslator.Translate(ctx, r.filter)
if err != nil {
err = &ErrInvalidFilter{Reason: err.Error()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] error-wrapping-idiom

ErrInvalidFilter is constructed via string-flattening (err.Error()) rather than error-chain wrapping. This discards the original error chain and differs from the DAO's convention for other typed errors.

return
}
if r.sql.filter.Len() > 0 {
Expand Down
43 changes: 43 additions & 0 deletions fulfillment-service/internal/database/dao/generic_dao_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2579,6 +2579,49 @@ var _ = Describe("Generic DAO", func() {
Expect(object.GetMetadata().GetName()).To(Equal("object-a"))
})
})

Describe("Filter descriptor override", func() {
It("Behaves identically when SetFilterDesc is set explicitly to the object's own descriptor", func() {
explicitDao, err := NewGenericDAO[*testsv1.Object]().
SetLogger(logger).
SetTenancyLogic(tenancy).
SetFilterDesc((*testsv1.Object)(nil).ProtoReflect().Descriptor()).
Build()
Expect(err).ToNot(HaveOccurred())
_, err = explicitDao.Create().
SetObject(
testsv1.Object_builder{
Metadata: testsv1.Metadata_builder{
Tenant: "my-tenant",
Name: "my-object",
}.Build(),
MyBool: true,
}.Build(),
).
Do(ctx)
Expect(err).ToNot(HaveOccurred())
response, err := explicitDao.List().
SetFilter("this.my_bool == true").
Do(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(response.GetItems()).To(HaveLen(1))
})

It("Rejects a filter referencing a field absent from the overridden descriptor", func() {
restrictedDao, err := NewGenericDAO[*testsv1.Object]().
SetLogger(logger).
SetTenancyLogic(tenancy).
SetFilterDesc((*testsv1.Status)(nil).ProtoReflect().Descriptor()).
Build()
Expect(err).ToNot(HaveOccurred())
_, err = restrictedDao.List().
SetFilter("this.my_bool == true").
Do(ctx)
Expect(err).To(HaveOccurred())
var invalidFilterErr *ErrInvalidFilter
Expect(errors.As(err, &invalidFilterErr)).To(BeTrue())
})
})
})

Describe("Project filtering", func() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func (b *BareMetalInstanceTypesServerBuilder) Build() (result *BareMetalInstance
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetFilterDesc((*publicv1.BareMetalInstanceType)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func (b *BareMetalInstanceCatalogItemsServerBuilder) Build() (result *BareMetalI
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetReferenceChecker(referenceChecker).
SetFilterDesc((*publicv1.BareMetalInstanceCatalogItem)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func (b *BareMetalInstanceTemplatesServerBuilder) Build() (result *BareMetalInst
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetFilterDesc((*publicv1.BareMetalInstanceTemplate)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ func (b *BareMetalInstancesServerBuilder) Build() (result *BareMetalInstancesSer
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetFilterDesc((*publicv1.BareMetalInstance)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func (b *ClusterCatalogItemsServerBuilder) Build() (result *ClusterCatalogItemsS
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetFilterDesc((*publicv1.ClusterCatalogItem)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func (b *ClusterTemplatesServerBuilder) Build() (result *ClusterTemplatesServer,
SetAttributionLogic(b.attributionLogic).
SetTenancyLogic(b.tenancyLogic).
SetMetricsRegisterer(b.metricsRegisterer).
SetFilterDesc((*publicv1.ClusterTemplate)(nil).ProtoReflect().Descriptor()).
Build()
if err != nil {
return
Expand Down
Loading
Loading