Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions pkg/connector/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/conductorone/baton-sdk/pkg/pagination"
"github.com/conductorone/baton-sdk/pkg/types/grant"
sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)

Expand Down Expand Up @@ -159,6 +161,10 @@ func (o *userResourceType) CreateAccountCapabilityDetails(_ context.Context) (*v
// CreateAccount provisions a new Linear user by sending a workspace invite. The
// user only becomes a Linear User after they accept the invite, so this returns
// an ActionRequiredResult — there is no resource yet.
//
// Linear rejects invites for existing members and duplicate invites, so a user
// who already exists is returned as AlreadyExists and a pending invite is
// reported as still awaiting acceptance. This keeps retries idempotent.
func (o *userResourceType) CreateAccount(
ctx context.Context,
accountInfo *v2.AccountInfo,
Expand All @@ -169,6 +175,36 @@ func (o *userResourceType) CreateAccount(
return nil, nil, nil, fmt.Errorf("baton-linear: email is required to create an invite")
}

existing, err := o.client.GetUserByEmail(ctx, email)
if err != nil {
return nil, nil, nil, fmt.Errorf("baton-linear: failed to look up user by email: %w", err)
}
if existing != nil {
if !existing.Active {
return nil, nil, nil, status.Errorf(codes.FailedPrecondition, "baton-linear: %s already has a suspended Linear account, which must be reactivated rather than invited", email)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A suspended account is still a duplicate, and this branch makes a deprovision/reprovision cycle unrecoverable.

Delete on this same type calls userSuspend (user.go:229) — Linear has no hard delete — so "suspended" is exactly the state C1's own deprovision leaves behind. The next CreateAccount for that email finds it here and fails, and the failure is terminal: pkg/tasks/c1api/create_account.go:53 joins ErrTaskNonRetryable to whatever CreateAccount returns without reading the gRPC code, and pkg/connectorbuilder/accounts.go builds no retryer (contrast resource_provisioner.go:83/:129 for Grant/Revoke). So the FailedPrecondition we added in 1fe4c2a improves how the error reads but doesn't make it retryable. There's no in-connector recovery either — we have SuspendUser but no unsuspend, and no enable_user action — so someone has to reactivate in Linear's UI.

Suggestion

Drop the status gate so both cases return the existing account:

if existing != nil {
    ur, err := userResource(ctx, existing, &v2.ResourceId{...})
    if err != nil {
        return nil, nil, nil, err
    }
    return &v2.CreateAccountResponse_AlreadyExistsResult{Resource: ur}, nil, nil, nil
}

Why

  • GetUsers already syncs disabled users (includeDisabled: true, client.go:172) and userResource stamps them RESOURCE_STATUS_DISABLED (user.go:75-78), so the resource we'd return is one C1 has already seen, with accurate status. Nothing is misreported.
  • baton-okta is the only shipped connector that gates this on status, and only for DEPROVISIONED, justified in the comment as "a login that can never be provisioned". Okta's reversible SUSPENDED falls through to AlreadyExistsResult. Linear has no deprovisioned state — userSuspend is reversible — so every collision here is the Okta-SUSPENDED case.
  • Re-enabling is a separate lifecycle concern. If we want the account usable again, the thing to add is userUnsuspend as an enable_user action, not a failure on create.

Separately: the message tells the operator the account "must be reactivated", but we don't expose a way to do that and docs/connector.mdx doesn't describe one. Worth rewording whatever happens to the branch.

See also

}
ur, err := userResource(ctx, existing, &v2.ResourceId{ResourceType: resourceTypeOrg.Id, Resource: existing.Organization.ID})
if err != nil {
return nil, nil, nil, err
}
return &v2.CreateAccountResponse_AlreadyExistsResult{
Resource: ur,
IsCreateAccountResult: true,
}, nil, nil, nil
}

pending, err := o.client.GetPendingOrganizationInvite(ctx, email)
if err != nil {
return nil, nil, nil, fmt.Errorf("baton-linear: failed to look up pending invites: %w", err)
}
if pending != nil {
return &v2.CreateAccountResponse_ActionRequiredResult{
Resource: nil,
Message: fmt.Sprintf("Invitation already pending for %s (invite ID: %s). The user must accept the invite to join the workspace.", email, pending.ID),
IsCreateAccountResult: true,
}, nil, nil, nil
}

role := accountRole(accountInfo)

inviteID, err := o.client.CreateOrganizationInvite(ctx, email, role, nil)
Expand Down
129 changes: 106 additions & 23 deletions pkg/connector/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/conductorone/baton-linear/pkg/linear"
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)

Expand Down Expand Up @@ -124,16 +126,40 @@ func TestUserCreateAccount_MissingEmail(t *testing.T) {
}
}

func TestUserCreateAccount_FromPrimaryEmail(t *testing.T) {
var seenInput map[string]interface{}
ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) {
// createAccountHandler serves the user lookup, pending-invite lookup and invite
// mutation that CreateAccount issues. onInvite is nil when no invite is expected.
func createAccountHandler(t *testing.T, users, invites, inviteResponse string, onInvite func(input map[string]interface{})) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
_ = decodeJSON(t, r, &req)
vars := req["variables"].(map[string]interface{})
seenInput = vars["input"].(map[string]interface{})
query, _ := req["query"].(string)
vars, _ := req["variables"].(map[string]interface{})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-abc"}}}}`))
})
switch {
case strings.Contains(query, "UserByEmail"):
_, _ = w.Write([]byte(`{"data":{"users":{"nodes":` + users + `}}}`))
case strings.Contains(query, "organizationInvites("):
_, _ = w.Write([]byte(`{"data":{"organizationInvites":{"nodes":` + invites + `,"pageInfo":{"hasNextPage":false}}}}`))
case strings.Contains(query, "organizationInviteCreate"):
if onInvite == nil {
t.Error("invite should not be sent")
} else {
onInvite(vars["input"].(map[string]interface{}))
}
_, _ = w.Write([]byte(inviteResponse))
default:
t.Errorf("unexpected query: %s", query)
}
}
}

