Skip to content

Commit d715e40

Browse files
committed
Merge branch 'main' of ssh://github.com/linuxfoundation/lfx-v2-query-service into andrest50/query-direct-fields
2 parents 6b17840 + 0a8e1d7 commit d715e40

File tree

31 files changed

+1071
-37
lines changed

31 files changed

+1071
-37
lines changed

CLAUDE.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,76 @@ Environment variables control implementation selection:
110110
- Unit tests use mock implementations
111111
- Integration tests can switch between real and mock implementations
112112
- Test files follow `*_test.go` pattern alongside implementation files
113+
114+
## CEL Filter Feature
115+
116+
The service supports Common Expression Language (CEL) filtering for post-query resource filtering.
117+
118+
### Overview
119+
120+
CEL filtering allows API consumers to filter resources on arbitrary data fields using a safe, non-Turing complete expression language. The filter is applied after the OpenSearch query but before access control checks.
121+
122+
### Implementation Details
123+
124+
**Location**: `internal/infrastructure/filter/cel_filter.go`
125+
126+
**Key Components**:
127+
- **ResourceFilter Interface** (`internal/domain/port/filter.go`): Domain interface for filtering
128+
- **CELFilter Implementation**: Uses `google/cel-go` library for expression evaluation
129+
- **Expression Caching**: LRU cache with TTL for compiled CEL programs
130+
- **Security Features**: Max expression length (1000 chars), evaluation timeout (100ms per resource)
131+
132+
**Integration Point**: `internal/service/resource_search.go` (lines 84-102)
133+
- CEL filter applied after OpenSearch query
134+
- Filters resources before access control checks
135+
- Reduces number of access control checks needed
136+
137+
### Available Variables in CEL Expressions
138+
139+
- `data` (map): Resource data object
140+
- `resource_type` (string): Resource type
141+
- `id` (string): Resource ID
142+
143+
Note: `type` is a reserved word in CEL, so we use `resource_type` instead.
144+
145+
### Example Usage
146+
147+
```go
148+
// API call
149+
GET /query/resources?type=project&cel_filter=data.slug == "tlf"
150+
151+
// Expression is evaluated against each resource after OpenSearch query
152+
// Only matching resources proceed to access control checks
153+
```
154+
155+
### Adding CEL Filter Tests
156+
157+
When writing tests that involve resource search:
158+
159+
```go
160+
// Use MockResourceFilter for testing
161+
mockFilter := mock.NewMockResourceFilter()
162+
163+
// Pass to service constructor
164+
service := service.NewResourceSearch(mockSearcher, mockAccessChecker, mockFilter)
165+
```
166+
167+
### Common CEL Operations
168+
169+
- Equality: `data.status == "active"`
170+
- Comparison: `data.priority > 5`
171+
- Boolean logic: `data.status == "active" && data.priority > 5`
172+
- String operations: `data.name.contains("LF")`
173+
- List membership: `data.category in ["security", "networking"]`
174+
- Field existence: `has(data.archived)`
175+
176+
### Performance Considerations
177+
178+
- Compiled CEL programs are cached (100 max entries, 5-minute TTL)
179+
- Each resource evaluation has 100ms timeout
180+
- Post-query filtering means pagination may return fewer results than page size
181+
- For best performance, use specific OpenSearch criteria first, then CEL for refinement
182+
183+
### Important Limitations
184+
185+
**Pagination**: CEL filters apply only to results from each OpenSearch page. If the target resource is not in the first page of OpenSearch results, it won't be found even if it matches the CEL filter. Always use specific primary search criteria (`type`, `name`, `parent`) to narrow OpenSearch results first.

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ GOLANGCI_LINT_VERSION := v2.2.2
2626
LINT_TIMEOUT := 10m
2727
LINT_TOOL=$(shell go env GOPATH)/bin/golangci-lint
2828

29+
GOA_VERSION := v3.22.6
30+
2931
##@ Development
3032

