[CXH-2285] - Return GrantAlreadyExists on folder/document no-op re-grants - #61
[CXH-2285] - Return GrantAlreadyExists on folder/document no-op re-grants#61sergiocorral-conductorone wants to merge 14 commits into
Conversation
…H-2285)
Folder and document provisioning were asymmetric on idempotency: Revoke
returns GrantAlreadyRevoked on a not-found delete, but Grant returned no
annotation when the user already held the requested collaborator role. The
upstream PUT is an idempotent upsert (200/201, never 409), so a re-grant of
a role the user already holds succeeded silently and was indistinguishable
from a grant that changed state — C1's audit log recorded both identically.
Detect the true no-op precisely by reading the user's current role via the
single-collaborator GET before upserting: if it equals the requested role,
return annotations.New(&v2.GrantAlreadyExists{}) and skip the PUT. A role
change (e.g. view -> edit) still upserts and returns the grant, since Lucid
roles are single-slot and the replacement is a real state change.
- add GetFolderUserCollaborator / GetDocumentUserCollaborator client methods
(GET single collaborator; 404 -> codes.NotFound => not a collaborator)
- wire IsAlreadyExistsError defensively on the upsert error path (previously
defined but unused; Lucid does not 409 here today)
- document the single-slot role-replacement semantics inline
- add httptest-driven Grant idempotency tests for folder and document
Does not touch the grant.NewGrant argument order (tracked in CXH-1919).
Fixes CXH-2285
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connector PR Review: [CXH-2285] - Return GrantAlreadyExists on folder/document no-op re-grantsBlocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryThe new commit attaches Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agents |
…nsts (CXH-2285) Address PR #61 review feedback: - folder.go/document.go Grant: the pre-check GET (GetFolderUserCollaborator / GetDocumentUserCollaborator) previously hard-aborted the grant on any read error other than 404. A 403 (observed on this collaborator surface) or 405 would fail a grant the PUT upsert alone would complete. Now log at Debug on ANY GET error and fall through to the authoritative upsert; only the upsert's own failure is fatal. - query.go: collapse the per-verb Get/Upsert/Delete path constants into one FolderUserCollaboratorPath / DocumentUserCollaboratorPath each (the HTTP method conveys the verb); update mutate.go/query.go call sites. - grant_idempotency_test.go: read putCalls via atomic.LoadInt64; add coverage for the GET-failure fall-through (403/500) and the upsert 409 already-exists branch, for both folder and document paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…XH-2285) Address the automated re-review on PR #61: - folder.go/document.go: split the best-effort pre-check GET log by error class — 404 (expected "not yet a collaborator") stays at Debug, any other failure (403/405/500, cancelled context) logs at Warn so a permanently degraded pre-check still emits an operational signal. Still falls through to the authoritative upsert in both cases. - grant_idempotency_test.go: interpolate the injected status into the fall-through subtest names so 403 vs 500 are distinguishable in output; correct the fault-injection field comment (handler reads atomically; tests write once during setup before any request, so no concurrent access). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…H-2285) Replace the bound-log-level-in-a-variable pattern in the folder/document collaborator pre-check with a plain if/else calling l.Debug/l.Warn directly, matching the rest of the package. Split grant_idempotency_test.go into folder_test.go and document_test.go per the resource-per-file convention, with shared mock server and helpers in collaborator_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2285) Connector code must never log at Warn (C1 surfaces warn-level logs prominently, so recurring conditions generate production noise). The collaborator pre-check GET failure can recur on every grant for a tenant where the pre-check is permanently unavailable, so it belongs at Debug. Collapse the if/else into a single unconditional Debug call since it already falls through to the authoritative upsert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tics (CXH-2285) Per the implement-entitlement-provisioning skill (Step 2), error classification belongs in pkg/client/helpers.go and connector builders must call the shared helpers rather than classify inline. Grant already used client.IsAlreadyExistsError; both Revoke() implementations were still doing inline status.Code(err) == codes.NotFound. Swap them to client.IsNotFoundError(err) (behavior-identical: uhttp maps 404 -> codes.NotFound). Drop the now-unused status/codes imports from folder.go; document.go keeps them for the Grants() PermissionDenied sync check. Also document the "remove membership" Revoke semantics in a code comment on both Revoke() methods, per the skill's Step 4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…CXH-2285) Grant() in folder.go/document.go no longer classifies the pre-check GET error via IsNotFoundError (since fb2d1ac); both log any read failure at Debug and fall through to the upsert unconditionally. Reword the doc comments on GetFolderUserCollaborator/GetDocumentUserCollaborator to describe that actual best-effort contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t Revoke (CXH-2285) Address PR #61 review: - Trim over-explained pre-check/revoke doc comments to short form. - Remove the defensive upsert "409 -> already exists" branch on folder and document Grant; Lucid never returns 409 on this path and nothing documents that it will, so the error path is now just return err. - Add DELETE handler + sync.Mutex to the collaborator test mock; cover Revoke (happy-path delete and 404 already-revoked) for folder and document; repurpose the old 409 grant tests to assert upsert failures propagate as errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mock's GET/PUT/DELETE handlers keyed only on uid and ignored the
{id} object-ID path segment, so a Grant/Revoke reading the wrong object
(e.g. ParentResourceId instead of Id) would still match the route and
pass. Record the object ID each handler receives and assert the grant
GET/PUT and revoke DELETE hit the expected folder/document.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…XH-2285)
Lucid documents GET /folders/{id}/shares/users/{userId} as direct-only
(ancestor/inherited access is not surfaced), so the Grant no-op
short-circuit that skips the PUT on a matching role cannot be tripped by
an inherited role. Cite the doc so the assumption is auditable.
The document endpoint (GetDocumentUserCollaborator) is intentionally left
unchanged: Lucid does not document it as direct-only, so that assumption
is not yet auditable and is being escalated rather than papered over.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Empirically verified against the live Lucid API that the document single-collaborator GET does NOT surface inherited parent-folder roles (sharing only a parent folder leaves the endpoint at 404 and the collaborator list unchanged), resolving the review concern on document.go. This makes the Grant no-op short-circuit safe and backs the previously unsupported "not a direct collaborator" claim in the doc comment. Lucid's docs don't state this for the document endpoint (unlike the folder equivalent), so the comment cites the direct API test rather than a doc page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cited readme.io URL (getfolderusercollaborators) uses Lucid's plural
slug convention but is the reference page for the single-collaborator GET
(page title 'Get Folder User Collaborator', GET /folders/{id}/shares/users/{userId}),
not the list endpoint. Clarify this in the comment so the direct-only quote
reads as a direct citation of this exact endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The already-exists path returned only the GrantAlreadyExists annotation with a nil grant list. The SDK passes the response's Grants and Annotations fields through independently (connectorbuilder/resource_provisioner.go), and the annotation carries no grant data, so C1 had no membership to materialize until the next full sync reconciled it. Return the grant alongside the annotation, built from entitlement.Resource (the folder/document) and resource.Id (the user principal) so its ID matches what Grants() emits and C1 dedupes it against the synced grant. Update the folder/document tests to assert the grant is returned and targets the entitlement object, not the principal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // Return the grant alongside GrantAlreadyExists so C1 materializes the | ||
| // membership now instead of waiting for the next sync; the annotation | ||
| // alone carries no grant data. The ID matches what Grants() emits. | ||
| newGrant := grant.NewGrant(entitlement.Resource, entitlement.Slug, resource.Id) |
There was a problem hiding this comment.
🟡 Suggestion: this line is correct (grant.NewGrant(resource, entitlementName, principal) → entitlement ID folder:9001:user/edit, matching Grants() at line 121), but the sibling success path at line 175 is not: it passes resource — which in Grant is the user principal, not the folder — as the first arg, producing entitlement ID user:100:user/edit and grant ID user:100:user/edit:user:100. So a first-time grant and a no-op re-grant now hand C1 two different grant identities for the same membership, and only the re-grant one matches what the next sync emits. This is pre-existing (unchanged lines), but the comment above claims parity with Grants(), and the new test only asserts require.Len(t, grants, 1) for the success path — adding the same Entitlement.Resource.Id.Resource assertion there would catch it.
Separately (minor): the no-op grant omits the metaRole/metaCreated grant metadata that both Grants() and the upsert path attach. current.Role and current.Created are already in hand, so grant.WithGrantMetadata(...) would make the emitted grant fully identical to the synced one.
| // Return the grant alongside GrantAlreadyExists so C1 materializes the | ||
| // membership now instead of waiting for the next sync; the annotation | ||
| // alone carries no grant data. The ID matches what Grants() emits. | ||
| newGrant := grant.NewGrant(entitlement.Resource, entitlement.Slug, resource.Id) |
There was a problem hiding this comment.
🟡 Suggestion: same as the folder path — this line correctly builds entitlement ID document:doc-abc:user/comment (matching Grants() at line 136), but the success path at line 190 passes resource, which here is the user principal, as NewGrant's resource arg, yielding user:200:user/comment:user:200. First-time grant and no-op re-grant therefore report different grant identities for the same membership. Pre-existing and on unchanged lines, but worth fixing alongside this change; the new success-path test asserts only require.Len(t, grants, 1), so mirroring the Entitlement.Resource.Id.Resource assertion from the re-grant case would pin it.
Minor: the no-op grant also drops the metaRole/metaCreated metadata that Grants() and the upsert path attach — current.Role/current.Created are available if you want byte-identical grants.
There was a problem hiding this comment.
Connector PR Review: [CXH-2285] - Return GrantAlreadyExists on folder/document no-op re-grants
Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 3420066d2dab.
Review mode: incremental since a8ae112c
View review run
No blocking issues found.
Review Summary
The new commit addresses @mateoHernandez123's feedback: the folder and document no-op re-grant paths now return the grant built by grant.NewGrant(entitlement.Resource, entitlement.Slug, resource.Id) alongside GrantAlreadyExists, and both tests were updated to assert the returned grant targets the folder/document rather than the principal. Verified against the vendored SDK (vendor/.../pkg/types/grant/grant.go:88-113) that this produces grant ID folder:9001:user/edit:user:100, which does match what Grants() emits. The full PR diff was also scanned for security and correctness; no security issues and no blocking correctness issues were found. The two non-blocking suggestions below concern the sibling success path in the same functions, which this fix leaves inconsistent.
Security Issues
None found.
Correctness Issues
None found.
Suggestions
pkg/connector/folder.go:175/pkg/connector/document.go:190— the upsert success path passesresource(the user principal) asNewGrant's resource arg, so a first-time grant emits entitlement IDuser:100:user/editwhile the new no-op path emitsfolder:9001:user/edit; only the latter matchesGrants(). Pre-existing and on unchanged lines, but the new tests assert grant identity only for the re-grant branch, so the divergence is untested.pkg/connector/folder.go:156/pkg/connector/document.go:171— the no-op grant omits themetaRole/metaCreatedgrant metadata that bothGrants()and the upsert path attach;current.Roleandcurrent.Createdare already available.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.
## Suggestions
In `pkg/connector/folder.go`:
- Around line 175: grant.NewGrant(resource, folderHasUserAccessEntitlement+response.Role, userID, ...) passes `resource` as the first argument, but in Grant that parameter is the user principal, not the folder. NewGrant's first argument is the resource the entitlement lives on, so this builds entitlement ID `user:<uid>:user/<role>` and grant ID `user:<uid>:user/<role>:user:<uid>`, which does not match what Grants() (line 121) or the new no-op path (line 156) emit. Change the first argument to `entitlement.Resource` so the success path produces `folder:<folderId>:user/<role>:user:<uid>`. Then extend the "new grant returns no already-exists annotation and calls upsert" and "role change is not treated as already-exists" subtests in pkg/connector/folder_test.go to assert grants[0].Entitlement.Resource.Id.Resource equals the folder ID and grants[0].Principal.Id.Resource equals the user ID, mirroring the assertions already added to the re-grant subtest.
- Around line 156: the no-op re-grant builds its grant without grant.WithGrantMetadata(...), so it lacks the metaRole/metaCreated entries that both Grants() (line 121) and the upsert path (line 175) attach. The `current` value returned by GetFolderUserCollaborator already carries Role and Created, so pass grant.WithGrantMetadata with metaRole set to current.Role and metaCreated set to current.Created.String(), making the emitted grant identical to the one the next sync produces.
In `pkg/connector/document.go`:
- Around line 190: same issue as the folder path — grant.NewGrant(resource, documentHasUserAccessEntitlement+response.Role, userID, ...) passes the user principal where the document resource belongs, yielding entitlement ID `user:<uid>:user/<role>` instead of `document:<documentId>:user/<role>`. Change the first argument to `entitlement.Resource`, and extend the corresponding subtests in pkg/connector/document_test.go to assert the returned grant's entitlement resource is the document ID and its principal is the user ID.
- Around line 171: the no-op re-grant omits the metaRole/metaCreated grant metadata attached by Grants() (line 136) and the upsert path (line 190). Add grant.WithGrantMetadata with metaRole set to current.Role and metaCreated set to current.Created.String(), using the `current` response from GetDocumentUserCollaborator.
Note: this summary was posted as a review body rather than an update to the existing summary comment — the review harness blocked every command containing the literal review-state JSON marker, so patching that comment would have destroyed its stored state (last_reviewed_sha). The existing summary comment was left intact so the next incremental run still resolves its state correctly.
…-2285) The no-op re-grant path returned grant.NewGrant(...) without grant.WithGrantMetadata, so the emitted grant lacked the metaRole/ metaCreated entries that both Grants() and the upsert-success path attach. That made the immediately-materialized grant differ from what the next full sync would produce, causing a spurious metadata diff. Build the metadata map from the `current` collaborator returned by the pre-check GET (which already carries Role and Created), mirroring the upsert-success path, so the no-op grant is metadata-identical to a normal sync grant. Add test assertions in folder_test.go/document_test.go confirming the no-op grant's metadata matches the upsert-success path's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the two suggestions from the latest review (5044138632): Fixed — no-op re-grant now carries grant metadata ( metadata := map[string]interface{}{
metaRole: current.Role,
metaCreated: current.Created.String(),
}
newGrant := grant.NewGrant(entitlement.Resource, entitlement.Slug, resource.Id, grant.WithGrantMetadata(metadata))So the immediately-materialized no-op grant is now metadata-identical to what the next full sync produces. Added assertions in Acknowledged, out of scope — CXH-1919 arg-swap on the upsert-success path |
| upGrants, _, err := upB.Grant(ctx, userPrincipal("100"), objectEntitlement(folderResourceType.Id, "9001", "edit")) | ||
| require.NoError(t, err) | ||
| require.Len(t, upGrants, 1) | ||
| require.Equal(t, grantMetadata(t, upGrants[0]), noopMeta) |
There was a problem hiding this comment.
🟡 Suggestion: the comment above says this "proves the no-op metadata is identical to what the upsert-success path emits", but the assertion only compares the GrantMetadata annotation — the two paths still produce different grant IDs. The no-op builds NewGrant(entitlement.Resource, ...) → folder:9001:user/edit:user:100, while the success path at folder.go:179 passes resource (the user principal) → user:100:user/edit:user:100. Adding require.Equal(t, upGrants[0].Id, grants[0].Id) here (and at document_test.go:60) would lock in the parity this PR is about and catch the sibling-path mismatch already flagged at folder.go:156 / document.go:171. Confidence: high.
Fixes CXH-2285
Problem
Folder and document provisioning were asymmetric on idempotency.
RevokereturnsGrantAlreadyRevokedon a not-found delete, butGrantreturned no annotation when the user already held the requested collaborator role.The upstream call is an idempotent PUT upsert (
pkg/connector/client/mutate.go) that returns 200 (update) / 201 (create), never 409 Conflict. So re-granting a role the user already holds succeeded silently and was indistinguishable from a grant that changed upstream state — C1's audit log recorded both outcomes identically. (IsAlreadyExistsErrorwas defined but never called.)Fix
Because the status code alone can't distinguish a same-role re-grant from a role change, we read the user's current role before upserting, via the single-collaborator GET (
GET /{folders|documents}/{id}/shares/users/{userId}, which 404s when the user is not a direct collaborator):annotations.New(&v2.GrantAlreadyExists{})and skip the PUT (true no-op, symmetric withRevoke).view → editchange is a real state change, so it is not reported as already-exists.Also wires
IsAlreadyExistsErrordefensively on the upsert error path (Lucid doesn't 409 here today, but if it ever does it's treated as an idempotent success), and documents the single-slot role-replacement semantics inline (the ticket noted this was undocumented).Scope / relation to CXH-1919
This PR intentionally does not change the
grant.NewGrantargument order (the principal-vs-resource swap), which is tracked separately in CXH-1919. Both tickets touch the same twoGrant()methods; the already-exists path here returnsnilgrants + annotation, so it does not build agrant.NewGrantand does not pre-empt or conflict with CXH-1919's fix. See the PR discussion / task report for the overlap note.Changes
pkg/connector/client/query.go— addGetFolderUserCollaborator/GetDocumentUserCollaborator+ path constants.pkg/connector/folder.go,pkg/connector/document.go— pre-check current role inGrant(), returnGrantAlreadyExistson a no-op; defensiveIsAlreadyExistsError; inline docs.pkg/connector/grant_idempotency_test.go—httptest-driven tests covering the ticket's three reproduction cases (new grant, same-role re-grant, role change) for both folder and document, asserting the annotation and that the upsert is skipped on a no-op.Validation
go build ./...,go vet ./...,gofmt -lclean.golangci-lint run ./...→ 0 issues.go test ./...passes, including the 6 new idempotency subtests.cmd/test-server(the CI harness) completes with exit 0 — no regression. (The mock serves empty folder/document contents, so it does not exercise collaborator grants; the new unit tests cover that path end-to-end through the real HTTP client.)🤖 Generated with Claude Code