const inviteSuccess = `{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-1"}}}}`

func TestUserCreateAccount_FromPrimaryEmail(t *testing.T) {
var seenInput map[string]interface{}
ub := newTestUserBuilder(t, createAccountHandler(t, `[]`, `[]`,
`{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-abc"}}}}`,
func(input map[string]interface{}) { seenInput = input }))

info := &v2.AccountInfo{
Emails: []*v2.AccountInfo_Email{
Expand Down Expand Up @@ -162,14 +188,8 @@ func TestUserCreateAccount_FromPrimaryEmail(t *testing.T) {

func TestUserCreateAccount_FallsBackToLogin(t *testing.T) {
var seenEmail interface{}
ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
_ = decodeJSON(t, r, &req)
vars := req["variables"].(map[string]interface{})
seenEmail = vars["input"].(map[string]interface{})["email"]
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-1"}}}}`))
})
ub := newTestUserBuilder(t, createAccountHandler(t, `[]`, `[]`, inviteSuccess,
func(input map[string]interface{}) { seenEmail = input["email"] }))

info := &v2.AccountInfo{Login: "login@example.com"}
if _, _, _, err := ub.CreateAccount(context.Background(), info, nil); err != nil {
Expand All @@ -195,14 +215,8 @@ func TestUserCreateAccount_RoleFromProfile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.profileRole, func(t *testing.T) {
var seenRole interface{}
ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
_ = decodeJSON(t, r, &req)
vars := req["variables"].(map[string]interface{})
seenRole = vars["input"].(map[string]interface{})["role"]
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-1"}}}}`))
})
ub := newTestUserBuilder(t, createAccountHandler(t, `[]`, `[]`, inviteSuccess,
func(input map[string]interface{}) { seenRole = input["role"] }))

profile, err := structpb.NewStruct(map[string]interface{}{
userRoleProfileKey: tt.profileRole,
Expand All @@ -224,6 +238,75 @@ func TestUserCreateAccount_RoleFromProfile(t *testing.T) {
}
}

func TestUserCreateAccount_ExistingUser(t *testing.T) {
ub := newTestUserBuilder(t, createAccountHandler(t,
`[{"id":"user-1","name":"Existing User","email":"existing@example.com","active":true,"organization":{"id":"org-1"}}]`,
`[]`, "", nil))

info := &v2.AccountInfo{Emails: []*v2.AccountInfo_Email{{Address: "Existing@example.com", IsPrimary: true}}}
resp, _, _, err := ub.CreateAccount(context.Background(), info, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
exists, ok := resp.(*v2.CreateAccountResponse_AlreadyExistsResult)
if !ok {
t.Fatalf("expected AlreadyExistsResult, got %T", resp)
}
if got := exists.Resource.GetId().GetResource(); got != "user-1" {
t.Errorf("resource ID: want user-1 got %s", got)
}
if got := exists.Resource.GetParentResourceId().GetResource(); got != "org-1" {
t.Errorf("parent resource ID: want org-1 got %s", got)
}
}

func TestUserCreateAccount_SuspendedUser(t *testing.T) {
ub := newTestUserBuilder(t, createAccountHandler(t,
`[{"id":"user-1","name":"Suspended User","email":"suspended@example.com","active":false,"organization":{"id":"org-1"}}]`,
`[]`, "", nil))

info := &v2.AccountInfo{Login: "suspended@example.com"}
_, _, _, err := ub.CreateAccount(context.Background(), info, nil)
if err == nil || !strings.Contains(err.Error(), "suspended") {
t.Fatalf("expected suspended-account error, got: %v", err)
}
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("expected FailedPrecondition, got %v", status.Code(err))
}
}

func TestUserCreateAccount_PendingInvite(t *testing.T) {
ub := newTestUserBuilder(t, createAccountHandler(t, `[]`,
`[{"id":"inv-old","email":"pending@example.com","acceptedAt":null,"expiresAt":"2000-01-01T00:00:00.000Z"},`+
`{"id":"inv-pending","email":"Pending@example.com","acceptedAt":null,"expiresAt":"2999-01-01T00:00:00.000Z"}]`,
"", nil))

info := &v2.AccountInfo{Login: "pending@example.com"}
resp, _, _, err := ub.CreateAccount(context.Background(), info, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
action, ok := resp.(*v2.CreateAccountResponse_ActionRequiredResult)
if !ok {
t.Fatalf("expected ActionRequiredResult, got %T", resp)
}
if !strings.Contains(action.Message, "inv-pending") {
t.Errorf("message should contain pending invite ID, got: %s", action.Message)
}
}

func TestUserCreateAccount_InviteErrorDetails(t *testing.T) {
ub := newTestUserBuilder(t, createAccountHandler(t, `[]`, `[]`,
`{"data":{"organizationInviteCreate":null},"errors":[{"message":"Invite limit reached"}]}`,
func(map[string]interface{}) {}))

info := &v2.AccountInfo{Login: "new@example.com"}
_, _, _, err := ub.CreateAccount(context.Background(), info, nil)
if err == nil || !strings.Contains(err.Error(), "Invite limit reached") {
t.Fatalf("expected error to include Linear's message, got: %v", err)
}
}

func TestUserDelete_Success(t *testing.T) {
var seenID interface{}
ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading
Loading