Skip to content

Commit e49988e

Browse files
btiplingclaude
andcommitted
[BB-543] Implement user provisioning for Jira Datacenter
This commit adds user provisioning capability to the Jira Datacenter connector: - Added CreateUserRequest model for the Jira API - Implemented CreateUser method in the client - Added AccountManager interface in userBuilder - Implemented secure password generation - Updated connector metadata and capabilities - Added automatic default group assignment for new users 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8ab979c commit e49988e

File tree

4 files changed

+243
-2
lines changed

4 files changed

+243
-2
lines changed

pkg/client/client.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const (
9292
allGroupsV2 = "rest/api/2/groups/picker"
9393
allPermissionSchemeV2 = "rest/api/2/permissionscheme"
9494
addUserToGroup = "rest/api/2/group/user"
95+
createUserPath = "rest/api/2/user"
9596
NF = -1
9697
)
9798

@@ -684,3 +685,53 @@ func (client *Client) Myself(ctx context.Context) error {
684685
defer resp.Body.Close()
685686
return nil
686687
}
688+
689+
// CreateUser creates a new user in Jira Datacenter
690+
// Returns the created user information.
691+
// https://developer.atlassian.com/server/jira/platform/rest/v2/api-group-user/#api-api-2-user-post
692+
func (client *Client) CreateUser(ctx context.Context, userRequest *CreateUserRequest) (*jira.User, error) {
693+
var userData UsersAPIData
694+
695+
// Create the URL
696+
endpointUrl, err := url.JoinPath(client.BaseURL, createUserPath)
697+
if err != nil {
698+
return nil, err
699+
}
700+
701+
uri, err := url.Parse(endpointUrl)
702+
if err != nil {
703+
return nil, err
704+
}
705+
706+
// Create the request
707+
req, err := client.httpClient.NewRequest(
708+
ctx,
709+
http.MethodPost,
710+
uri,
711+
uhttp.WithJSONBody(userRequest),
712+
uhttp.WithAcceptJSONHeader(),
713+
uhttp.WithContentTypeJSONHeader(),
714+
)
715+
if err != nil {
716+
return nil, err
717+
}
718+
719+
// Make the request
720+
resp, err := client.httpClient.Do(req, uhttp.WithJSONResponse(&userData))
721+
if err != nil {
722+
return nil, err
723+
}
724+
defer resp.Body.Close()
725+
726+
// Convert to jira.User format
727+
return &jira.User{
728+
Self: userData.Self,
729+
Key: userData.Key,
730+
Name: userData.Name,
731+
EmailAddress: userData.EmailAddress,
732+
DisplayName: userData.DisplayName,
733+
Active: userData.Active,
734+
TimeZone: userData.TimeZone,
735+
Locale: userData.Locale,
736+
}, nil
737+
}

pkg/client/model.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ type BodyActors struct {
129129
Name string `json:"name,omitempty"`
130130
}
131131

132+
type CreateUserRequest struct {
133+
Name string `json:"name"`
134+
Password string `json:"password"`
135+
EmailAddress string `json:"emailAddress"`
136+
DisplayName string `json:"displayName"`
137+
Notification bool `json:"notification,string"`
138+
}
139+
132140
type ActorsAPIData struct {
133141
Self string `json:"self,omitempty"`
134142
Name string `json:"name,omitempty"`

pkg/connector/connector.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,40 @@ func (d *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error)
4040
return &v2.ConnectorMetadata{
4141
DisplayName: "Jira (Datacenter)",
4242
Description: "Implements syncing, provisioning, and ticketing for Jira Datacenter instances",
43+
AccountCreationSchema: &v2.ConnectorAccountCreationSchema{
44+
FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{
45+
"email": {
46+
DisplayName: "Email",
47+
Required: true,
48+
Description: "User's email address (used for login)",
49+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
50+
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
51+
},
52+
Placeholder: "user@example.com",
53+
Order: 1,
54+
},
55+
"first_name": {
56+
DisplayName: "First Name",
57+
Required: true,
58+
Description: "User's first name",
59+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
60+
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
61+
},
62+
Placeholder: "John",
63+
Order: 2,
64+
},
65+
"last_name": {
66+
DisplayName: "Last Name",
67+
Required: true,
68+
Description: "User's last name",
69+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
70+
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
71+
},
72+
Placeholder: "Doe",
73+
Order: 3,
74+
},
75+
},
76+
},
4377
}, nil
4478
}
4579

pkg/connector/users.go

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ package connector
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"fmt"
7+
"math/big"
58

9+
"github.com/conductorone/baton-jira-datacenter/pkg/client"
610
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
711
"github.com/conductorone/baton-sdk/pkg/annotations"
12+
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
813
"github.com/conductorone/baton-sdk/pkg/pagination"
914
sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource"
1015
jira "github.com/conductorone/go-jira/v2/onpremise"
11-
12-
"github.com/conductorone/baton-jira-datacenter/pkg/client"
16+
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
17+
"go.uber.org/zap"
1318
)
1419

