fix(protectedbranch): preserve UserID/GroupID and fix reconcile thrash for identity-only access rules - #405
Conversation
… access levels GenerateProtectRepositoryBranchesOptions only ever read the first entry of PushAccessLevels/MergeAccessLevels/UnprotectAccessLevels and mapped its AccessLevel onto the deprecated singular push/merge/unprotect access level fields. UserID- and GroupID-scoped entries, and any entry beyond the first, were silently dropped from the API request. Because IsProtectedBranchUpToDate/isAccessLevelsUpToDate correctly compares the full desired array (including UserID/GroupID) against GitLab's observed state, a UserID-based rule could never converge: GitLab settles on whatever role-based rule it can infer, the provider sees that as a permanent diff against the desired UserID rule, and Update sends the same incomplete request again, causing continuous protected_branch delete/recreate thrash. Send the array-based allowed_to_push/allowed_to_merge/allowed_to_unprotect options instead, preserving AccessLevel, UserID, and GroupID for every entry. Fixes crossplane-contrib#267 Signed-off-by: 1872etd7q23wd <122471510+1872etd7q23wd@users.noreply.github.com>
…cessLevel
isAccessLevelsUpToDate only entered its match check when the spec entry
set AccessLevel. A userId-only or groupId-only rule (the common case,
e.g. pushAccessLevels: [{userId: X}] with no accessLevel) therefore
could never be found as up to date, even after the previous fix makes
the provider send/observe the userId correctly - IsProtectedBranchUpToDate
would keep returning false forever, and Update always unprotects and
re-protects the branch, reproducing the same delete/recreate thrash.
Verified live against a real GitLab (Premium) project: GitLab persists
a user-scoped push rule and reports it back with a real AccessLevel
value alongside the UserID (e.g. access_level=40, user_id=X), not
AccessLevel=0/absent. AccessLevel is now only compared when the spec
sets it, so identity-only rules match on UserID/GroupID alone.
Fixes crossplane-contrib#267
Signed-off-by: 1872etd7q23wd <122471510+1872etd7q23wd@users.noreply.github.com>
henrysachs
left a comment
There was a problem hiding this comment.
Please add regression coverage for duplicate/cardinality and nil desired entries, and regenerate the cluster mirror from the namespaced source to keep generated parity.
| } | ||
| // AccessLevel is only compared when the spec sets it; a userId/groupId-only | ||
| // rule must match on identity alone, or it can never be considered up to date. | ||
| accessMatch := specLevel.AccessLevel == nil || int64(*specLevel.AccessLevel) == int64(gitlabLevel.AccessLevel) |
There was a problem hiding this comment.
Each desired entry currently scans the full observed slice, so duplicate desired rules can reuse one observed rule; nil desired entries also panic here, unlike branchPermissionOptions. Filter nil desired entries before the cardinality check, then record each matched observed index.
| accessMatch := specLevel.AccessLevel == nil || int64(*specLevel.AccessLevel) == int64(gitlabLevel.AccessLevel) | |
| filteredSpecLevels := make([]*v1alpha1.BranchAccessDescription, 0, len(specLevels)) | |
| for _, specLevel := range specLevels { | |
| if specLevel != nil { | |
| filteredSpecLevels = append(filteredSpecLevels, specLevel) | |
| } | |
| } | |
| if len(filteredSpecLevels) != len(gitlabLevels) { | |
| return false | |
| } | |
| matched := make([]bool, len(gitlabLevels)) | |
| for _, specLevel := range filteredSpecLevels { | |
| found := false | |
| for i, gitlabLevel := range gitlabLevels { | |
| if matched[i] || gitlabLevel == nil { | |
| continue | |
| } | |
| accessMatch := specLevel.AccessLevel == nil || int64(*specLevel.AccessLevel) == int64(gitlabLevel.AccessLevel) | |
| userMatch := (specLevel.UserID == nil && gitlabLevel.UserID == 0) || (specLevel.UserID != nil && *specLevel.UserID == gitlabLevel.UserID) | |
| groupMatch := (specLevel.GroupID == nil && gitlabLevel.GroupID == 0) || (specLevel.GroupID != nil && *specLevel.GroupID == gitlabLevel.GroupID) | |
| if accessMatch && userMatch && groupMatch { | |
| matched[i] = true | |
| found = true | |
| break | |
| } | |
| } | |
| if !found { | |
| return false | |
| } | |
| } | |
| return true |
There was a problem hiding this comment.
Applied this and found one more issue while adding the requested tests: the matching is still greedy. Each desired entry takes the first compatible observed slot and keeps it which is fine for exact matches but an unconstrained entry (no AccessLevel/UserID/GroupID set) is compatible with any role-based observed entry. It can grab a slot that a more specific entry needed, making the result depend on entry order:
specA := &v1alpha1.BranchAccessDescription{} // no constraints
specB := &v1alpha1.BranchAccessDescription{AccessLevel: &level30}
gitlabLevels := []*gitlab.BranchAccessDescription{{AccessLevel: 30}, {AccessLevel: 40}}
isAccessLevelsUpToDate([]*v1alpha1.BranchAccessDescription{specA, specB}, gitlabLevels) // false
isAccessLevelsUpToDate([]*v1alpha1.BranchAccessDescription{specB, specA}, gitlabLevels) // true
Same state but different answer specA takes the level30 slot even though it could've taken 40 and leaves specB with no match. Since Update() always unprotects/re-protects on "not up to date," this reintroduces the same thrash bug behind a narrower trigger.
Replaced the greedy assignment with augmenting-path bipartite matching (tryAssign), added a test asserting both orderings agree, and re-ran make generate/make reviewable test clean.
@henrysachs take a look and let me know if you agree with this fix
|
also just a tip, your username seems a bit suspicious for supply chain attacks so maybe you want to make it a bit clearer who you are :) |
…esired rules isAccessLevelsUpToDate let every desired entry scan the full observed slice from the start, so duplicate desired rules could both match the same observed entry while a distinct observed entry went unmatched, hiding a real mismatch. A nil desired entry also reached the AccessLevel/UserID/GroupID field access unchecked, unlike branchPermissionOptions which already filters nil entries, and would panic. Filter nil desired entries before the cardinality check, and track which observed entries have already been claimed so each one can only satisfy one desired entry. Adds regression coverage for the duplicate/cardinality case and the nil desired entry case, and regenerates the cluster-scoped mirror from the namespaced source to keep generated parity. Signed-off-by: 1872etd7q23wd <122471510+1872etd7q23wd@users.noreply.github.com>
… false negatives The matched-slot tracking added to fix duplicate/cardinality handling still assigned each desired entry to the first compatible observed entry it found (greedy), then permanently removed that slot. Since an unconstrained desired entry (no AccessLevel/UserID/GroupID set) can be compatible with more than one observed entry, greedy assignment could grab a slot that a later, more specific desired entry needed, even though a valid pairing existed - making IsProtectedBranchUpToDate's result depend on the order entries appear in the spec rather than only on the sets being compared. Replace the greedy assignment with a standard augmenting-path bipartite matching (tryAssign), which finds a valid pairing whenever one exists regardless of input order. Split the per-pair compatibility check into accessLevelDescriptionMatches so each function stays simple enough that the previous //nolint:gocyclo suppression is no longer needed. Adds a regression test asserting the same desired/observed state produces the same answer under both orderings, and regenerates the cluster-scoped mirror from the namespaced source to keep generated parity. Signed-off-by: 1872etd7q23wd <122471510+1872etd7q23wd@users.noreply.github.com>
Doc comments elsewhere in this package are one or two lines; the comments added for the bipartite-matching change were multi-paragraph rationale blocks, well past what the rest of the codebase does for comparable functions. Condensed each to a single line and regenerated the cluster-scoped mirror to match. Signed-off-by: 1872etd7q23wd <122471510+1872etd7q23wd@users.noreply.github.com>
|
@1872etd7q23wd please merge the latest master into your branch so the trivy finding is fixed. Else the pipeline won't let us merge 😅 |
|
I just learned I can merge anyway. NICE! |
Description of your changes
This fixes two related bugs in
ProtectedBranchthat together caused the delete/recreate ("thrash") loop described in #267.1.
GenerateProtectRepositoryBranchesOptionsdroppedUserID/GroupID.It only ever read the first entry of
PushAccessLevels/MergeAccessLevels/UnprotectAccessLevelsand mapped itsAccessLevelonto the deprecated singular push/merge/unprotect access level fields.UserID- andGroupID-scoped entries, and any entry beyond the first, were silently dropped from the API request GitLab actually received. Fixed by sending the array-basedallowed_to_push/allowed_to_merge/allowed_to_unprotectoptions instead, preservingAccessLevel,UserID, andGroupIDfor every entry.2.
isAccessLevelsUpToDaterequiredAccessLevelto match even when the spec didn't set one.For the common case of a userId-only or groupId-only rule (e.g.
pushAccessLevels: [{userId: X}], noaccessLevel), the match check only ran whenspecLevel.AccessLevel != nil. Since that's never true for an identity-only rule,isAccessLevelsUpToDatereturnedfalseunconditionally — meaning even after fix #1 makes the provider send/observe the userId correctly,IsProtectedBranchUpToDatewould still report "not up to date" on every single reconcile.Update()has no real PATCH for protected branches — it always unprotects and re-protects the branch (seecontroller.go) — so this alone reproduces the exact delete/recreate thrash from the issue. Fixed by only comparingAccessLevelwhen the spec actually sets it.I verified fix #2 was necessary, not theoretical, by testing directly against a real GitLab (Premium-tier) project: sending
allowed_to_push: [{"user_id": X}]is accepted and persisted correctly, and GitLab reports it back as{"access_level": 40, "user_id": X, ...}— a real, non-zeroaccess_levelalongside theuser_id. Without fix #2, that response would never satisfy the old comparison for a spec with noaccessLevelset, regardless of fix #1.Fixes #267
I have:
make reviewable testto ensure this PR is ready for review.How has this code been tested
allowed_to_push: [{"user_id": X}]request (what fix Bootstrap provider-gitlab #1 now sends) is accepted by GitLab's API and round-trips correctly on read-back, for both a project owner/member and a service-account user.access_levelvalue alongside theuser_id(not zero/absent) — which is exactly what makes the oldisAccessLevelsUpToDate(pre-fix Implement projects api #2) permanently return "not up to date" for identity-only rules, since it requiredaccess_levelto match too.UserID-only push rule (request-building), a mixedAccessLevel+UserIDarray, aGroupID-only merge rule, the no-access-levels case, andIsProtectedBranchUpToDatescenarios for userId-only match/mismatch and the existing accessLevel-only behavior — all pass.pkg/namespaced/clients/projects,pkg/cluster/clients/projects) passes with no regressions.go build ./...,go vet, andgofmt -lare clean.make reviewable test(generate + lint + test) passes end-to-end across the whole repository:make generateproduces no diff (the generatedpkg/cluster/clients/projects/zz_protectedbranch.go, mirrored from the namespaced client viahack/generate-cluster-scope.go, stays in sync);make lint(golangci-lint v2.11.2) reports 0 issues;make testpasses repo-wide.