Skip to content

Commit 75688bc

Browse files
committed
feat: Add QueryResourcesCount API endpoint
Implement resource count aggregation endpoint to provide efficient counting of resources with access control support. Changes include: - Add new GET /query/resources/count endpoint in Goa design - Implement QueryResourcesCount method in service layer - Add resource count domain model and service interfaces - Update mock implementation to support count operations - Fix OpenSearch aggregation field to use .keyword subfield - Add access control integration for private resource counting Generated with Claude Code (https://claude.ai/code) Signed-off-by: Andres Tobon <[email protected]>
1 parent ee23e30 commit 75688bc

File tree

32 files changed

+1634
-66
lines changed

32 files changed

+1634
-66
lines changed

charts/lfx-v2-query-service/templates/ruleset.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ spec:
4545
config:
4646
values:
4747
aud: lfx-v2-query-service
48+
- id: "rule:lfx:lfx-v2-query-service:resources-count"
49+
match:
50+
methods:
51+
- GET
52+
routes:
53+
- path: /query/resources/count
54+
execute:
55+
- authenticator: oidc
56+
- authenticator: anonymous_authenticator
57+
{{- if .Values.app.use_oidc_contextualizer }}
58+
- contextualizer: oidc_contextualizer
59+
{{- end }}
60+
- authorizer: allow_all
61+
- finalizer: create_jwt
62+
config:
63+
values:
64+
aud: lfx-v2-query-service
4865
- id: "rule:lfx:lfx-v2-query-service:org-search"
4966
match:
5067
methods:

cmd/service/converters.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,67 @@ func (s *querySvcsrvc) domainResultToResponse(result *model.SearchResult) *query
7979
return response
8080
}
8181