1520
type userBuilder struct {
@@ -105,3 +110,146 @@ func newUserBuilder(client *client.Client) *userBuilder {
105110
client: client,
106111
}
107112
}
113+
114+
// CreateAccountCapabilityDetails defines what credential options are supported by this connector.
115+
func (u *userBuilder) CreateAccountCapabilityDetails(ctx context.Context) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) {
116+
return &v2.CredentialDetailsAccountProvisioning{
117+
SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{
118+
v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD,
119+
},
120+
PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD,
121+
}, nil, nil
122+
}
123+
124+
// generateRandomPassword creates a secure random password for new user accounts.
125+
func generateRandomPassword(length int) (string, error) {
126+
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]"
127+
if length < 8 {
128+
length = 8 // Jira typically requires at least 8 characters
129+
}
130+
131+
password := make([]byte, length)
132+
for i := 0; i < length; i++ {
133+
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
134+
if err != nil {
135+
return "", err
136+
}
137+
password[i] = charset[n.Int64()]
138+
}
139+
140+
return string(password), nil
141+
}
142+
143+
// CreateAccount provisions a new user in Jira Datacenter.
144+
func (u *userBuilder) CreateAccount(
145+
ctx context.Context,
146+
accountInfo *v2.AccountInfo,
147+
credentialOptions *v2.CredentialOptions,
148+
) (connectorbuilder.CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error) {
149+
l := ctxzap.Extract(ctx)
150+
151+
// Extract account information from the profile
152+
profile := accountInfo.Profile.AsMap()
153+
154+
// Get required fields
155+
email, ok := profile["email"].(string)
156+
if !ok || email == "" {
157+
return nil, nil, nil, fmt.Errorf("email is required")
158+
}
159+
160+
firstName, _ := profile["first_name"].(string)
161+
lastName, _ := profile["last_name"].(string)
162+
163+
// Create a display name from the first and last name
164+
displayName := firstName
165+
if lastName != "" {
166+
displayName += " " + lastName
167+
}
168+
169+
// If display name is empty, use email as display name
170+
if displayName == "" {
171+
displayName = email
172+
}
173+
174+
// Generate username from email (common practice in Jira)
175+
username := email
176+
177+
// Generate a password if needed
178+
var password string
179+
var plaintextData []*v2.PlaintextData
180+
181+
if credentialOptions.GetRandomPassword() != nil {
182+
// Generate a random password
183+
length := int(credentialOptions.GetRandomPassword().GetLength())
184+
if length <= 0 {
185+
length = 12 // Default length
186+
}
187+
188+
var err error
189+
password, err = generateRandomPassword(length)
190+
if err != nil {
191+
return nil, nil, nil, fmt.Errorf("failed to generate password: %w", err)
192+
}
193+
194+
// Return the password as plaintext data
195+
plaintextData = append(plaintextData, &v2.PlaintextData{
196+
Name: "password",
197+
Description: "Generated password for the new account",
198+
Bytes: []byte(password),
199+
})
200+
} else {
201+
return nil, nil, nil, fmt.Errorf("random password is required for Jira user creation")
202+
}
203+
204+
// Create the user request
205+
userRequest := &client.CreateUserRequest{
206+
Name: username,
207+
Password: password,
208+
EmailAddress: email,
209+
DisplayName: displayName,
210+
Notification: false, // Set to false to avoid sending notification emails
211+
}
212+
213+
// Call the API to create the user
214+
user, err := u.client.CreateUser(ctx, userRequest)
215+
if err != nil {
216+
l.Error("failed to create user", zap.Error(err), zap.String("email", email))
217+
return nil, nil, nil, fmt.Errorf("failed to create user: %w", err)
218+
}
219+
220+
l.Info("user created successfully",
221+
zap.String("username", username),
222+
zap.String("email", email),
223+
)
224+
225+
// Add user to the default group if available
226+
if u.client.DefaultGroupName != "" {
227+
_, err = u.client.AddUserToGroup(ctx, u.client.DefaultGroupName, username)
228+
if err != nil {
229+
l.Warn("failed to add user to default group",
230+
zap.String("group", u.client.DefaultGroupName),
231+
zap.String("username", username),
232+
zap.Error(err),
233+
)
234+
// Don't fail the whole operation if just the group assignment fails
235+
} else {
236+
l.Info("added user to default group",
237+
zap.String("group", u.client.DefaultGroupName),
238+
zap.String("username", username),
239+
)
240+
}
241+
}
242+
243+
// Create a resource from the new user
244+
resource, err := userResource(*user)
245+
if err != nil {
246+
return nil, nil, nil, fmt.Errorf("failed to create resource for new user: %w", err)
247+
}
248+
249+
// Return success result with the new user resource
250+
successResult := &v2.CreateAccountResponse_SuccessResult{
251+
Resource: resource,
252+
}
253+
254+
return successResult, plaintextData, nil, nil
255+
}

0 commit comments

Comments
 (0)