Skip to content
Merged
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
101 changes: 100 additions & 1 deletion pkg/connector/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
196 changes: 196 additions & 0 deletions pkg/connector/user_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
78 changes: 78 additions & 0 deletions pkg/linear/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading