Skip to content

Commit 4297242

Browse files
authored
[BB-686] Add User Provisioning to LDAP Connector (#116)
* Add LdapAdd * Stub account creation * Rough implementation * Tweak create account parameters * Update README.md * add rdnValue back * Add ci test for account provisioning * adjust placeholders and default values
1 parent 2d1d552 commit 4297242

File tree

6 files changed

+323
-0
lines changed

6 files changed

+323
-0
lines changed

.github/workflows/ci.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,20 @@ jobs:
112112
BATON_PRINCIPAL: "cn=testuser00099@example.com,dc=example,dc=org"
113113
BATON_PRINCIPAL_TYPE: "user"
114114
run: ./scripts/grant-revoke.sh
115+
116+
- name: Create account
117+
env:
118+
ACCOUNT_DISPLAYNAME: 'Example User'
119+
CREATE_ACCOUNT_FLAGS: >-
120+
--create-account-login="example-user"
121+
--create-account-profile='{"rdnKey":"cn","rdnValue":"example-user","path":"","suffix":"dc=example,dc=org","objectClass":["top","person"],"additionalAttributes":{"cn":"Example User","sn":"User"}}'
122+
123+
run: ./baton-ldap ${{ env.CREATE_ACCOUNT_FLAGS }}
124+
125+
- name: Check account was created
126+
id: check_account
127+
run: |
128+
./baton-ldap
129+
CREATED_ACCOUNT_ID=$(baton resources --output-format=json | jq -r --arg name "Example User" '.resources[] | select(.resource.displayName == $name) | .resource.id.resource')
130+
echo "account_id=$CREATED_ACCOUNT_ID" >> $GITHUB_OUTPUT
131+
[ -n "$CREATED_ACCOUNT_ID" ]

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ brew install conductorone/baton/baton conductorone/baton/baton-ldap
4444

4545
Use `baton-ldap --help` to see all configuration flags and environment variables.
4646

47+
## --create-account
48+
49+
To provision an account from the command line, you'll need to provide the login, email, and account profile. For example:
50+
51+
```
52+
.\baton-ldap.exe --base-dn "DC=baton-dev,DC=d2,DC=ductone,DC=com" --password "password" -p --create-account-login 'example-user' --create-account-profile "{\"rdnKey\":\"uid\",\"path\":\"cn=staged users,cn=accounts,cn=provisioning\",\"suffix\":\"dc=example,dc=test\",\"objectClass\":[\"top\",\"person\",\"organizationalperson\",\"posixAccount\"],\"additionalAttributes\":{\"cn\":\"Example User\",\"sn\":\"User\",\"homeDirectory\":\"\",\"uidNumber\":\"-1\",\"gidNumber\":\"-1\"}}"'
53+
```
54+
4755
# Developing baton-ldap
4856

4957
## How to test with Docker Compose

pkg/connector/connector.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,70 @@ func (l *LDAP) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
5757
DisplayName: "LDAP",
5858
// TODO: add better description
5959
Description: "LDAP connector for Baton",
60+
61+
AccountCreationSchema: schema(),
6062
}, nil
6163
}
6264