82+
func (s *querySvcsrvc) payloadToCountPublicCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
83+
// Parameters used for /<index>/_count search.
84+
criteria := model.SearchCriteria{
85+
GroupBySize: constants.DefaultBucketSize,
86+
// Page size is not passed to this endpoint.
87+
PageSize: -1,
88+
// For _count, we only want public resources.
89+
PublicOnly: true,
90+
}
91+
92+
// Set the criteria from the payload
93+
criteria.Tags = payload.Tags
94+
if payload.Name != nil {
95+
criteria.Name = payload.Name
96+
}
97+
if payload.Type != nil {
98+
criteria.ResourceType = payload.Type
99+
}
100+
if payload.Parent != nil {
101+
criteria.ParentRef = payload.Parent
102+
}
103+
104+
return criteria
105+
}
106+
107+
func (s *querySvcsrvc) payloadToCountAggregationCriteria(payload *querysvc.QueryResourcesCountPayload) model.SearchCriteria {
108+
// Parameters used for the "group by" aggregated /<index>/_search search.
109+
criteria := model.SearchCriteria{
110+
GroupBySize: constants.DefaultBucketSize,
111+
// We only want the aggregation, not the actual results.
112+
PageSize: 0,
113+
// The aggregation results will only count private resources.
114+
PrivateOnly: true,
115+
// Set the attribute to aggregate on.
116+
// Use .keyword subfield for aggregation on text fields
117+
GroupBy: "access_check_query.keyword",
118+
}
119+
120+
// Set the criteria from the payload
121+
criteria.Tags = payload.Tags
122+
if payload.Name != nil {
123+
criteria.Name = payload.Name
124+
}
125+
if payload.Type != nil {
126+
criteria.ResourceType = payload.Type
127+
}
128+
if payload.Parent != nil {
129+
criteria.ParentRef = payload.Parent
130+
}
131+
132+
return criteria
133+
}
134+
135+
func (s *querySvcsrvc) domainCountResultToResponse(result *model.CountResult) *querysvc.QueryResourcesCountResult {
136+
return &querysvc.QueryResourcesCountResult{
137+
Count: uint64(result.Count),
138+
HasMore: result.HasMore,
139+
CacheControl: result.CacheControl,
140+
}
141+
}
142+
82143
// payloadToOrganizationCriteria converts the generated payload to domain organization search criteria
83144
func (s *querySvcsrvc) payloadToOrganizationCriteria(ctx context.Context, p *querysvc.QueryOrgsPayload) model.OrganizationSearchCriteria {
84145
criteria := model.OrganizationSearchCriteria{

cmd/service/service.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import (
1818

1919
// query-svc service implementation using clean architecture.
2020
type querySvcsrvc struct {
21-
resourceService service.ResourceSearcher
22-
organizationService service.OrganizationSearcher
23-
auth port.Authenticator
21+
resourceService service.ResourceSearcher
22+
resourceCountService service.ResourceCountSearcher
23+
organizationService service.OrganizationSearcher
24+
auth port.Authenticator
2425
}
2526

2627
// JWTAuth implements the authorization logic for service "query-svc" for the
@@ -66,6 +67,28 @@ func (s *querySvcsrvc) QueryResources(ctx context.Context, p *querysvc.QueryReso
6667
return res, nil
6768
}
6869

70+
// QueryResourcesCount returns an aggregate count of resources the user hase
71+
// access to, by implementing an aggregation over the stored OpenFGA
72+
// relationship.
73+
func (s *querySvcsrvc) QueryResourcesCount(ctx context.Context, p *querysvc.QueryResourcesCountPayload) (*querysvc.QueryResourcesCountResult, error) {
74+
75+
slog.DebugContext(ctx, "querySvc.query-resources",
76+
"name", p.Name,
77+
)
78+
79+
// Convert payload to domain criteria
80+
countCriteria := s.payloadToCountPublicCriteria(p)
81+
aggregationCriteria := s.payloadToCountAggregationCriteria(p)
82+
83+
// Execute search using the service layer
84+
result, errQueryResources := s.resourceCountService.QueryResourcesCount(ctx, countCriteria, aggregationCriteria)
85+
if errQueryResources != nil {
86+
return nil, wrapError(ctx, errQueryResources)
87+
}
88+
89+
return s.domainCountResultToResponse(result), nil
90+
}
91+
6992
// Locate a single organization by name or domain.
7093
func (s *querySvcsrvc) QueryOrgs(ctx context.Context, p *querysvc.QueryOrgsPayload) (res *querysvc.Organization, err error) {
7194

@@ -136,10 +159,12 @@ func NewQuerySvc(resourceSearcher port.ResourceSearcher,
136159
auth port.Authenticator,
137160
) querysvc.Service {
138161
resourceService := service.NewResourceSearch(resourceSearcher, accessControlChecker)
162+
resourceCountService := service.NewResourceCount(resourceSearcher, accessControlChecker)
139163
organizationService := service.NewOrganizationSearch(organizationSearcher)
140164
return &querySvcsrvc{
141-
resourceService: resourceService,
142-
organizationService: organizationService,
143-
auth: auth,
165+
resourceService: resourceService,
166+
resourceCountService: resourceCountService,
167+
organizationService: organizationService,
168+
auth: auth,
144169
}
145170
}

design/query-svc.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,66 @@ var _ = dsl.Service("query-svc", func() {
8686
})
8787
})
8888

89+
dsl.Method("query-resources-count", func() {
90+
dsl.Description("Count matching resources by query.")
91+
92+
dsl.Security(JWTAuth)
93+
94+
dsl.Payload(func() {
95+
dsl.Token("bearer_token", dsl.String, func() {
96+
dsl.Description("JWT token issued by Heimdall")
97+
dsl.Example("eyJhbGci...")
98+
})
99+
dsl.Attribute("version", dsl.String, "Version of the API", func() {
100+
dsl.Enum("1")
101+
dsl.Example("1")
102+
})
103+
dsl.Attribute("name", dsl.String, "Resource name or alias; supports typeahead", func() {
104+
dsl.Example("gov board")
105+
dsl.MinLength(1)
106+
})
107+
dsl.Attribute("parent", dsl.String, "Parent (for navigation; varies by object type)", func() {
108+
dsl.Example("project:123")
109+
})
110+
dsl.Attribute("type", dsl.String, "Resource type to search", func() {
111+
dsl.Example("committee")
112+
})
113+
dsl.Attribute("tags", dsl.ArrayOf(dsl.String), "Tags to search (varies by object type)", func() {
114+
dsl.Example([]string{"active"})
115+
})
116+
dsl.Required("bearer_token", "version")
117+
})
118+
119+
dsl.Result(func() {
120+
dsl.Attribute("count", dsl.UInt64, "Count of resources found", func() {
121+
dsl.Example(1234)
122+
})
123+
dsl.Attribute("has_more", dsl.Boolean, "True if count is not guaranteed to be exhaustive: client should request a narrower query", func() {
124+
dsl.Example(false)
125+
})
126+
dsl.Attribute("cache_control", dsl.String, "Cache control header", func() {
127+
dsl.Example("public, max-age=300")
128+
})
129+
dsl.Required("count", "has_more")
130+
})
131+
132+
dsl.Error("BadRequest", dsl.ErrorResult, "Bad request")
133+
134+
dsl.HTTP(func() {
135+
dsl.GET("/query/resources/count")
136+
dsl.Param("version:v")
137+
dsl.Param("name")
138+
dsl.Param("parent")
139+
dsl.Param("type")
140+
dsl.Param("tags")
141+
dsl.Header("bearer_token:Authorization")
142+
dsl.Response(dsl.StatusOK, func() {
143+
dsl.Header("cache_control:Cache-Control")
144+
})
145+
dsl.Response("BadRequest", dsl.StatusBadRequest)
146+
})
147+
})
148+
89149
dsl.Method("query-orgs", func() {
90150
dsl.Description("Locate a single organization by name or domain.")
91151

gen/http/cli/lfx_v2_query_service/cli.go

Lines changed: 35 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)