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
17 changes: 17 additions & 0 deletions charts/lfx-v2-query-service/templates/ruleset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ spec:
config:
values:
aud: lfx-v2-query-service
- id: "rule:lfx:lfx-v2-query-service:resources-count"
match:
methods:
- GET
routes:
- path: /query/resources/count
execute:
- authenticator: oidc
- authenticator: anonymous_authenticator
{{- if .Values.app.use_oidc_contextualizer }}
- contextualizer: oidc_contextualizer
{{- end }}
- authorizer: allow_all
- finalizer: create_jwt
config:
values:
aud: lfx-v2-query-service
- id: "rule:lfx:lfx-v2-query-service:org-search"
match:
methods:
Expand Down
61 changes: 61 additions & 0 deletions cmd/service/converters.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,67 @@ func (s *querySvcsrvc) domainResultToResponse(result *model.SearchResult) *query
return response
}

func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
// Parameters used for /<index>/_count search.
criteria := model.SearchCriteria{
GroupBySize: constants.DefaultBucketSize,
// Page size is not passed to this endpoint.
PageSize: -1,
// For _count, we only want public resources.
PublicOnly: true,
}

// Set the criteria from the payload
criteria.Tags = payload.Tags
if payload.Name != nil {
criteria.Name = payload.Name
}
if payload.Type != nil {
criteria.ResourceType = payload.Type
}
if payload.Parent != nil {
criteria.ParentRef = payload.Parent
}

return criteria
}

func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
// Parameters used for the "group by" aggregated /<index>/_search search.
criteria := model.SearchCriteria{
GroupBySize: constants.DefaultBucketSize,
// We only want the aggregation, not the actual results.
PageSize: 0,
// The aggregation results will only count private resources.
PrivateOnly: true,
// Set the attribute to aggregate on.
// Use .keyword subfield for aggregation on text fields
GroupBy: "access_check_query.keyword",
}

// Set the criteria from the payload
criteria.Tags = payload.Tags
if payload.Name != nil {
criteria.Name = payload.Name
}
if payload.Type != nil {
criteria.ResourceType = payload.Type
}
if payload.Parent != nil {
criteria.ParentRef = payload.Parent
}

return criteria
}

func (s *querySvcsrvc) domainCountResultToResponse(result *model.CountResult) *querysvc.QueryResourcesCountResult {
return &querysvc.QueryResourcesCountResult{
Count: uint64(result.Count),
HasMore: result.HasMore,
CacheControl: result.CacheControl,
}
}

// payloadToOrganizationCriteria converts the generated payload to domain organization search criteria
func (s *querySvcsrvc) payloadToOrganizationCriteria(ctx context.Context, p *querysvc.QueryOrgsPayload) model.OrganizationSearchCriteria {
criteria := model.OrganizationSearchCriteria{
Expand Down
22 changes: 22 additions & 0 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ func (s *querySvcsrvc) QueryResources(ctx context.Context, p *querysvc.QueryReso
return res, nil
}

// QueryResourcesCount returns an aggregate count of resources the user hase
// access to, by implementing an aggregation over the stored OpenFGA
// relationship.
func (s *querySvcsrvc) QueryResourcesCount(ctx context.Context, p *querysvc.QueryResourcesCountPayload) (*querysvc.QueryResourcesCountResult, error) {

slog.DebugContext(ctx, "querySvc.query-resource-counts",
"name", p.Name,
)

// Convert payload to domain criteria
countCriteria := s.payloadToCountPublicCriteria(p)
aggregationCriteria := s.payloadToCountAggregationCriteria(p)

// Execute search using the service layer
result, errQueryResources := s.resourceService.QueryResourcesCount(ctx, countCriteria, aggregationCriteria)
if errQueryResources != nil {
return nil, wrapError(ctx, errQueryResources)
}

return s.domainCountResultToResponse(result), nil
}

// Locate a single organization by name or domain.
func (s *querySvcsrvc) QueryOrgs(ctx context.Context, p *querysvc.QueryOrgsPayload) (res *querysvc.Organization, err error) {

Expand Down
124 changes: 124 additions & 0 deletions cmd/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package service

import (
"context"
"fmt"
"testing"

querysvc "github.com/linuxfoundation/lfx-v2-query-service/gen/query_svc"
Expand Down Expand Up @@ -172,6 +173,129 @@ func TestQuerySvcsrvc_QueryResources(t *testing.T) {
}
}

func TestQuerySvcsrvc_QueryResourcesCount(t *testing.T) {
tests := []struct {
name string
payload *querysvc.QueryResourcesCountPayload
setupMocks func(*mock.MockResourceSearcher, *mock.MockAccessControlChecker)
expectedError bool
expectedErrorType interface{}
expectedCount uint64
}{
{
name: "successful count query",
payload: &querysvc.QueryResourcesCountPayload{
Version: "1",
Type: stringPtr("project"),
},
setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) {
searcher.SetQueryResourcesCountResponse(&model.CountResult{
Count: 5,
HasMore: false,
})
accessChecker.DefaultResult = "allowed"
},
expectedError: false,
expectedCount: 5,
},
{
name: "successful count query with name filter",
payload: &querysvc.QueryResourcesCountPayload{
Version: "1",
Name: stringPtr("Test"),
Type: stringPtr("committee"),
},
setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) {
searcher.SetQueryResourcesCountResponse(&model.CountResult{
Count: 2,
HasMore: false,
})
accessChecker.DefaultResult = "allowed"
},
expectedError: false,
expectedCount: 2,
},
{
name: "count query with tags",
payload: &querysvc.QueryResourcesCountPayload{
Version: "1",
Tags: []string{"active", "governance"},
},
setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) {
searcher.SetQueryResourcesCountResponse(&model.CountResult{
Count: 10,
HasMore: true,
})
accessChecker.DefaultResult = "allowed"
},
expectedError: false,
expectedCount: 10,
},
{
name: "count query with parent filter",
payload: &querysvc.QueryResourcesCountPayload{
Version: "1",
Parent: stringPtr("project:123"),
},
setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) {
searcher.SetQueryResourcesCountResponse(&model.CountResult{
Count: 3,
HasMore: false,
})
accessChecker.DefaultResult = "allowed"
},
expectedError: false,
expectedCount: 3,
},
{
name: "count query with service error",
payload: &querysvc.QueryResourcesCountPayload{
Version: "1",
Type: stringPtr("invalid"),
},
setupMocks: func(searcher *mock.MockResourceSearcher, accessChecker *mock.MockAccessControlChecker) {
searcher.SetQueryResourcesCountError(fmt.Errorf("service error"))
},
expectedError: true,
expectedErrorType: &querysvc.InternalServerError{},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup mocks
mockResourceSearcher := mock.NewMockResourceSearcher()
mockAccessChecker := mock.NewMockAccessControlChecker()
mockOrgSearcher := mock.NewMockOrganizationSearcher()
tc.setupMocks(mockResourceSearcher, mockAccessChecker)

service := NewQuerySvc(mockResourceSearcher, mockAccessChecker, mockOrgSearcher, mock.NewMockAuthService())
svc, ok := service.(*querySvcsrvc)
assert.True(t, ok)

ctx := context.WithValue(context.Background(), constants.PrincipalContextID, "test-user")

// Execute
result, err := svc.QueryResourcesCount(ctx, tc.payload)

// Verify
if tc.expectedError {
assert.Error(t, err)
if tc.expectedErrorType != nil {
assert.IsType(t, tc.expectedErrorType, err)
}
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, tc.expectedCount, result.Count)
// HasMore is returned from the service
assert.NotNil(t, result.HasMore)
}
})
}
}

