|
1
|
|
|
package coverage |
|
2
|
|
|
|
|
3
|
|
|
import ( |
|
4
|
|
|
"fmt" |
|
5
|
|
|
"slices" |
|
6
|
|
|
|
|
7
|
|
|
"github.com/Permify/permify/pkg/attribute" |
|
8
|
|
|
"github.com/Permify/permify/pkg/development/file" |
|
9
|
|
|
"github.com/Permify/permify/pkg/dsl/compiler" |
|
10
|
|
|
"github.com/Permify/permify/pkg/dsl/parser" |
|
11
|
|
|
base "github.com/Permify/permify/pkg/pb/base/v1" |
|
12
|
|
|
"github.com/Permify/permify/pkg/tuple" |
|
13
|
|
|
) |
|
14
|
|
|
|
|
15
|
|
|
// SchemaCoverageInfo represents the overall coverage information for a schema |
|
16
|
|
|
type SchemaCoverageInfo struct { |
|
17
|
|
|
EntityCoverageInfo []EntityCoverageInfo |
|
18
|
|
|
TotalRelationshipsCoverage int |
|
19
|
|
|
TotalAttributesCoverage int |
|
20
|
|
|
TotalAssertionsCoverage int |
|
21
|
|
|
} |
|
22
|
|
|
|
|
23
|
|
|
// EntityCoverageInfo represents coverage information for a single entity |
|
24
|
|
|
type EntityCoverageInfo struct { |
|
25
|
|
|
EntityName string |
|
26
|
|
|
|
|
27
|
|
|
UncoveredRelationships []string |
|
28
|
|
|
CoverageRelationshipsPercent int |
|
29
|
|
|
|
|
30
|
|
|
UncoveredAttributes []string |
|
31
|
|
|
CoverageAttributesPercent int |
|
32
|
|
|
|
|
33
|
|
|
UncoveredAssertions map[string][]string |
|
34
|
|
|
CoverageAssertionsPercent map[string]int |
|
35
|
|
|
} |
|
36
|
|
|
|
|
37
|
|
|
// SchemaCoverage represents the expected coverage for a schema entity |
|
38
|
|
|
// |
|
39
|
|
|
// Example schema: |
|
40
|
|
|
// |
|
41
|
|
|
// entity user {} |
|
42
|
|
|
// |
|
43
|
|
|
// entity organization { |
|
44
|
|
|
// relation admin @user |
|
45
|
|
|
// relation member @user |
|
46
|
|
|
// } |
|
47
|
|
|
// |
|
48
|
|
|
// entity repository { |
|
49
|
|
|
// relation parent @organization |
|
50
|
|
|
// relation owner @user @organization#admin |
|
51
|
|
|
// permission edit = parent.admin or owner |
|
52
|
|
|
// permission delete = owner |
|
53
|
|
|
// } |
|
54
|
|
|
// |
|
55
|
|
|
// Expected relationships coverage: |
|
56
|
|
|
// - organization#admin@user |
|
57
|
|
|
// - organization#member@user |
|
58
|
|
|
// - repository#parent@organization |
|
59
|
|
|
// - repository#owner@user |
|
60
|
|
|
// - repository#owner@organization#admin |
|
61
|
|
|
// |
|
62
|
|
|
// Expected assertions coverage: |
|
63
|
|
|
// - repository#edit |
|
64
|
|
|
// - repository#delete |
|
65
|
|
|
type SchemaCoverage struct { |
|
66
|
|
|
EntityName string |
|
67
|
|
|
Relationships []string |
|
68
|
|
|
Attributes []string |
|
69
|
|
|
Assertions []string |
|
70
|
|
|
} |
|
71
|
|
|
|
|
72
|
|
|
// Run analyzes the coverage of relationships, attributes, and assertions |
|
73
|
|
|
// for a given schema shape and returns the coverage information |
|
74
|
|
|
func Run(shape file.Shape) SchemaCoverageInfo { |
|
75
|
|
|
definitions, err := parseAndCompileSchema(shape.Schema) |
|
76
|
|
|
if err != nil { |
|
77
|
|
|
return SchemaCoverageInfo{} |
|
78
|
|
|
} |
|
79
|
|
|
|
|
80
|
|
|
refs := extractSchemaReferences(definitions) |
|
81
|
|
|
entityCoverageInfos := calculateEntityCoverages(refs, shape) |
|
82
|
|
|
|
|
83
|
|
|
return buildSchemaCoverageInfo(entityCoverageInfos) |
|
84
|
|
|
} |
|
85
|
|
|
|
|
86
|
|
|
// parseAndCompileSchema parses and compiles the schema into entity definitions |
|
87
|
|
|
func parseAndCompileSchema(schema string) ([]*base.EntityDefinition, error) { |
|
88
|
|
|
p, err := parser.NewParser(schema).Parse() |
|
89
|
|
|
if err != nil { |
|
90
|
|
|
return nil, err |
|
91
|
|
|
} |
|
92
|
|
|
|
|
93
|
|
|
definitions, _, err := compiler.NewCompiler(true, p).Compile() |
|
94
|
|
|
if err != nil { |
|
95
|
|
|
return nil, err |
|
96
|
|
|
} |
|
97
|
|
|
|
|
98
|
|
|
return definitions, nil |
|
99
|
|
|
} |
|
100
|
|
|
|
|
101
|
|
|
// extractSchemaReferences extracts all coverage references from entity definitions |
|
102
|
|
|
func extractSchemaReferences(definitions []*base.EntityDefinition) []SchemaCoverage { |
|
103
|
|
|
refs := make([]SchemaCoverage, len(definitions)) |
|
104
|
|
|
for idx, entityDef := range definitions { |
|
105
|
|
|
refs[idx] = extractEntityReferences(entityDef) |
|
106
|
|
|
} |
|
107
|
|
|
return refs |
|
108
|
|
|
} |
|
109
|
|
|
|
|
110
|
|
|
// extractEntityReferences extracts relationships, attributes, and assertions from an entity definition |
|
111
|
|
|
func extractEntityReferences(entity *base.EntityDefinition) SchemaCoverage { |
|
112
|
|
|
coverage := SchemaCoverage{ |
|
113
|
|
|
EntityName: entity.GetName(), |
|
114
|
|
|
Relationships: extractRelationships(entity), |
|
115
|
|
|
Attributes: extractAttributes(entity), |
|
116
|
|
|
Assertions: extractAssertions(entity), |
|
117
|
|
|
} |
|
118
|
|
|
return coverage |
|
119
|
|
|
} |
|
120
|
|
|
|
|
121
|
|
|
// extractRelationships extracts all relationship references from an entity |
|
122
|
|
|
func extractRelationships(entity *base.EntityDefinition) []string { |
|
123
|
|
|
relationships := []string{} |
|
124
|
|
|
|
|
125
|
|
|
for _, relation := range entity.GetRelations() { |
|
126
|
|
|
for _, reference := range relation.GetRelationReferences() { |
|
127
|
|
|
formatted := formatRelationship( |
|
128
|
|
|
entity.GetName(), |
|
129
|
|
|
relation.GetName(), |
|
130
|
|
|
reference.GetType(), |
|
131
|
|
|
reference.GetRelation(), |
|
132
|
|
|
) |
|
133
|
|
|
relationships = append(relationships, formatted) |
|
134
|
|
|
} |
|
135
|
|
|
} |
|
136
|
|
|
|
|
137
|
|
|
return relationships |
|
138
|
|
|
} |
|
139
|
|
|
|
|
140
|
|
|
// extractAttributes extracts all attribute references from an entity |
|
141
|
|
|
func extractAttributes(entity *base.EntityDefinition) []string { |
|
142
|
|
|
attributes := []string{} |
|
143
|
|
|
|
|
144
|
|
|
for _, attr := range entity.GetAttributes() { |
|
145
|
|
|
formatted := formatAttribute(entity.GetName(), attr.GetName()) |
|
146
|
|
|
attributes = append(attributes, formatted) |
|
147
|
|
|
} |
|
148
|
|
|
|
|
149
|
|
|
return attributes |
|
150
|
|
|
} |
|
151
|
|
|
|
|
152
|
|
|
// extractAssertions extracts all permission/assertion references from an entity |
|
153
|
|
|
func extractAssertions(entity *base.EntityDefinition) []string { |
|
154
|
|
|
assertions := []string{} |
|
155
|
|
|
|
|
156
|
|
|
for _, permission := range entity.GetPermissions() { |
|
157
|
|
|
formatted := formatAssertion(entity.GetName(), permission.GetName()) |
|
158
|
|
|
assertions = append(assertions, formatted) |
|
159
|
|
|
} |
|
160
|
|
|
|
|
161
|
|
|
return assertions |
|
162
|
|
|
} |
|
163
|
|
|
|
|
164
|
|
|
// calculateEntityCoverages calculates coverage for all entities |
|
165
|
|
|
func calculateEntityCoverages(refs []SchemaCoverage, shape file.Shape) []EntityCoverageInfo { |
|
166
|
|
|
entityCoverageInfos := []EntityCoverageInfo{} |
|
167
|
|
|
|
|
168
|
|
|
for _, ref := range refs { |
|
169
|
|
|
entityCoverageInfo := calculateEntityCoverage(ref, shape) |
|
170
|
|
|
entityCoverageInfos = append(entityCoverageInfos, entityCoverageInfo) |
|
171
|
|
|
} |
|
172
|
|
|
|
|
173
|
|
|
return entityCoverageInfos |
|
174
|
|
|
} |
|
175
|
|
|
|
|
176
|
|
|
// calculateEntityCoverage calculates coverage for a single entity |
|
177
|
|
|
func calculateEntityCoverage(ref SchemaCoverage, shape file.Shape) EntityCoverageInfo { |
|
178
|
|
|
entityCoverageInfo := newEntityCoverageInfo(ref.EntityName) |
|
179
|
|
|
|
|
180
|
|
|
// Calculate relationships coverage |
|
181
|
|
|
entityCoverageInfo.UncoveredRelationships = findUncoveredRelationships( |
|
182
|
|
|
ref.EntityName, |
|
183
|
|
|
ref.Relationships, |
|
184
|
|
|
shape.Relationships, |
|
185
|
|
|
) |
|
186
|
|
|
entityCoverageInfo.CoverageRelationshipsPercent = calculateCoveragePercent( |
|
187
|
|
|
ref.Relationships, |
|
188
|
|
|
entityCoverageInfo.UncoveredRelationships, |
|
189
|
|
|
) |
|
190
|
|
|
|
|
191
|
|
|
// Calculate attributes coverage |
|
192
|
|
|
entityCoverageInfo.UncoveredAttributes = findUncoveredAttributes( |
|
193
|
|
|
ref.EntityName, |
|
194
|
|
|
ref.Attributes, |
|
195
|
|
|
shape.Attributes, |
|
196
|
|
|
) |
|
197
|
|
|
entityCoverageInfo.CoverageAttributesPercent = calculateCoveragePercent( |
|
198
|
|
|
ref.Attributes, |
|
199
|
|
|
entityCoverageInfo.UncoveredAttributes, |
|
200
|
|
|
) |
|
201
|
|
|
|
|
202
|
|
|
// Calculate assertions coverage for each scenario |
|
203
|
|
|
for _, scenario := range shape.Scenarios { |
|
204
|
|
|
uncovered := findUncoveredAssertions( |
|
205
|
|
|
ref.EntityName, |
|
206
|
|
|
ref.Assertions, |
|
207
|
|
|
scenario.Checks, |
|
208
|
|
|
scenario.EntityFilters, |
|
209
|
|
|
) |
|
210
|
|
|
// Only add to UncoveredAssertions if there are uncovered assertions |
|
211
|
|
|
if len(uncovered) > 0 { |
|
212
|
|
|
entityCoverageInfo.UncoveredAssertions[scenario.Name] = uncovered |
|
213
|
|
|
} |
|
214
|
|
|
entityCoverageInfo.CoverageAssertionsPercent[scenario.Name] = calculateCoveragePercent( |
|
215
|
|
|
ref.Assertions, |
|
216
|
|
|
uncovered, |
|
217
|
|
|
) |
|
218
|
|
|
} |
|
219
|
|
|
|
|
220
|
|
|
return entityCoverageInfo |
|
221
|
|
|
} |
|
222
|
|
|
|
|
223
|
|
|
// newEntityCoverageInfo creates a new EntityCoverageInfo with initialized fields |
|
224
|
|
|
func newEntityCoverageInfo(entityName string) EntityCoverageInfo { |
|
225
|
|
|
return EntityCoverageInfo{ |
|
226
|
|
|
EntityName: entityName, |
|
227
|
|
|
UncoveredRelationships: []string{}, |
|
228
|
|
|
UncoveredAttributes: []string{}, |
|
229
|
|
|
CoverageAssertionsPercent: make(map[string]int), |
|
230
|
|
|
UncoveredAssertions: make(map[string][]string), |
|
231
|
|
|
CoverageRelationshipsPercent: 0, |
|
232
|
|
|
CoverageAttributesPercent: 0, |
|
233
|
|
|
} |
|
234
|
|
|
} |
|
235
|
|
|
|
|
236
|
|
|
// findUncoveredRelationships finds relationships that are not covered in the shape |
|
237
|
|
|
func findUncoveredRelationships(entityName string, expected, actual []string) []string { |
|
238
|
|
|
covered := extractCoveredRelationships(entityName, actual) |
|
239
|
|
|
uncovered := []string{} |
|
240
|
|
|
|
|
241
|
|
|
for _, relationship := range expected { |
|
242
|
|
|
if !slices.Contains(covered, relationship) { |
|
243
|
|
|
uncovered = append(uncovered, relationship) |
|
244
|
|
|
} |
|
245
|
|
|
} |
|
246
|
|
|
|
|
247
|
|
|
return uncovered |
|
248
|
|
|
} |
|
249
|
|
|
|
|
250
|
|
|
// findUncoveredAttributes finds attributes that are not covered in the shape |
|
251
|
|
|
func findUncoveredAttributes(entityName string, expected, actual []string) []string { |
|
252
|
|
|
covered := extractCoveredAttributes(entityName, actual) |
|
253
|
|
|
uncovered := []string{} |
|
254
|
|
|
|
|
255
|
|
|
for _, attr := range expected { |
|
256
|
|
|
if !slices.Contains(covered, attr) { |
|
257
|
|
|
uncovered = append(uncovered, attr) |
|
258
|
|
|
} |
|
259
|
|
|
} |
|
260
|
|
|
|
|
261
|
|
|
return uncovered |
|
262
|
|
|
} |
|
263
|
|
|
|
|
264
|
|
|
// findUncoveredAssertions finds assertions that are not covered in the shape |
|
265
|
|
|
func findUncoveredAssertions(entityName string, expected []string, checks []file.Check, filters []file.EntityFilter) []string { |
|
266
|
|
|
covered := extractCoveredAssertions(entityName, checks, filters) |
|
267
|
|
|
uncovered := []string{} |
|
268
|
|
|
|
|
269
|
|
|
for _, assertion := range expected { |
|
270
|
|
|
if !slices.Contains(covered, assertion) { |
|
271
|
|
|
uncovered = append(uncovered, assertion) |
|
272
|
|
|
} |
|
273
|
|
|
} |
|
274
|
|
|
|
|
275
|
|
|
return uncovered |
|
276
|
|
|
} |
|
277
|
|
|
|
|
278
|
|
|
// buildSchemaCoverageInfo builds the final SchemaCoverageInfo with total coverage |
|
279
|
|
|
func buildSchemaCoverageInfo(entityCoverageInfos []EntityCoverageInfo) SchemaCoverageInfo { |
|
280
|
|
|
relationshipsCoverage, attributesCoverage, assertionsCoverage := calculateTotalCoverage(entityCoverageInfos) |
|
281
|
|
|
|
|
282
|
|
|
return SchemaCoverageInfo{ |
|
283
|
|
|
EntityCoverageInfo: entityCoverageInfos, |
|
284
|
|
|
TotalRelationshipsCoverage: relationshipsCoverage, |
|
285
|
|
|
TotalAttributesCoverage: attributesCoverage, |
|
286
|
|
|
TotalAssertionsCoverage: assertionsCoverage, |
|
287
|
|
|
} |
|
288
|
|
|
} |
|
289
|
|
|
|
|
290
|
|
|
// calculateCoveragePercent calculates coverage percentage based on total and uncovered elements |
|
291
|
|
|
func calculateCoveragePercent(totalElements, uncoveredElements []string) int { |
|
292
|
|
|
totalCount := len(totalElements) |
|
293
|
|
|
if totalCount == 0 { |
|
294
|
|
|
return 100 |
|
295
|
|
|
} |
|
296
|
|
|
|
|
297
|
|
|
coveredCount := totalCount - len(uncoveredElements) |
|
298
|
|
|
return (coveredCount * 100) / totalCount |
|
299
|
|
|
} |
|
300
|
|
|
|
|
301
|
|
|
// calculateTotalCoverage calculates average coverage percentages across all entities |
|
302
|
|
|
func calculateTotalCoverage(entities []EntityCoverageInfo) (int, int, int) { |
|
303
|
|
|
var ( |
|
304
|
|
|
totalRelationships int |
|
305
|
|
|
totalCoveredRelationships int |
|
306
|
|
|
totalAttributes int |
|
307
|
|
|
totalCoveredAttributes int |
|
308
|
|
|
totalAssertions int |
|
309
|
|
|
totalCoveredAssertions int |
|
310
|
|
|
) |
|
311
|
|
|
|
|
312
|
|
|
for _, entity := range entities { |
|
313
|
|
|
totalRelationships++ |
|
314
|
|
|
totalCoveredRelationships += entity.CoverageRelationshipsPercent |
|
315
|
|
|
|
|
316
|
|
|
totalAttributes++ |
|
317
|
|
|
totalCoveredAttributes += entity.CoverageAttributesPercent |
|
318
|
|
|
|
|
319
|
|
|
for _, assertionPercent := range entity.CoverageAssertionsPercent { |
|
320
|
|
|
totalAssertions++ |
|
321
|
|
|
totalCoveredAssertions += assertionPercent |
|
322
|
|
|
} |
|
323
|
|
|
} |
|
324
|
|
|
|
|
325
|
|
|
return calculateAverageCoverage(totalRelationships, totalCoveredRelationships), |
|
326
|
|
|
calculateAverageCoverage(totalAttributes, totalCoveredAttributes), |
|
327
|
|
|
calculateAverageCoverage(totalAssertions, totalCoveredAssertions) |
|
328
|
|
|
} |
|
329
|
|
|
|
|
330
|
|
|
// calculateAverageCoverage calculates average coverage with zero-division guard |
|
331
|
|
|
func calculateAverageCoverage(total, covered int) int { |
|
332
|
|
|
if total == 0 { |
|
333
|
|
|
return 100 |
|
334
|
|
|
} |
|
335
|
|
|
return covered / total |
|
336
|
|
|
} |
|
337
|
|
|
|
|
338
|
|
|
// extractCoveredRelationships extracts covered relationships for a given entity from the shape |
|
339
|
|
|
func extractCoveredRelationships(entityName string, relationships []string) []string { |
|
340
|
|
|
covered := []string{} |
|
341
|
|
|
|
|
342
|
|
|
for _, relationship := range relationships { |
|
343
|
|
|
tup, err := tuple.Tuple(relationship) |
|
344
|
|
|
if err != nil { |
|
345
|
|
|
continue |
|
346
|
|
|
} |
|
347
|
|
|
|
|
348
|
|
|
if tup.GetEntity().GetType() != entityName { |
|
349
|
|
|
continue |
|
350
|
|
|
} |
|
351
|
|
|
|
|
352
|
|
|
formatted := formatRelationship( |
|
353
|
|
|
tup.GetEntity().GetType(), |
|
354
|
|
|
tup.GetRelation(), |
|
355
|
|
|
tup.GetSubject().GetType(), |
|
356
|
|
|
tup.GetSubject().GetRelation(), |
|
357
|
|
|
) |
|
358
|
|
|
covered = append(covered, formatted) |
|
359
|
|
|
} |
|
360
|
|
|
|
|
361
|
|
|
return covered |
|
362
|
|
|
} |
|
363
|
|
|
|
|
364
|
|
|
// extractCoveredAttributes extracts covered attributes for a given entity from the shape |
|
365
|
|
|
func extractCoveredAttributes(entityName string, attributes []string) []string { |
|
366
|
|
|
covered := []string{} |
|
367
|
|
|
|
|
368
|
|
|
for _, attrStr := range attributes { |
|
369
|
|
|
a, err := attribute.Attribute(attrStr) |
|
370
|
|
|
if err != nil { |
|
371
|
|
|
continue |
|
372
|
|
|
} |
|
373
|
|
|
|
|
374
|
|
|
if a.GetEntity().GetType() != entityName { |
|
375
|
|
|
continue |
|
376
|
|
|
} |
|
377
|
|
|
|
|
378
|
|
|
formatted := formatAttribute(a.GetEntity().GetType(), a.GetAttribute()) |
|
379
|
|
|
covered = append(covered, formatted) |
|
380
|
|
|
} |
|
381
|
|
|
|
|
382
|
|
|
return covered |
|
383
|
|
|
} |
|
384
|
|
|
|
|
385
|
|
|
// extractCoveredAssertions extracts covered assertions for a given entity from checks and filters |
|
386
|
|
|
func extractCoveredAssertions(entityName string, checks []file.Check, filters []file.EntityFilter) []string { |
|
387
|
|
|
covered := []string{} |
|
388
|
|
|
|
|
389
|
|
|
// Extract from checks |
|
390
|
|
|
for _, check := range checks { |
|
391
|
|
|
entity, err := tuple.E(check.Entity) |
|
392
|
|
|
if err != nil { |
|
393
|
|
|
continue |
|
394
|
|
|
} |
|
395
|
|
|
|
|
396
|
|
|
if entity.GetType() != entityName { |
|
397
|
|
|
continue |
|
398
|
|
|
} |
|
399
|
|
|
|
|
400
|
|
|
for permission := range check.Assertions { |
|
401
|
|
|
formatted := formatAssertion(entity.GetType(), permission) |
|
402
|
|
|
covered = append(covered, formatted) |
|
403
|
|
|
} |
|
404
|
|
|
} |
|
405
|
|
|
|
|
406
|
|
|
// Extract from entity filters |
|
407
|
|
|
for _, filter := range filters { |
|
408
|
|
|
if filter.EntityType != entityName { |
|
409
|
|
|
continue |
|
410
|
|
|
} |
|
411
|
|
|
|
|
412
|
|
|
for permission := range filter.Assertions { |
|
413
|
|
|
formatted := formatAssertion(filter.EntityType, permission) |
|
414
|
|
|
covered = append(covered, formatted) |
|
415
|
|
|
} |
|
416
|
|
|
} |
|
417
|
|
|
|
|
418
|
|
|
return covered |
|
419
|
|
|
} |
|
420
|
|
|
|
|
421
|
|
|
// formatRelationship formats a relationship string |
|
422
|
|
|
func formatRelationship(entityName, relationName, subjectType, subjectRelation string) string { |
|
423
|
|
|
if subjectRelation != "" { |
|
424
|
|
|
return fmt.Sprintf("%s#%s@%s#%s", entityName, relationName, subjectType, subjectRelation) |
|
425
|
|
|
} |
|
426
|
|
|
return fmt.Sprintf("%s#%s@%s", entityName, relationName, subjectType) |
|
427
|
|
|
} |
|
428
|
|
|
|
|
429
|
|
|
// formatAttribute formats an attribute string |
|
430
|
|
|
func formatAttribute(entityName, attributeName string) string { |
|
431
|
|
|
return fmt.Sprintf("%s#%s", entityName, attributeName) |
|
432
|
|
|
} |
|
433
|
|
|
|
|
434
|
|
|
// formatAssertion formats an assertion/permission string |
|
435
|
|
|
func formatAssertion(entityName, permissionName string) string { |
|
436
|
|
|
return fmt.Sprintf("%s#%s", entityName, permissionName) |
|
437
|
|
|
} |
|
438
|
|
|
|