From d17828e0711185bff761d884a6d78b4b25cd7033 Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Fri, 15 May 2026 10:43:49 -0700 Subject: [PATCH 1/2] feat: account provisioning and deprovisioning for Linear users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements AccountManagerLimited via organizationInviteCreate (invite-based; returns ActionRequiredResult since the User only exists after the invitee accepts) and ResourceDeleterLimited via userSuspend (Linear's deprovisioning primitive — revokes access, invalidates sessions; Linear has no hard-delete). Role is read from the user_role profile field (admin/guest/user, matching Linear's lowercase UserRoleType enum); email is read from AccountInfo.Emails or Login. --- pkg/connector/user.go | 101 +++++++++++++++++++++++++++++++++++++++++- pkg/linear/client.go | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 7fce0049..7554e03c 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -14,7 +14,11 @@ import ( sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource" ) -var _ connectorbuilder.ResourceSyncer = (*userResourceType)(nil) +var ( + _ connectorbuilder.ResourceSyncer = (*userResourceType)(nil) + _ connectorbuilder.AccountManagerLimited = (*userResourceType)(nil) + _ connectorbuilder.ResourceDeleterLimited = (*userResourceType)(nil) +) const userRoleProfileKey = "user_role" @@ -139,6 +143,101 @@ func (o *userResourceType) Grants(ctx context.Context, resource *v2.Resource, pt return rv, "", nil, nil } +func (o *userResourceType) CreateAccountCapabilityDetails(_ context.Context) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) { + return &v2.CredentialDetailsAccountProvisioning{ + SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{ + v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD, + }, + PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD, + }, nil, nil +} + +// 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. +func (o *userResourceType) CreateAccount( + ctx context.Context, + accountInfo *v2.AccountInfo, + _ *v2.LocalCredentialOptions, +) (connectorbuilder.CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error) { + email := accountEmail(accountInfo) + if email == "" { + return nil, nil, nil, fmt.Errorf("baton-linear: email is required to create an invite") + } + + role := accountRole(accountInfo) + + inviteID, err := o.client.CreateOrganizationInvite(ctx, email, role, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("baton-linear: failed to create organization invite: %w", err) + } + + return &v2.CreateAccountResponse_ActionRequiredResult{ + Resource: nil, + Message: fmt.Sprintf("Invitation sent to %s (invite ID: %s). The user must accept the invite to join the workspace.", email, inviteID), + IsCreateAccountResult: true, + }, nil, nil, nil +} + +// Delete deprovisions a Linear user by suspending them. Linear does not delete +// user records; userSuspend revokes workspace access and invalidates sessions. +func (o *userResourceType) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) { + if resourceId.GetResourceType() != resourceTypeUser.Id { + return nil, fmt.Errorf("baton-linear: non-user resource passed to user delete: %s", resourceId.GetResourceType()) + } + + success, err := o.client.SuspendUser(ctx, resourceId.GetResource()) + if err != nil { + return nil, fmt.Errorf("baton-linear: failed to suspend user: %w", err) + } + if !success { + return nil, fmt.Errorf("baton-linear: userSuspend returned success=false") + } + return nil, nil +} + +// accountEmail extracts the invitee's email from AccountInfo, preferring the +// primary email, then any email, then Login. +func accountEmail(accountInfo *v2.AccountInfo) string { + if accountInfo == nil { + return "" + } + for _, e := range accountInfo.GetEmails() { + if e.GetIsPrimary() && e.GetAddress() != "" { + return e.GetAddress() + } + } + for _, e := range accountInfo.GetEmails() { + if e.GetAddress() != "" { + return e.GetAddress() + } + } + return accountInfo.GetLogin() +} + +// accountRole extracts the requested Linear role from the profile. Defaults to +// "user". Valid Linear values are admin, guest, user (owner is granted manually). +func accountRole(accountInfo *v2.AccountInfo) string { + if accountInfo == nil { + return "" + } + profile := accountInfo.GetProfile() + if profile == nil { + return "" + } + field, ok := profile.GetFields()[userRoleProfileKey] + if !ok || field == nil { + return "" + } + v := strings.ToLower(strings.TrimSpace(field.GetStringValue())) + switch v { + case roleAdmin, roleGuest, roleUser: + return v + default: + return "" + } +} + func userBuilder(client *linear.Client) *userResourceType { return &userResourceType{ resourceType: resourceTypeUser, diff --git a/pkg/linear/client.go b/pkg/linear/client.go index ea38cd10..a2677a3c 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -509,6 +509,84 @@ func (c *Client) AddMemberToTeam(ctx context.Context, teamId, userId string) (st return res.TeamMembership.ID, nil } +// CreateOrganizationInvite invites a new user to the Linear workspace. +// Returns the organization invite ID. The user becomes a User in Linear only after accepting. +func (c *Client) CreateOrganizationInvite(ctx context.Context, email string, role string, teamIDs []string) (string, error) { + mutation := `mutation OrganizationInviteCreate($input: OrganizationInviteCreateInput!) { + organizationInviteCreate(input: $input) { + success + organizationInvite { + id + } + } + }` + + input := map[string]interface{}{ + "email": email, + } + if role != "" { + input["role"] = role + } + if len(teamIDs) > 0 { + input["teamIds"] = teamIDs + } + + b := map[string]interface{}{ + "query": mutation, + "variables": map[string]interface{}{"input": input}, + } + + var res struct { + Data struct { + OrganizationInviteCreate struct { + Success bool `json:"success"` + OrganizationInvite struct { + ID string `json:"id"` + } `json:"organizationInvite"` + } `json:"organizationInviteCreate"` + } `json:"data"` + } + resp, _, err := c.doRequest(ctx, b, &res) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if !res.Data.OrganizationInviteCreate.Success { + return "", fmt.Errorf("organizationInviteCreate returned success=false") + } + + return res.Data.OrganizationInviteCreate.OrganizationInvite.ID, nil +} + +// SuspendUser deactivates a user's account, revoking their access to the workspace +// and invalidating their sessions. Reversible via UnsuspendUser. Requires admin/owner. +func (c *Client) SuspendUser(ctx context.Context, userID string) (bool, error) { + mutation := `mutation UserSuspend($id: String!) { + userSuspend(id: $id) { + success + } + }` + + b := map[string]interface{}{ + "query": mutation, + "variables": map[string]interface{}{"id": userID}, + } + + var res struct { + Data struct { + UserSuspend SuccessResponse `json:"userSuspend"` + } `json:"data"` + } + resp, _, err := c.doRequest(ctx, b, &res) + if err != nil { + return false, err + } + defer resp.Body.Close() + + return res.Data.UserSuspend.Success, nil +} + func (c *Client) RemoveTeamMembership(ctx context.Context, teamMembershipId string) (bool, error) { mutation := `mutation TeamMembershipDelete($teamMembershipDeleteId: String!){ teamMembershipDelete(id: $teamMembershipDeleteId) { From 1d87a15bfbd99afc81ab7bdd3b12c21dbc92cf4f Mon Sep 17 00:00:00 2001 From: Russell Haering Date: Fri, 15 May 2026 11:41:44 -0700 Subject: [PATCH 2/2] test: cover invite, suspend, and CreateAccount/Delete paths httptest-based tests for CreateOrganizationInvite and SuspendUser, plus connector-level coverage of CreateAccount (email/login fallback, role filtering, ActionRequiredResult shape) and Delete (success, wrong resource type, success=false). --- pkg/connector/user_test.go | 196 +++++++++++++++++++++++++++++++++++++ pkg/linear/client_test.go | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 pkg/connector/user_test.go create mode 100644 pkg/linear/client_test.go diff --git a/pkg/connector/user_test.go b/pkg/connector/user_test.go new file mode 100644 index 00000000..e9d3dff2 --- /dev/null +++ b/pkg/connector/user_test.go @@ -0,0 +1,196 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/conductorone/baton-linear/pkg/linear" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "google.golang.org/protobuf/types/known/structpb" +) + +func decodeJSON(t *testing.T, r *http.Request, out interface{}) error { + t.Helper() + if err := json.NewDecoder(r.Body).Decode(out); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + return nil +} + +func newTestUserBuilder(t *testing.T, handler http.HandlerFunc) *userResourceType { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := linear.NewClient(context.Background(), "test-api-key", server.URL) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + return userBuilder(client) +} + +func TestUserCreateAccount_MissingEmail(t *testing.T) { + ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("API should not be called when email is missing") + }) + _, _, _, err := ub.CreateAccount(context.Background(), &v2.AccountInfo{}, nil) + if err == nil { + t.Fatal("expected error for missing email") + } + if !strings.Contains(err.Error(), "email is required") { + t.Errorf("expected 'email is required' error, got: %v", err) + } +} + +func TestUserCreateAccount_FromPrimaryEmail(t *testing.T) { + var seenInput map[string]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{}) + seenInput = vars["input"].(map[string]interface{}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-abc"}}}}`)) + }) + + info := &v2.AccountInfo{ + Emails: []*v2.AccountInfo_Email{ + {Address: "alt@example.com", IsPrimary: false}, + {Address: "primary@example.com", IsPrimary: true}, + }, + } + 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-abc") { + t.Errorf("message should contain invite ID, got: %s", action.Message) + } + if seenInput["email"] != "primary@example.com" { + t.Errorf("expected primary email, got %v", seenInput["email"]) + } + if _, has := seenInput["role"]; has { + t.Errorf("role should not be sent when profile omits user_role, got %v", seenInput["role"]) + } +} + +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"}}}}`)) + }) + + info := &v2.AccountInfo{Login: "login@example.com"} + if _, _, _, err := ub.CreateAccount(context.Background(), info, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seenEmail != "login@example.com" { + t.Errorf("expected login fallback, got %v", seenEmail) + } +} + +func TestUserCreateAccount_RoleFromProfile(t *testing.T) { + tests := []struct { + profileRole string + wantRole interface{} + }{ + {"admin", "admin"}, + {" Admin ", "admin"}, + {"guest", "guest"}, + {"user", "user"}, + {"owner", nil}, // owner is not allowed via invite — should be dropped + {"junk", nil}, + } + 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"}}}}`)) + }) + + profile, err := structpb.NewStruct(map[string]interface{}{ + userRoleProfileKey: tt.profileRole, + }) + if err != nil { + t.Fatalf("structpb: %v", err) + } + info := &v2.AccountInfo{ + Emails: []*v2.AccountInfo_Email{{Address: "x@example.com", IsPrimary: true}}, + Profile: profile, + } + if _, _, _, err := ub.CreateAccount(context.Background(), info, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seenRole != tt.wantRole { + t.Errorf("role: want %v got %v", tt.wantRole, seenRole) + } + }) + } +} + +func TestUserDelete_Success(t *testing.T) { + var seenID 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{}) + seenID = vars["id"] + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"userSuspend":{"success":true}}}`)) + }) + + _, err := ub.Delete(context.Background(), &v2.ResourceId{ + ResourceType: resourceTypeUser.Id, + Resource: "user-xyz", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seenID != "user-xyz" { + t.Errorf("user id: want user-xyz got %v", seenID) + } +} + +func TestUserDelete_WrongResourceType(t *testing.T) { + ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("API should not be called for wrong resource type") + }) + _, err := ub.Delete(context.Background(), &v2.ResourceId{ + ResourceType: resourceTypeTeam.Id, + Resource: "team-1", + }) + if err == nil { + t.Fatal("expected error for non-user resource type") + } +} + +func TestUserDelete_SuccessFalse(t *testing.T) { + ub := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"userSuspend":{"success":false}}}`)) + }) + _, err := ub.Delete(context.Background(), &v2.ResourceId{ + ResourceType: resourceTypeUser.Id, + Resource: "user-xyz", + }) + if err == nil { + t.Fatal("expected error when userSuspend returns success=false") + } +} diff --git a/pkg/linear/client_test.go b/pkg/linear/client_test.go new file mode 100644 index 00000000..5e0f1135 --- /dev/null +++ b/pkg/linear/client_test.go @@ -0,0 +1,175 @@ +package linear + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// decodeGraphQLRequest parses the GraphQL request body sent by the client and +// returns the input variable map (the "input" key under "variables"). +func decodeGraphQLRequest(t *testing.T, body io.Reader) map[string]interface{} { + t.Helper() + var req map[string]interface{} + if err := json.NewDecoder(body).Decode(&req); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + vars, ok := req["variables"].(map[string]interface{}) + if !ok { + t.Fatalf("request body missing variables map: %v", req) + } + input, ok := vars["input"].(map[string]interface{}) + if !ok { + // SuspendUser passes id at the top level, not in input. + return vars + } + return input +} + +func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := NewClient(context.Background(), "test-api-key", server.URL) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + return client, server +} + +func TestCreateOrganizationInvite(t *testing.T) { + tests := []struct { + name string + email string + role string + teamIDs []string + response string + wantErr bool + wantInviteID string + assertInput func(t *testing.T, input map[string]interface{}) + }{ + { + name: "success with default role", + email: "newuser@example.com", + response: `{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-1"}}}}`, + wantInviteID: "inv-1", + assertInput: func(t *testing.T, input map[string]interface{}) { + if input["email"] != "newuser@example.com" { + t.Errorf("email: got %v", input["email"]) + } + if _, has := input["role"]; has { + t.Errorf("role should be omitted when caller passes empty string, got %v", input["role"]) + } + if _, has := input["teamIds"]; has { + t.Errorf("teamIds should be omitted when empty, got %v", input["teamIds"]) + } + }, + }, + { + name: "success with admin role and teams", + email: "admin@example.com", + role: "admin", + teamIDs: []string{"team-a", "team-b"}, + response: `{"data":{"organizationInviteCreate":{"success":true,"organizationInvite":{"id":"inv-2"}}}}`, + wantInviteID: "inv-2", + assertInput: func(t *testing.T, input map[string]interface{}) { + if input["role"] != "admin" { + t.Errorf("role: got %v", input["role"]) + } + teams, ok := input["teamIds"].([]interface{}) + if !ok || len(teams) != 2 || teams[0] != "team-a" || teams[1] != "team-b" { + t.Errorf("teamIds: got %v", input["teamIds"]) + } + }, + }, + { + name: "API returns success=false", + email: "fail@example.com", + response: `{"data":{"organizationInviteCreate":{"success":false,"organizationInvite":{"id":""}}}}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + input := decodeGraphQLRequest(t, r.Body) + if tt.assertInput != nil { + tt.assertInput(t, input) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + }) + + id, err := client.CreateOrganizationInvite(context.Background(), tt.email, tt.role, tt.teamIDs) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantInviteID { + t.Errorf("invite ID: want %q got %q", tt.wantInviteID, id) + } + }) + } +} + +func TestSuspendUser(t *testing.T) { + tests := []struct { + name string + userID string + response string + wantSuccess bool + wantErr bool + }{ + { + name: "success", + userID: "user-1", + response: `{"data":{"userSuspend":{"success":true}}}`, + wantSuccess: true, + }, + { + name: "API returns success=false", + userID: "user-2", + response: `{"data":{"userSuspend":{"success":false}}}`, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + vars := decodeGraphQLRequest(t, r.Body) + if vars["id"] != tt.userID { + t.Errorf("id: want %q got %v", tt.userID, vars["id"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + }) + + ok, err := client.SuspendUser(context.Background(), tt.userID) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok != tt.wantSuccess { + t.Errorf("success: want %v got %v", tt.wantSuccess, ok) + } + }) + } +}