Skip to content

Commit 27f45cc

Browse files
mstanbCOJavierCarnelli-ConductorOneclaude
authored
Adds fetching expired invitations so we can track when an invitaiton is invalid in C1 (#150)
* Adds fetching expired invitations so we can track when an invitaiton is no longer valid in c1 * Treat 404 as success when cancelling an expired invitation CancelInvite now returns success on 404 so that deleting an already-expired (or previously cancelled) invitation does not fail. Consistent with the idempotent grant/revoke pattern used elsewhere in the connector. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Javier David Carnelli <javier.carnelli@conductorone.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a408b6 commit 27f45cc

2 files changed

Lines changed: 405 additions & 33 deletions

File tree

pkg/connector/invitation.go

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,62 @@ import (
77
"net/http"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
1213
"github.com/conductorone/baton-sdk/pkg/annotations"
1314
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
15+
"github.com/conductorone/baton-sdk/pkg/pagination"
1416
resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
1517
"github.com/google/go-github/v69/github"
1618
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
1719
"go.uber.org/zap"
1820
)
1921

20-
func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, error) {
22+
const (
23+
// Profile keys for invitation status metadata.
24+
invitationProfileKeyStatus = "invitation_status"
25+
invitationProfileKeyExpiresAt = "invitation_expires_at"
26+
27+
// Values exposed via invitation_status.
28+
invitationStatusPendingAcceptance = "invitation_pending_acceptance"
29+
invitationStatusExpired = "invitation_expired"
30+
31+
// Pagination bag states used to drive the two upstream listing endpoints.
32+
invitationStatePending = "invitation:pending"
33+
invitationStateFailed = "invitation:failed"
34+
35+
// GitHub returns the literal string "expired" in failed_reason when an
36+
// invitation has expired without being accepted.
37+
githubInvitationFailedReasonExpired = "expired"
38+
39+
// Organization invitations expire 7 days after creation.
40+
// https://github.blog/changelog/2020-02-05-self-expiring-repository-and-organization-invitations/
41+
invitationLifetime = 7 * 24 * time.Hour
42+
)
43+
44+
func invitationToUserResource(invitation *github.Invitation, status string) (*v2.Resource, error) {
2145
login := invitation.GetLogin()
2246
if login == "" {
2347
login = invitation.GetEmail()
2448
}
2549

50+
profile := map[string]interface{}{
51+
"login": login,
52+
"inviter": invitation.GetInviter().GetLogin(),
53+
invitationProfileKeyStatus: status,
54+
}
55+
if expiresAt, ok := invitationExpiresAt(invitation, status); ok {
56+
profile[invitationProfileKeyExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
57+
}
58+
2659
ret, err := resourceSdk.NewUserResource(
2760
login,
2861
resourceTypeInvitation,
2962
invitation.GetID(),
3063
[]resourceSdk.UserTraitOption{
3164
resourceSdk.WithEmail(invitation.GetEmail(), true),
32-
resourceSdk.WithUserProfile(map[string]interface{}{
33-
"login": login,
34-
"inviter": invitation.GetInviter().GetLogin(),
35-
}),
65+
resourceSdk.WithUserProfile(profile),
3666
resourceSdk.WithStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED),
3767
resourceSdk.WithUserLogin(login),
3868
},
@@ -43,6 +73,22 @@ func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, erro
4373
return ret, nil
4474
}
4575

76+
// invitationExpiresAt returns the moment the invitation expired (for already
77+
// expired invitations) or will expire (for pending invitations). GitHub does
78+
// not surface an expires_at field on the org invitation payload, so for
79+
// pending invitations we derive it from created_at + 7 days.
80+
func invitationExpiresAt(invitation *github.Invitation, status string) (time.Time, bool) {
81+
if status == invitationStatusExpired {
82+
if t := invitation.GetFailedAt(); !t.IsZero() {
83+
return t.Time, true
84+
}
85+
}
86+
if t := invitation.GetCreatedAt(); !t.IsZero() {
87+
return t.Add(invitationLifetime), true
88+
}
89+
return time.Time{}, false
90+
}
91+
4692
type invitationResourceType struct {
4793
client *github.Client
4894
orgCache *orgNameCache
@@ -54,7 +100,6 @@ func (i *invitationResourceType) ResourceType(_ context.Context) *v2.ResourceTyp
54100
}
55101

56102
func (i *invitationResourceType) List(ctx context.Context, parentID *v2.ResourceId, opts resourceSdk.SyncOpAttrs) ([]*v2.Resource, *resourceSdk.SyncOpResults, error) {
57-
var annotations annotations.Annotations
58103
if parentID == nil {
59104
return nil, &resourceSdk.SyncOpResults{}, nil
60105
}
@@ -68,44 +113,106 @@ func (i *invitationResourceType) List(ctx context.Context, parentID *v2.Resource
68113
if err != nil {
69114
return nil, nil, err
70115
}
71-
invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, orgName, &github.ListOptions{
116+
117+
listOpts := &github.ListOptions{
72118
Page: page,
73119
PerPage: opts.PageToken.Size,
74-
})
75-
if err != nil {
76-
if isNotFoundError(resp) {
77-
return nil, &resourceSdk.SyncOpResults{}, nil
78-
}
79-
return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations")
80120
}
81121

82-
restApiRateLimit, err := extractRateLimitData(resp)
83-
if err != nil {
84-
return nil, nil, err
85-
}
122+
var (
123+
invitationResources []*v2.Resource
124+
respAnnos annotations.Annotations
125+
)
86126

87-
nextPage, _, err := parseResp(resp)
88-
if err != nil {
89-
return nil, nil, err
90-
}
127+
switch bag.ResourceTypeID() {
128+
case resourceTypeInvitation.Id:
129+
// First call: fan out into the two listing states. Pending is pushed
130+
// last so it is processed first; failed/expired runs after pending
131+
// fully drains.
132+
bag.Pop()
133+
bag.Push(pagination.PageState{ResourceTypeID: invitationStateFailed})
134+
bag.Push(pagination.PageState{ResourceTypeID: invitationStatePending})
135+
136+
case invitationStatePending:
137+
invitations, resp, err := i.client.Organizations.ListPendingOrgInvitations(ctx, orgName, listOpts)
138+
if err != nil {
139+
if isNotFoundError(resp) {
140+
if err := bag.Next(""); err != nil {
141+
return nil, nil, err
142+
}
143+
break
144+
}
145+
return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list pending org invitations")
146+
}
91147

92-
pageToken, err := bag.NextToken(nextPage)
93-
if err != nil {
94-
return nil, nil, err
95-
}
148+
nextPage, annos, err := parseResp(resp)
149+
if err != nil {
150+
return nil, nil, err
151+
}
152+
respAnnos = annos
153+
154+
if err := bag.Next(nextPage); err != nil {
155+
return nil, nil, err
156+
}
96157

97-
invitationResources := make([]*v2.Resource, 0, len(invitations))
98-
for _, invitation := range invitations {
99-
ir, err := invitationToUserResource(invitation)
158+
invitationResources = make([]*v2.Resource, 0, len(invitations))
159+
for _, invitation := range invitations {
160+
ir, err := invitationToUserResource(invitation, invitationStatusPendingAcceptance)
161+
if err != nil {
162+
return nil, nil, err
163+
}
164+
invitationResources = append(invitationResources, ir)
165+
}
166+
167+
case invitationStateFailed:
168+
invitations, resp, err := i.client.Organizations.ListFailedOrgInvitations(ctx, orgName, listOpts)
169+
if err != nil {
170+
if isNotFoundError(resp) {
171+
if err := bag.Next(""); err != nil {
172+
return nil, nil, err
173+
}
174+
break
175+
}
176+
return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list failed org invitations")
177+
}
178+
179+
nextPage, annos, err := parseResp(resp)
100180
if err != nil {
101181
return nil, nil, err
102182
}
103-
invitationResources = append(invitationResources, ir)
183+
respAnnos = annos
184+
185+
if err := bag.Next(nextPage); err != nil {
186+
return nil, nil, err
187+
}
188+
189+
invitationResources = make([]*v2.Resource, 0, len(invitations))
190+
for _, invitation := range invitations {
191+
// The failed_invitations endpoint includes failures other than
192+
// expirations (e.g. user_was_inactive, unexpected_failure). Only
193+
// surface invitations that explicitly expired.
194+
if invitation.GetFailedReason() != githubInvitationFailedReasonExpired {
195+
continue
196+
}
197+
ir, err := invitationToUserResource(invitation, invitationStatusExpired)
198+
if err != nil {
199+
return nil, nil, err
200+
}
201+
invitationResources = append(invitationResources, ir)
202+
}
203+
204+
default:
205+
return nil, nil, fmt.Errorf("github-connector: unexpected invitation page state %q", bag.ResourceTypeID())
104206
}
105-
annotations.WithRateLimiting(restApiRateLimit)
207+
208+
pageToken, err := bag.Marshal()
209+
if err != nil {
210+
return nil, nil, err
211+
}
212+
106213
return invitationResources, &resourceSdk.SyncOpResults{
107214
NextPageToken: pageToken,
108-
Annotations: annotations,
215+
Annotations: respAnnos,
109216
}, nil
110217
}
111218

@@ -195,7 +302,7 @@ func (i *invitationResourceType) CreateAccount(
195302
var annotations annotations.Annotations
196303
annotations.WithRateLimiting(restApiRateLimit)
197304

198-
r, err := invitationToUserResource(invitation)
305+
r, err := invitationToUserResource(invitation, invitationStatusPendingAcceptance)
199306
if err != nil {
200307
return nil, nil, nil, fmt.Errorf("github-connectorv2: cannot create user resource: %w", err)
201308
}
@@ -229,6 +336,12 @@ func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.Reso
229336
resp, err = i.client.Organizations.CancelInvite(ctx, org, invitationID)
230337
if err == nil {
231338
isRemoved = true
339+
continue
340+
}
341+
if isNotFoundError(resp) {
342+
// Invitation is already gone (expired or previously cancelled).
343+
// Desired state is achieved, so treat as success.
344+
isRemoved = true
232345
}
233346
}
234347

@@ -315,7 +428,7 @@ func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, or
315428
}
316429
for _, inv := range invitations {
317430
if invitationMatches(inv, login, email) {
318-
return invitationToUserResource(inv)
431+
return invitationToUserResource(inv, invitationStatusPendingAcceptance)
319432
}
320433
}
321434
if resp.NextPage == 0 {

0 commit comments

Comments
 (0)