func TestQuerySvcsrvc_QueryOrgs(t *testing.T) {
tests := []struct {
name string
Expand Down
62 changes: 62 additions & 0 deletions design/query-svc.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,68 @@ var _ = dsl.Service("query-svc", func() {
})
})

dsl.Method("query-resources-count", func() {
dsl.Description("Count matching resources by query.")

dsl.Security(JWTAuth)

dsl.Payload(func() {
dsl.Token("bearer_token", dsl.String, func() {
dsl.Description("JWT token issued by Heimdall")
dsl.Example("eyJhbGci...")
})
dsl.Attribute("version", dsl.String, "Version of the API", func() {
dsl.Enum("1")
dsl.Example("1")
})
dsl.Attribute("name", dsl.String, "Resource name or alias; supports typeahead", func() {
dsl.Example("gov board")
dsl.MinLength(1)
})
dsl.Attribute("parent", dsl.String, "Parent (for navigation; varies by object type)", func() {
dsl.Example("project:123")
})
dsl.Attribute("type", dsl.String, "Resource type to search", func() {
dsl.Example("committee")
})
dsl.Attribute("tags", dsl.ArrayOf(dsl.String), "Tags to search (varies by object type)", func() {
dsl.Example([]string{"active"})
})
dsl.Required("bearer_token", "version")
})

dsl.Result(func() {
dsl.Attribute("count", dsl.UInt64, "Count of resources found", func() {
dsl.Example(1234)
})
dsl.Attribute("has_more", dsl.Boolean, "True if count is not guaranteed to be exhaustive: client should request a narrower query", func() {
dsl.Example(false)
})
dsl.Attribute("cache_control", dsl.String, "Cache control header", func() {
dsl.Example("public, max-age=300")
})
dsl.Required("count", "has_more")
})

dsl.Error("BadRequest", dsl.ErrorResult, "Bad request")

dsl.HTTP(func() {
dsl.GET("/query/resources/count")
dsl.Param("version:v")
dsl.Param("name")
dsl.Param("parent")
dsl.Param("type")
dsl.Param("tags")
dsl.Header("bearer_token:Authorization")
dsl.Response(dsl.StatusOK, func() {
dsl.Header("cache_control:Cache-Control")
})
dsl.Response("BadRequest", dsl.StatusBadRequest)
dsl.Response("InternalServerError", dsl.StatusInternalServerError)
dsl.Response("ServiceUnavailable", dsl.StatusServiceUnavailable)
})
})

dsl.Method("query-orgs", func() {
dsl.Description("Locate a single organization by name or domain.")

Expand Down
Loading