3133
.PHONY: setup-dev
@@ -43,7 +45,7 @@ setup: ## Setup development environment
4345
.PHONY: deps
4446
deps: ## Install dependencies
4547
@echo "Installing dependencies..."
46-
go install goa.design/goa/v3/cmd/goa@latest
48+
go install goa.design/goa/v3/cmd/goa@$(GOA_VERSION)
4749

4850
.PHONY: apigen
4951
apigen: deps #@ Generate API code using Goa

README.md

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ Authorization: Bearer <jwt_token>
214214
- `name`: Resource name or alias (supports typeahead search)
215215
- `type`: Resource type to filter by
216216
- `parent`: Parent resource for hierarchical queries
217-
- `tags`: Array of tags to filter by
217+
- `tags`: Array of tags to filter by (OR logic)
218+
- `tags_all`: Array of tags where all must match (AND logic)
219+
- `cel_filter`: CEL expression for advanced post-query filtering (see [CEL Filter](#cel-filter) section)
218220
- `sort`: Sort order (name_asc, name_desc, updated_asc, updated_desc)
219221
- `page_token`: Pagination token
220222
- `v`: API version (required)
@@ -239,6 +241,119 @@ Authorization: Bearer <jwt_token>
239241
}
240242
```
241243

244+
#### CEL Filter
245+
246+
The `cel_filter` query parameter enables advanced filtering of search results using Common Expression Language (CEL). CEL is a non-Turing complete expression language designed for safe, fast evaluation of expressions in performance-critical applications.
247+
248+
**Why CEL Filter?**
249+
250+
CEL filtering was added to provide flexible, dynamic filtering capabilities on arbitrary resource data fields without modifying the OpenSearch query structure. This allows API consumers to:
251+
252+
- Filter on any field within the resource data
253+
- Combine multiple conditions with boolean logic
254+
- Perform complex comparisons beyond simple equality checks
255+
- Apply filters without requiring backend code changes
256+
257+
**What is CEL?**
258+
259+
CEL (Common Expression Language) is an open-source expression language developed by Google. It provides:
260+
261+
- **Safety**: Non-Turing complete, no side effects, no infinite loops
262+
- **Performance**: Linear time evaluation with compilation and caching
263+
- **Portability**: Language-agnostic with implementations in multiple languages
264+
- **Security**: Execution timeouts and resource constraints
265+
266+
Learn more: [CEL Specification](https://github.com/google/cel-spec) | [CEL-Go Documentation](https://github.com/google/cel-go)
267+
268+
**How It Works**
269+
270+
CEL filters are applied **after** the OpenSearch query executes but **before** access control checks. This means:
271+
272+
1. OpenSearch returns initial results based on primary search criteria (`type`, `name`, `parent`, `tags`)
273+
2. CEL filter evaluates each resource and removes non-matching items
274+
3. Access control checks are performed only on filtered results (improved performance)
275+
4. Final results are returned to the client
276+
277+
**Available Variables**
278+
279+
CEL expressions have access to the following variables for each resource:
280+
281+
- `data` (map): The resource's data object containing all custom fields
282+
- `resource_type` (string): The type of the resource (e.g., "project", "committee")
283+
- `id` (string): The unique identifier of the resource
284+
285+
**Security Constraints**
286+
287+
- **Maximum expression length**: 1000 characters
288+
- **Evaluation timeout**: 100ms per resource
289+
- **Expression caching**: Compiled programs cached with LRU and 5-minute TTL
290+
- **No external access**: Cannot make network calls or access filesystem
291+
292+
**Usage Examples**
293+
294+
Filter projects by slug:
295+
```
296+
GET /query/resources?type=project&cel_filter=data.slug == "tlf"&v=1
297+
```
298+
299+
Filter by status and priority:
300+
```
301+
GET /query/resources?type=project&cel_filter=data.status == "active" && data.priority > 5&v=1
302+
```
303+
304+
Filter by resource type:
305+
```
306+
GET /query/resources?parent=org:123&cel_filter=resource_type == "committee"&v=1
307+
```
308+
309+
Complex boolean logic:
310+
```
311+
GET /query/resources?type=project&cel_filter=data.status == "active" || (data.priority > 8 && data.category == "security")&v=1
312+
```
313+
314+
String operations:
315+
```
316+
GET /query/resources?type=project&cel_filter=data.name.contains("LF") && data.description.startsWith("Open")&v=1
317+
```
318+
319+
Check field existence:
320+
```
321+
GET /query/resources?type=project&cel_filter=has(data.archived) && data.archived == false&v=1
322+
```
323+
324+
List membership:
325+
```
326+
GET /query/resources?type=project&cel_filter=data.category in ["security", "networking", "storage"]&v=1
327+
```
328+
329+
Nested field access:
330+
```
331+
GET /query/resources?type=project&cel_filter=data.metadata.owner == "admin" && data.metadata.region == "us-west"&v=1
332+
```
333+
334+
**Supported Operators**
335+
336+
- **Comparison**: `==`, `!=`, `<`, `<=`, `>`, `>=`
337+
- **Logical**: `&&` (AND), `||` (OR), `!` (NOT)
338+
- **Arithmetic**: `+`, `-`, `*`, `/`, `%`
339+
- **String**: `contains()`, `startsWith()`, `endsWith()`, `matches()` (regex)
340+
- **Membership**: `in`
341+
- **Field check**: `has()`
342+
343+
**Important Limitations**
344+
345+
⚠️ **Pagination Consideration**: CEL filters are applied to the results from each OpenSearch page. If you're looking for a specific resource that matches your CEL filter but it's not in the first page of OpenSearch results, it may not be found. For best results when using CEL filters, use more specific primary search parameters (`type`, `name`, `parent`, `tags`) to narrow down the OpenSearch results first.
346+
347+
**Error Handling**
348+
349+
Invalid CEL expressions return a 400 Bad Request with details:
350+
351+
```json
352+
{
353+
"error": "filter expression failed: ERROR: <input>:1:6: Syntax error: mismatched input 'invalid' expecting {'[', '{', '(', '.', '-', '!', 'true', 'false', 'null', NUM_FLOAT, NUM_INT, NUM_UINT, STRING, BYTES, IDENTIFIER}"
354+
}
355+
```
356+
242357
#### Organization Search API
243358

244359
**Query Organizations:**

charts/lfx-v2-query-service/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ apiVersion: v2
55
name: lfx-v2-query-service
66
description: LFX Platform V2 Query Service chart
77
type: application
8-
version: 0.4.9
8+
version: 0.4.10
99
appVersion: "latest"

cmd/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,14 @@ func main() {
5959
accessControlChecker := service.AccessControlCheckerImpl(ctx)
6060
organizationSearcher := service.OrganizationSearcherImpl(ctx)
6161
authService := service.AuthServiceImpl(ctx)
62+
resourceFilter := service.ResourceFilterImpl(ctx)
6263

6364
// Initialize the services.
6465
var (
6566
querySvcSvc querysvc.Service
6667
)
6768
{
68-
querySvcSvc = service.NewQuerySvc(resourceSearcher, accessControlChecker, organizationSearcher, authService)
69+
querySvcSvc = service.NewQuerySvc(resourceSearcher, accessControlChecker, resourceFilter, organizationSearcher, authService)
6970
}
7071

7172
// Wrap the services in endpoints that can be invoked from other services

cmd/service/converters.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func (s *querySvcsrvc) payloadToCriteria(ctx context.Context, p *querysvc.QueryR
5858
Tags: p.Tags,
5959
TagsAll: p.TagsAll,
6060
Filters: filters,
61+
CelFilter: p.CelFilter,
6162
SortBy: p.Sort,
6263
PageToken: p.PageToken,
6364
PageSize: constants.DefaultPageSize,

cmd/service/converters_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestPayloadToCriteria(t *testing.T) {
2020
mockAccessChecker := mock.NewMockAccessControlChecker()
2121
mockOrgSearcher := mock.NewMockOrganizationSearcher()
2222
mockAuth := mock.NewMockAuthService()
23-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
23+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
2424
svc := service.(*querySvcsrvc)
2525

2626
// Setup environment variable for page token secret
@@ -165,7 +165,7 @@ func TestDomainResultToResponse(t *testing.T) {
165165
mockAccessChecker := mock.NewMockAccessControlChecker()
166166
mockOrgSearcher := mock.NewMockOrganizationSearcher()
167167
mockAuth := mock.NewMockAuthService()
168-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
168+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
169169
svc := service.(*querySvcsrvc)
170170

171171
tests := []struct {
@@ -290,7 +290,7 @@ func TestPayloadToOrganizationCriteria(t *testing.T) {
290290
mockAccessChecker := mock.NewMockAccessControlChecker()
291291
mockOrgSearcher := mock.NewMockOrganizationSearcher()
292292
mockAuth := mock.NewMockAuthService()
293-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
293+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
294294
svc := service.(*querySvcsrvc)
295295

296296
tests := []struct {
@@ -357,7 +357,7 @@ func TestDomainOrganizationToResponse(t *testing.T) {
357357
mockAccessChecker := mock.NewMockAccessControlChecker()
358358
mockOrgSearcher := mock.NewMockOrganizationSearcher()
359359
mockAuth := mock.NewMockAuthService()
360-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
360+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
361361
svc := service.(*querySvcsrvc)
362362

363363
tests := []struct {
@@ -437,7 +437,7 @@ func TestPayloadToOrganizationSuggestionCriteria(t *testing.T) {
437437
mockAccessChecker := mock.NewMockAccessControlChecker()
438438
mockOrgSearcher := mock.NewMockOrganizationSearcher()
439439
mockAuth := mock.NewMockAuthService()
440-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
440+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
441441
svc := service.(*querySvcsrvc)
442442

443443
tests := []struct {
@@ -493,7 +493,7 @@ func TestDomainOrganizationSuggestionsToResponse(t *testing.T) {
493493
mockAccessChecker := mock.NewMockAccessControlChecker()
494494
mockOrgSearcher := mock.NewMockOrganizationSearcher()
495495
mockAuth := mock.NewMockAuthService()
496-
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mockAuth)
496+
service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mock.NewMockResourceFilter(), mockOrgSearcher, mockAuth)
497497
svc := service.(*querySvcsrvc)
498498

499499
tests := []struct {

cmd/service/providers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/linuxfoundation/lfx-v2-query-service/internal/domain/port"
1515
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/auth"
1616
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/clearbit"
17+
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/filter"
1718
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/mock"
1819
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/nats"
1920
"github.com/linuxfoundation/lfx-v2-query-service/internal/infrastructure/opensearch"
@@ -242,3 +243,15 @@ func OrganizationSearcherImpl(ctx context.Context) port.OrganizationSearcher {
242243

243244
return organizationSearcher
244245
}
246+
247+
// ResourceFilterImpl injects the resource filter implementation
248+
func ResourceFilterImpl(ctx context.Context) port.ResourceFilter {
249+
slog.InfoContext(ctx, "initializing CEL resource filter")
250+
251+
celFilter, err := filter.NewCELFilter()
252+
if err != nil {
253+
log.Fatalf("failed to initialize CEL filter: %v", err)
254+
}
255+
256+
return celFilter
257+
}

cmd/service/service.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,11 @@ func (s *querySvcsrvc) Livez(ctx context.Context) (res []byte, err error) {
160160
// NewQuerySvc returns the query-svc service implementation.
161161
func NewQuerySvc(resourceSearcher port.ResourceSearcher,
162162
accessControlChecker port.AccessControlChecker,
163+
resourceFilter port.ResourceFilter,
163164
organizationSearcher port.OrganizationSearcher,
164165
auth port.Authenticator,
165166
) querysvc.Service {
166-
resourceService := service.NewResourceSearch(resourceSearcher, accessControlChecker)
167+
resourceService := service.NewResourceSearch(resourceSearcher, accessControlChecker, resourceFilter)
167168
organizationService := service.NewOrganizationSearch(organizationSearcher)
168169
return &querySvcsrvc{
169170
resourceService: resourceService,

0 commit comments

Comments
 (0)