|
1 | 1 | package governance |
2 | 2 |
|
3 | | -import ( |
4 | | - "context" |
5 | | - "errors" |
6 | | - "fmt" |
7 | | - "sort" |
8 | | - "time" |
| 3 | +import "context" |
9 | 4 |
|
10 | | - "github.com/samber/lo" |
11 | | - |
12 | | - "github.com/openmeterio/openmeter/openmeter/customer" |
13 | | - "github.com/openmeterio/openmeter/openmeter/entitlement" |
14 | | - "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" |
15 | | - "github.com/openmeterio/openmeter/pkg/clock" |
16 | | - "github.com/openmeterio/openmeter/pkg/models" |
17 | | - pagination "github.com/openmeterio/openmeter/pkg/pagination/v2" |
18 | | -) |
19 | | - |
20 | | -// featureFetchLimit caps the org-wide feature fetch used when no feature filter is given. |
21 | | -// Acceptable for prototype scale; revisit if feature counts grow large. |
22 | | -const featureFetchLimit = 10_000 |
23 | | - |
24 | | -type service struct { |
25 | | - customerService customer.Service |
26 | | - entitlementService entitlement.Service |
27 | | - featureConnector feature.FeatureConnector |
28 | | -} |
29 | | - |
30 | | -var _ Service = (*service)(nil) |
31 | | - |
32 | | -// resolvedCustomer groups the matched input keys for a single customer. |
33 | | -type resolvedCustomer struct { |
34 | | - Customer customer.Customer |
35 | | - Matched []string |
36 | | -} |
37 | | - |
38 | | -func (s *service) QueryAccess(ctx context.Context, input QueryAccessInput) (QueryResult, error) { |
39 | | - if err := input.Validate(); err != nil { |
40 | | - return QueryResult{}, err |
41 | | - } |
42 | | - |
43 | | - // Resolve each input key to a customer; deduplicate by customer ID. |
44 | | - customerMap := make(map[string]*resolvedCustomer) |
45 | | - var queryErrors []QueryError |
46 | | - |
47 | | - for _, key := range input.CustomerKeys { |
48 | | - cus, err := s.customerService.GetCustomerByUsageAttribution(ctx, customer.GetCustomerByUsageAttributionInput{ |
49 | | - Namespace: input.Namespace, |
50 | | - Key: key, |
51 | | - }) |
52 | | - if err != nil { |
53 | | - if models.IsGenericNotFoundError(err) { |
54 | | - queryErrors = append(queryErrors, QueryError{ |
55 | | - CustomerKey: key, |
56 | | - Code: QueryErrorCustomerNotFound, |
57 | | - Message: "customer not found", |
58 | | - }) |
59 | | - continue |
60 | | - } |
61 | | - return QueryResult{}, fmt.Errorf("resolve customer key %q: %w", key, err) |
62 | | - } |
63 | | - |
64 | | - if rc, ok := customerMap[cus.ID]; ok { |
65 | | - rc.Matched = append(rc.Matched, key) |
66 | | - } else { |
67 | | - customerMap[cus.ID] = &resolvedCustomer{ |
68 | | - Customer: *cus, |
69 | | - Matched: []string{key}, |
70 | | - } |
71 | | - } |
72 | | - } |
73 | | - |
74 | | - // Sort by (CreatedAt, ID) for stable cursor pagination. |
75 | | - customers := lo.Values(customerMap) |
76 | | - sort.Slice(customers, func(i, j int) bool { |
77 | | - ti := customers[i].Customer.CreatedAt |
78 | | - tj := customers[j].Customer.CreatedAt |
79 | | - if !ti.Equal(tj) { |
80 | | - return ti.Before(tj) |
81 | | - } |
82 | | - return customers[i].Customer.ID < customers[j].Customer.ID |
83 | | - }) |
84 | | - |
85 | | - customers, hasPrev, hasNext := paginate(customers, input) |
86 | | - |
87 | | - var featureKeys []string |
88 | | - if len(input.FeatureKeys) > 0 { |
89 | | - featureKeys = input.FeatureKeys |
90 | | - } |
91 | | - |
92 | | - now := clock.Now() |
93 | | - results := make([]CustomerAccess, 0, len(customers)) |
94 | | - for _, rc := range customers { |
95 | | - access, err := s.entitlementService.GetAccess(ctx, input.Namespace, rc.Customer.ID) |
96 | | - if err != nil { |
97 | | - return QueryResult{}, fmt.Errorf("get access for customer %s: %w", rc.Customer.ID, err) |
98 | | - } |
99 | | - |
100 | | - featureAccess, err := s.buildFeatureAccess(ctx, input.Namespace, featureKeys, access) |
101 | | - if err != nil { |
102 | | - return QueryResult{}, fmt.Errorf("build feature access for customer %s: %w", rc.Customer.ID, err) |
103 | | - } |
104 | | - |
105 | | - results = append(results, CustomerAccess{ |
106 | | - Customer: rc.Customer, |
107 | | - Matched: rc.Matched, |
108 | | - Features: featureAccess, |
109 | | - UpdatedAt: now, |
110 | | - }) |
111 | | - } |
112 | | - |
113 | | - out := QueryResult{ |
114 | | - Customers: results, |
115 | | - Errors: queryErrors, |
116 | | - HasPrev: hasPrev, |
117 | | - HasNext: hasNext, |
118 | | - } |
119 | | - if len(customers) > 0 { |
120 | | - out.First = lo.ToPtr(cursorFor(customers[0])) |
121 | | - out.Last = lo.ToPtr(cursorFor(customers[len(customers)-1])) |
122 | | - } |
123 | | - |
124 | | - return out, nil |
125 | | -} |
126 | | - |
127 | | -// cursorFor builds the pagination cursor for a resolved customer. CreatedAt is truncated |
128 | | -// to second precision to match the RFC3339 encoding used by cursor strings. |
129 | | -func cursorFor(rc *resolvedCustomer) pagination.Cursor { |
130 | | - return pagination.NewCursor(rc.Customer.CreatedAt.Truncate(time.Second), rc.Customer.ID) |
131 | | -} |
132 | | - |
133 | | -// paginate applies cursor pagination over the sorted customers and reports whether adjacent |
134 | | -// pages exist. Exactly one of input.After / input.Before may be set (enforced by Validate). |
135 | | -func paginate(customers []*resolvedCustomer, input QueryAccessInput) (page []*resolvedCustomer, hasPrev, hasNext bool) { |
136 | | - if input.Before != nil { |
137 | | - // Backward: take the last pageSize items strictly before the cursor. |
138 | | - bc := *input.Before |
139 | | - end := 0 |
140 | | - for i, rc := range customers { |
141 | | - c := cursorFor(rc) |
142 | | - if c.Time.After(bc.Time) || (c.Time.Equal(bc.Time) && c.ID >= bc.ID) { |
143 | | - break |
144 | | - } |
145 | | - end = i + 1 |
146 | | - } |
147 | | - candidates := customers[:end] |
148 | | - hasPrev = len(candidates) > input.PageSize |
149 | | - if hasPrev { |
150 | | - candidates = candidates[len(candidates)-input.PageSize:] |
151 | | - } |
152 | | - // next is always set in backward mode: the before-cursor item itself is forward. |
153 | | - return candidates, hasPrev, true |
154 | | - } |
155 | | - |
156 | | - // Forward (after cursor or first page). |
157 | | - start := 0 |
158 | | - if input.After != nil { |
159 | | - ac := *input.After |
160 | | - start = len(customers) // beyond all items if cursor is past the end |
161 | | - for i, rc := range customers { |
162 | | - c := cursorFor(rc) |
163 | | - if c.Time.After(ac.Time) || (c.Time.Equal(ac.Time) && c.ID > ac.ID) { |
164 | | - start = i |
165 | | - break |
166 | | - } |
167 | | - } |
168 | | - } |
169 | | - hasPrev = start > 0 |
170 | | - page = customers[start:] |
171 | | - hasNext = len(page) > input.PageSize |
172 | | - if hasNext { |
173 | | - page = page[:input.PageSize] |
174 | | - } |
175 | | - return page, hasPrev, hasNext |
176 | | -} |
177 | | - |
178 | | -// buildFeatureAccess returns the feature access map for a single customer. |
179 | | -// If featureKeys is non-empty, only those keys are evaluated. |
180 | | -// If featureKeys is empty, all non-archived features in the namespace are returned; |
181 | | -// features the customer has no entitlement for are marked feature-unavailable. |
182 | | -func (s *service) buildFeatureAccess(ctx context.Context, ns string, featureKeys []string, access entitlement.Access) (map[string]FeatureAccess, error) { |
183 | | - result := make(map[string]FeatureAccess) |
184 | | - |
185 | | - if len(featureKeys) == 0 { |
186 | | - orgFeatures, err := s.listAllOrgFeatures(ctx, ns) |
187 | | - if err != nil { |
188 | | - return nil, err |
189 | | - } |
190 | | - for _, f := range orgFeatures { |
191 | | - if ev, ok := access.Entitlements[f.Key]; ok { |
192 | | - result[f.Key] = mapEntitlementToAccess(ev.Value) |
193 | | - } else { |
194 | | - result[f.Key] = featureUnavailable(f.Key) |
195 | | - } |
196 | | - } |
197 | | - return result, nil |
198 | | - } |
199 | | - |
200 | | - for _, key := range featureKeys { |
201 | | - ev, ok := access.Entitlements[key] |
202 | | - if !ok { |
203 | | - fa, err := s.resolveAbsentFeature(ctx, ns, key) |
204 | | - if err != nil { |
205 | | - return nil, err |
206 | | - } |
207 | | - result[key] = fa |
208 | | - continue |
209 | | - } |
210 | | - result[key] = mapEntitlementToAccess(ev.Value) |
211 | | - } |
212 | | - |
213 | | - return result, nil |
214 | | -} |
215 | | - |
216 | | -// listAllOrgFeatures fetches all non-archived features in the namespace in one shot. |
217 | | -func (s *service) listAllOrgFeatures(ctx context.Context, ns string) ([]feature.Feature, error) { |
218 | | - res, err := s.featureConnector.ListFeatures(ctx, feature.ListFeaturesParams{ |
219 | | - Namespace: ns, |
220 | | - IncludeArchived: false, |
221 | | - Limit: featureFetchLimit, |
222 | | - }) |
223 | | - if err != nil { |
224 | | - return nil, fmt.Errorf("list org features: %w", err) |
225 | | - } |
226 | | - return res.Items, nil |
227 | | -} |
228 | | - |
229 | | -// resolveAbsentFeature determines why a requested feature key is absent from GetAccess results: |
230 | | -// either the feature doesn't exist in the org (feature-not-found) or the customer has no |
231 | | -// entitlement for it (feature-unavailable). |
232 | | -func (s *service) resolveAbsentFeature(ctx context.Context, ns, featureKey string) (FeatureAccess, error) { |
233 | | - _, err := s.featureConnector.GetFeature(ctx, ns, featureKey, feature.IncludeArchivedFeatureFalse) |
234 | | - if err != nil { |
235 | | - var fne *feature.FeatureNotFoundError |
236 | | - if errors.As(err, &fne) || models.IsGenericNotFoundError(err) { |
237 | | - return FeatureAccess{ |
238 | | - HasAccess: false, |
239 | | - Reason: &AccessReason{ |
240 | | - Code: ReasonFeatureNotFound, |
241 | | - Message: fmt.Sprintf("feature %q not found", featureKey), |
242 | | - }, |
243 | | - }, nil |
244 | | - } |
245 | | - return FeatureAccess{}, fmt.Errorf("get feature %q: %w", featureKey, err) |
246 | | - } |
247 | | - |
248 | | - return featureUnavailable(featureKey), nil |
249 | | -} |
250 | | - |
251 | | -func featureUnavailable(featureKey string) FeatureAccess { |
252 | | - return FeatureAccess{ |
253 | | - HasAccess: false, |
254 | | - Reason: &AccessReason{ |
255 | | - Code: ReasonFeatureUnavailable, |
256 | | - Message: fmt.Sprintf("feature %q is not available for this customer", featureKey), |
257 | | - }, |
258 | | - } |
| 5 | +// Service evaluates feature access for customers by composing the customer, entitlement, |
| 6 | +// and feature services. It owns no persistence of its own. |
| 7 | +type Service interface { |
| 8 | + QueryAccess(ctx context.Context, input QueryAccessInput) (QueryResult, error) |
259 | 9 | } |
0 commit comments