65+
func schema() *v2.ConnectorAccountCreationSchema {
66+
return &v2.ConnectorAccountCreationSchema{
67+
FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{
68+
"rdnKey": {
69+
DisplayName: "RDN Key",
70+
Required: true,
71+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{},
72+
Description: "The RDN key to use for the user.",
73+
Placeholder: "cn, uid, etc.",
74+
Order: 1,
75+
},
76+
"rdnValue": {
77+
DisplayName: "RDN Value",
78+
Required: true,
79+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{},
80+
Description: "The RDN value to use for the user.",
81+
Placeholder: "JaneDoe",
82+
Order: 2,
83+
},
84+
"path": {
85+
DisplayName: "Path",
86+
Required: true,
87+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{},
88+
Description: "The path to create the user in.",
89+
Order: 3,
90+
Placeholder: "ou=users",
91+
},
92+
"suffix": {
93+
DisplayName: "Suffix",
94+
Required: true,
95+
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{},
96+
Description: "The top level entry DN (naming context) to create the user in.",
97+
Order: 4,
98+
Placeholder: "dc=example,dc=org",
99+
},
100+
"objectClass": {
101+
DisplayName: "Object Class(es)",
102+
Required: true,
103+
Description: "A list of Object Classes to apply to the user, e.g. person, user, etc.",
104+
Field: &v2.ConnectorAccountCreationSchema_Field_StringListField{
105+
StringListField: &v2.ConnectorAccountCreationSchema_StringListField{
106+
DefaultValue: []string{"top", "person"},
107+
},
108+
},
109+
Order: 5,
110+
Placeholder: "[\"top\", \"person\", \"organizationalPerson\", \"inetOrgPerson\"]",
111+
},
112+
"additionalAttributes": {
113+
DisplayName: "Additional Attributes",
114+
Required: false,
115+
Field: &v2.ConnectorAccountCreationSchema_Field_MapField{},
116+
Description: "A map representing additional attributes to set on the user",
117+
Order: 6,
118+
Placeholder: "{\"cn\":\"Jane Doe\",\"sn\":\"Doe\"}",
119+
},
120+
},
121+
}
122+
}
123+
63124
// Validates that the user has read access to all relevant tables (more information in the readme).
64125
func (l *LDAP) Validate(ctx context.Context) (annotations.Annotations, error) {
65126
_, _, err := l.client.LdapSearch(

pkg/connector/helpers.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package connector
22

33
import (
4+
"context"
5+
"fmt"
6+
"strconv"
47
"strings"
58

69
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
710
"github.com/conductorone/baton-sdk/pkg/annotations"
811
"github.com/conductorone/baton-sdk/pkg/pagination"
912
mapset "github.com/deckarep/golang-set/v2"
1013
"github.com/go-ldap/ldap/v3"
14+
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
15+
"go.uber.org/zap"
16+
"golang.org/x/exp/slices"
1117
)
1218

1319
var ResourcesPageSize uint32 = 50
@@ -67,3 +73,149 @@ func parseValue(entry *ldap.Entry, targetAttrs []string) string {
6773

6874
return ""
6975
}
76+
77+
// We assume that all values are of the same type.
78+
func toVals(vals []any) []string {
79+
if len(vals) == 0 {
80+
return nil
81+
}
82+
83+
switch vals[0].(type) {
84+
case string:
85+
ret := make([]string, len(vals))
86+
for i, v := range vals {
87+
ret[i] = v.(string)
88+
}
89+
return ret
90+
case []byte:
91+
ret := make([]string, len(vals))
92+
for i, v := range vals {
93+
ret[i] = string(v.([]byte))
94+
}
95+
return ret
96+
default:
97+
ret := make([]string, len(vals))
98+
for i, v := range vals {
99+
ret[i] = fmt.Sprintf("%v", v)
100+
}
101+
return ret
102+
}
103+
}
104+
105+
func toAttr(k string, v interface{}) ldap.Attribute {
106+
switch v := v.(type) {
107+
case []string:
108+
return ldap.Attribute{
109+
Type: k,
110+
Vals: v,
111+
}
112+
case []any:
113+
return ldap.Attribute{
114+
Type: k,
115+
Vals: toVals(v),
116+
}
117+
case string:
118+
return ldap.Attribute{
119+
Type: k,
120+
Vals: []string{v},
121+
}
122+
case []byte:
123+
return ldap.Attribute{
124+
Type: k,
125+
Vals: []string{string(v)},
126+
}
127+
case bool:
128+
return ldap.Attribute{
129+
Type: k,
130+
Vals: []string{strconv.FormatBool(v)},
131+
}
132+
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
133+
return ldap.Attribute{
134+
Type: k,
135+
Vals: []string{fmt.Sprintf("%d", v)},
136+
}
137+
case float32, float64:
138+
return ldap.Attribute{
139+
Type: k,
140+
Vals: []string{fmt.Sprintf("%f", v)},
141+
}
142+
default:
143+
// l.Warn("unsupported attribute type", zap.Any("type", v))
144+
return ldap.Attribute{
145+
Type: k,
146+
Vals: []string{fmt.Sprintf("%v", v)},
147+
}
148+
}
149+
}
150+
151+
func extractProfile(ctx context.Context, accountInfo *v2.AccountInfo) (string, []ldap.Attribute, error) {
152+
l := ctxzap.Extract(ctx)
153+
154+
prof := accountInfo.GetProfile()
155+
if prof == nil {
156+
return "", nil, fmt.Errorf("missing profile")
157+
}
158+
data := prof.AsMap()
159+
l.Debug("baton-ldap: create-account profile", zap.Any("data", data))
160+
161+
suffix, ok := data["suffix"].(string)
162+
if !ok {
163+
return "", nil, fmt.Errorf("invalid/missing suffix")
164+
}
165+
path, ok := data["path"].(string)
166+
if !ok {
167+
return "", nil, fmt.Errorf("invalid/missing path")
168+
}
169+
rdnKey, ok := data["rdnKey"].(string)
170+
if !ok {
171+
return "", nil, fmt.Errorf("invalid/missing rdnKey")
172+
}
173+
rdnValue, ok := data["rdnValue"].(string)
174+
if !ok {
175+
return "", nil, fmt.Errorf("invalid/missing rdnValue")
176+
}
177+
178+
var dn string
179+
if path != "" {
180+
dn = strings.Join([]string{fmt.Sprintf("%s=%s", rdnKey, rdnValue), path, suffix}, ",")
181+
} else {
182+
dn = strings.Join([]string{fmt.Sprintf("%s=%s", rdnKey, rdnValue), suffix}, ",")
183+
}
184+
185+
objectClass, ok := data["objectClass"].([]any)
186+
if !ok {
187+
return "", nil, fmt.Errorf("invalid/missing objectClass")
188+
}
189+
for _, oc := range objectClass {
190+
if _, ok := oc.(string); !ok {
191+
return "", nil, fmt.Errorf("invalid objectClass")
192+
}
193+
}
194+
195+
attrs := []ldap.Attribute{}
196+
197+
for k, v := range data {
198+
if slices.Contains([]string{
199+
"additionalAttributes",
200+
"rdnKey",
201+
"rdnValue",
202+
"path",
203+
"suffix",
204+
"login",
205+
}, k) {
206+
continue
207+
}
208+
attrs = append(attrs, toAttr(k, v))
209+
}
210+
211+
additionalAttributes, ok := data["additionalAttributes"].(map[string]interface{})
212+
if ok {
213+
for k, v := range additionalAttributes {
214+
attrs = append(attrs, toAttr(k, v))
215+
}
216+
}
217+
218+
l.Debug("baton-ldap: create-account attributes", zap.Any("attrs", attrs))
219+
220+
return dn, attrs, nil
221+
}

pkg/connector/user.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
1111
"github.com/conductorone/baton-sdk/pkg/annotations"
12+
builder "github.com/conductorone/baton-sdk/pkg/connectorbuilder"
1213
"github.com/conductorone/baton-sdk/pkg/pagination"
1314
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
1415
mapset "github.com/deckarep/golang-set/v2"
@@ -363,6 +364,74 @@ func (u *userResourceType) Grants(ctx context.Context, resource *v2.Resource, to
363364
return nil, "", nil, nil
364365
}
365366

367+
func (o *userResourceType) CreateAccountCapabilityDetails(ctx context.Context) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) {
368+
return &v2.CredentialDetailsAccountProvisioning{
369+
SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{
370+
v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
371+
},
372+
PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
373+
}, nil, nil
374+
}
375+
376+
func (o *userResourceType) CreateAccount(
377+
ctx context.Context,
378+
accountInfo *v2.AccountInfo,
379+
credentialOptions *v2.CredentialOptions,
380+
) (
381+
builder.CreateAccountResponse,
382+
[]*v2.PlaintextData,
383+
annotations.Annotations,
384+
error,
385+
) {
386+
l := ctxzap.Extract(ctx)
387+
388+
if credentialOptions == nil {
389+
return nil, nil, nil, fmt.Errorf("baton-ldap: create-account: missing credential options")
390+
}
391+
392+
dn, attrs, err := extractProfile(ctx, accountInfo)
393+
if err != nil {
394+
l.Error("baton-ldap: create-account failed to extract profile", zap.Error(err), zap.Any("accountInfo", accountInfo))
395+
return nil, nil, nil, err
396+
}
397+
398+
user := &ldap3.AddRequest{
399+
DN: dn,
400+
Attributes: attrs,
401+
}
402+
403+
err = o.client.LdapAdd(ctx, user)
404+
if err != nil {
405+
l.Error("baton-ldap: create-account failed to create account", zap.Error(err), zap.Any("userParams", user))
406+
return nil, nil, nil, err
407+
}
408+
409+
acc, err := getAccount(ctx, o.client, dn)
410+
if err != nil {
411+
l.Error("baton-ldap: create-account failed to get account", zap.Error(err), zap.Any("accountInfo", accountInfo))
412+
return nil, nil, nil, err
413+
}
414+
415+
ur, err := userResource(ctx, acc)
416+
if err != nil {
417+
l.Error("baton-ldap: create-account failed to create resource", zap.Error(err), zap.Any("accountInfo", accountInfo))
418+
return nil, nil, nil, err
419+
}
420+
resp := &v2.CreateAccountResponse_SuccessResult{
421+
Resource: ur,
422+
}
423+
424+
return resp, nil, nil, nil
425+
}
426+
427+
func getAccount(ctx context.Context, client *ldap.Client, dn string) (*ldap.Entry, error) {
428+
userEntry, err := client.LdapGetWithStringDN(ctx, dn, userFilter, allAttrs)
429+
if err != nil {
430+
return nil, fmt.Errorf("ldap-connector: failed to get user: %w", err)
431+
}
432+
return userEntry, nil
433+
}
434+
366435
func userBuilder(client *ldap.Client, userSearchDN *ldap3.DN, disableOperationalAttrs bool) *userResourceType {
367436
return &userResourceType{
368437
resourceType: resourceTypeUser,

pkg/ldap/client.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,22 @@ func (c *Client) _ldapSearch(ctx context.Context,
277277
return ret, nextPageToken, nil
278278
}
279279

280+
func (c *Client) LdapAdd(ctx context.Context, addRequest *ldap.AddRequest) error {
281+
l := ctxzap.Extract(ctx)
282+
283+
l.Debug("adding ldap entry", zap.String("DN", addRequest.DN), zap.Any("attributes", addRequest.Attributes))
284+
285+
err := c.getConnection(ctx, true, func(client *ldapConn) error {
286+
return client.conn.Add(addRequest)
287+
})
288+
if err != nil {
289+
l.Error("baton-ldap: client failed to add record", zap.Error(err))
290+
return err
291+
}
292+
293+
return nil
294+
}
295+
280296
func (c *Client) LdapModify(ctx context.Context, modifyRequest *ldap.ModifyRequest) error {
281297
l := ctxzap.Extract(ctx)
282298

0 commit comments

Comments
 (0)