Skip to content

Commit 0961b97

Browse files
committed
Add plugin to detect breaking changes in permissions
1 parent decb5f3 commit 0961b97

File tree

18 files changed

+695
-0
lines changed

18 files changed

+695
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Package main implements a plugin that checks for breaking changes in permissions.
2+
// The plugin detects when method permissions change, which could break existing
3+
// client code that depends on certain access levels.
4+
//
5+
// The plugin handles both AND and OR permission logic via the requires_all_permissions option:
6+
// - requires_all_permissions=true (default): ALL permissions must be met (AND logic)
7+
// - requires_all_permissions=false: ANY ONE permission must be met (OR logic)
8+
//
9+
// Breaking changes detected:
10+
// - Adding permissions to methods that previously had none (restricts access)
11+
// - Removing all permissions from methods (changes access model)
12+
// - Changing requires_all_permissions from false to true (OR to AND, more restrictive)
13+
// - For AND permissions (requires_all_permissions=true): ANY change to permissions
14+
// - For OR permissions (requires_all_permissions=false): REMOVING permissions
15+
//
16+
// Non-breaking changes (not reported):
17+
// - New methods with permissions (handled automatically by buf framework)
18+
// - Changing requires_all_permissions from true to false (AND to OR, more permissive)
19+
// - For OR permissions (requires_all_permissions=false): ADDING permissions
20+
//
21+
// To use this plugin:
22+
//
23+
// # buf.yaml
24+
// version: v2
25+
// breaking:
26+
// use:
27+
// - QDRANT_CLOUD_PERMISSIONS_BREAKING
28+
// plugins:
29+
// - plugin: buf-plugin-permissions-breaking
30+
package main
31+
32+
import (
33+
"context"
34+
"fmt"
35+
"sort"
36+
"strings"
37+
38+
"buf.build/go/bufplugin/check"
39+
"buf.build/go/bufplugin/check/checkutil"
40+
"buf.build/go/bufplugin/info"
41+
"google.golang.org/protobuf/proto"
42+
"google.golang.org/protobuf/reflect/protoreflect"
43+
44+
commonv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/common/v1"
45+
)
46+
47+
const (
48+
permissionsBreakingRuleID = "QDRANT_CLOUD_PERMISSIONS_BREAKING"
49+
)
50+
51+
// PermissionConfig holds the permission configuration for a method.
52+
type PermissionConfig struct {
53+
Permissions []string
54+
RequiresAll bool // true = AND (default), false = OR
55+
}
56+
57+
var (
58+
permissionsBreakingRuleSpec = &check.RuleSpec{
59+
ID: permissionsBreakingRuleID,
60+
Default: true,
61+
Purpose: `Checks for breaking changes in method permissions.`,
62+
Type: check.RuleTypeBreaking,
63+
Handler: checkutil.NewMethodPairRuleHandler(checkPermissionsBreaking, checkutil.WithoutImports()),
64+
}
65+
spec = &check.Spec{
66+
Rules: []*check.RuleSpec{
67+
permissionsBreakingRuleSpec,
68+
},
69+
Info: &info.Spec{
70+
Documentation: `A plugin that checks for breaking changes in method permissions.`,
71+
SPDXLicenseID: "",
72+
LicenseURL: "",
73+
},
74+
}
75+
permissionsOption = commonv1.E_Permissions
76+
requiresAllPermissionsOption = commonv1.E_RequiresAllPermissions
77+
)
78+
79+
func main() {
80+
check.Main(spec)
81+
}
82+
83+
func checkPermissionsBreaking(ctx context.Context, responseWriter check.ResponseWriter, request check.Request, methodDescriptor, againstMethodDescriptor protoreflect.MethodDescriptor) error {
84+
againstConfig := getMethodPermissionConfig(againstMethodDescriptor)
85+
currentConfig := getMethodPermissionConfig(methodDescriptor)
86+
87+
// Check for breaking changes based on permission logic
88+
if isBreakingChange(againstConfig, currentConfig) {
89+
var message string
90+
if len(currentConfig.Permissions) == 0 {
91+
message = fmt.Sprintf("Method %q had permissions %v but now has no permissions, this is a breaking change",
92+
methodDescriptor.FullName(), againstConfig.Permissions)
93+
} else if len(againstConfig.Permissions) == 0 {
94+
message = fmt.Sprintf("Method %q had no permissions but now requires permissions %v, this is a breaking change",
95+
methodDescriptor.FullName(), currentConfig.Permissions)
96+
} else {
97+
requiresAllChanged := againstConfig.RequiresAll != currentConfig.RequiresAll
98+
if requiresAllChanged {
99+
message = fmt.Sprintf("Method %q permissions logic changed from requires_all=%t to requires_all=%t with permissions %v to %v, this is a breaking change",
100+
methodDescriptor.FullName(), againstConfig.RequiresAll, currentConfig.RequiresAll, againstConfig.Permissions, currentConfig.Permissions)
101+
} else {
102+
message = fmt.Sprintf("Method %q permissions changed from %v to %v (requires_all=%t), this is a breaking change",
103+
methodDescriptor.FullName(), againstConfig.Permissions, currentConfig.Permissions, currentConfig.RequiresAll)
104+
}
105+
}
106+
responseWriter.AddAnnotation(
107+
check.WithMessage(message),
108+
check.WithDescriptor(methodDescriptor),
109+
)
110+
}
111+
112+
return nil
113+
}
114+
115+
// getMethodPermissionConfig extracts the permission configuration from a method descriptor.
116+
func getMethodPermissionConfig(methodDescriptor protoreflect.MethodDescriptor) PermissionConfig {
117+
options := methodDescriptor.Options()
118+
119+
// Extract permissions
120+
var permissions []string
121+
if proto.HasExtension(options, permissionsOption) {
122+
permissionsRaw := proto.GetExtension(options, permissionsOption)
123+
if permissionsSlice, ok := permissionsRaw.([]string); ok {
124+
// Filter out empty permissions and sort for consistent comparison
125+
for _, perm := range permissionsSlice {
126+
if strings.TrimSpace(perm) != "" {
127+
permissions = append(permissions, strings.TrimSpace(perm))
128+
}
129+
}
130+
sort.Strings(permissions)
131+
}
132+
}
133+
134+
// Extract requires_all_permissions (defaults to true)
135+
requiresAll := true // Default to AND behavior
136+
if proto.HasExtension(options, requiresAllPermissionsOption) {
137+
if val, ok := proto.GetExtension(options, requiresAllPermissionsOption).(bool); ok {
138+
requiresAll = val
139+
}
140+
}
141+
142+
return PermissionConfig{
143+
Permissions: permissions,
144+
RequiresAll: requiresAll,
145+
}
146+
}
147+
148+
// isBreakingChange determines if a permission configuration change is breaking.
149+
func isBreakingChange(against, current PermissionConfig) bool {
150+
// If both configs are identical, no breaking change
151+
if configsEqual(against, current) {
152+
return false
153+
}
154+
155+
// If requires_all_permissions logic changed:
156+
// - true -> false (AND to OR): non-breaking (more permissive)
157+
// - false -> true (OR to AND): breaking (more restrictive)
158+
if against.RequiresAll != current.RequiresAll {
159+
if against.RequiresAll && !current.RequiresAll {
160+
// Changed from AND to OR - non-breaking (more permissive)
161+
return false
162+
} else {
163+
// Changed from OR to AND - breaking (more restrictive)
164+
return true
165+
}
166+
}
167+
168+
// Handle the case where permissions are added to a method that had none
169+
if len(against.Permissions) == 0 && len(current.Permissions) > 0 {
170+
return true // Adding permissions to a previously unrestricted method is breaking
171+
}
172+
173+
// Handle the case where permissions are removed completely
174+
if len(against.Permissions) > 0 && len(current.Permissions) == 0 {
175+
return true // Removing all permissions changes the access model
176+
}
177+
178+
// For methods that had permissions before and still have permissions
179+
if len(against.Permissions) > 0 && len(current.Permissions) > 0 {
180+
if against.RequiresAll {
181+
// AND logic: ANY change is breaking (both adding and removing permissions)
182+
return !permissionsEqual(against.Permissions, current.Permissions)
183+
} else {
184+
// OR logic: Only removing permissions is breaking, adding is non-breaking
185+
return hasRemovedPermissions(against.Permissions, current.Permissions)
186+
}
187+
}
188+
189+
return false
190+
}
191+
192+
// configsEqual checks if two permission configurations are identical.
193+
func configsEqual(a, b PermissionConfig) bool {
194+
return a.RequiresAll == b.RequiresAll && permissionsEqual(a.Permissions, b.Permissions)
195+
}
196+
197+
// hasRemovedPermissions checks if any permissions were removed (for OR logic).
198+
func hasRemovedPermissions(previous, current []string) bool {
199+
currentSet := make(map[string]bool)
200+
for _, perm := range current {
201+
currentSet[perm] = true
202+
}
203+
204+
for _, perm := range previous {
205+
if !currentSet[perm] {
206+
return true // Found a permission that was removed
207+
}
208+
}
209+
return false
210+
}
211+
212+
func permissionsEqual(a, b []string) bool {
213+
if len(a) != len(b) {
214+
return false
215+
}
216+
217+
for i := range a {
218+
if a[i] != b[i] {
219+
return false
220+
}
221+
}
222+
return true
223+
}

0 commit comments

Comments
 (0)