pkg/connector/users.go
224 linesgo
package connector

import (
	"context"
	"fmt"

	"example/baton-junction/pkg/client"

	config "github.com/conductorone/baton-sdk/pb/c1/config/v1"
	v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
	"github.com/conductorone/baton-sdk/pkg/actions"
	"github.com/conductorone/baton-sdk/pkg/annotations"
	"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
	"github.com/conductorone/baton-sdk/pkg/types/resource"
	"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
	"go.uber.org/zap"
)

type userBuilder struct {
	client *client.Client
}

func newUserBuilder(c *client.Client) *userBuilder {
	return &userBuilder{client: c}
}

func (o *userBuilder) ResourceType(_ context.Context) *v2.ResourceType {
	return userResourceType
}

func (o *userBuilder) List(
	ctx context.Context,
	_ *v2.ResourceId,
	attrs resource.SyncOpAttrs,
) ([]*v2.Resource, *resource.SyncOpResults, error) {
	l := ctxzap.Extract(ctx)

	bag, cursor, err := parsePageToken(attrs.PageToken.Token, &v2.ResourceId{ResourceType: userResourceType.Id})
	if err != nil {
		return nil, nil, err
	}

	users, nextCursor, err := o.client.GetUsers(ctx, cursor)
	if err != nil {
		return nil, nil, fmt.Errorf("baton-junction: failed to list users: %w", err)
	}

	l.Debug("listed users", zap.Int("count", len(users)), zap.String("next_cursor", nextCursor))

	var resources []*v2.Resource
	for _, u := range users {
		r, err := userResource(&u)
		if err != nil {
			return nil, nil, err
		}
		resources = append(resources, r)
	}

	if err := bag.Next(nextCursor); err != nil {
		return nil, nil, fmt.Errorf("baton-junction: failed to advance pagination: %w", err)
	}

	nextPageToken, err := bag.Marshal()
	if err != nil {
		return nil, nil, fmt.Errorf("baton-junction: failed to marshal pagination bag: %w", err)
	}

	return resources, &resource.SyncOpResults{NextPageToken: nextPageToken}, nil
}

func (o *userBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) {
	return nil, nil, nil
}

func (o *userBuilder) Grants(_ context.Context, _ *v2.Resource, _ resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) {
	return nil, nil, nil
}

func (o *userBuilder) Get(
	ctx context.Context,
	resourceId *v2.ResourceId,
	_ *v2.ResourceId,
) (*v2.Resource, annotations.Annotations, error) {
	l := ctxzap.Extract(ctx)

	user, err := o.client.GetUser(ctx, resourceId.Resource)
	if err != nil {
		return nil, nil, fmt.Errorf("baton-junction: failed to get user %s: %w", resourceId.Resource, err)
	}

	l.Debug("fetched user", zap.String("user_id", resourceId.Resource))

	r, err := userResource(user)
	if err != nil {
		return nil, nil, err
	}

	return r, nil, nil
}

func (o *userBuilder) CreateAccountCapabilityDetails(
	_ context.Context,
) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) {
	return v2.CredentialDetailsAccountProvisioning_builder{
		SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{
			v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
		},
		PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
	}.Build(), nil, nil
}

func (o *userBuilder) CreateAccount(
	ctx context.Context,
	accountInfo *v2.AccountInfo,
	_ *v2.LocalCredentialOptions,
) (connectorbuilder.CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error) {
	l := ctxzap.Extract(ctx)

	login := accountInfo.GetLogin()
	emails := accountInfo.GetEmails()
	var email string
	if len(emails) > 0 {
		email = emails[0].GetAddress()
	}

	req := &client.CreateUserRequest{
		Email:     email,
		FirstName: accountInfo.GetProfile().GetFields()["first_name"].GetStringValue(),
		LastName:  accountInfo.GetProfile().GetFields()["last_name"].GetStringValue(),
		Username:  login,
	}

	user, err := o.client.CreateUser(ctx, req)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("baton-junction: failed to create account: %w", err)
	}

	l.Info("created user account",
		zap.String("user_id", user.ID),
		zap.String("email", user.Email),
	)

	result := v2.CreateAccountResponse_SuccessResult_builder{
		IsCreateAccountResult: true,
	}.Build()

	return result, nil, nil, nil
}

func (o *userBuilder) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error {
	updateProfileSchema := &v2.BatonActionSchema{
		Name:        "update_profile",
		DisplayName: "Update User Profile",
		Description: "Update a user's profile attributes",
		ActionType:  []v2.ActionType{v2.ActionType_ACTION_TYPE_ACCOUNT_UPDATE_PROFILE},
		Arguments: []*config.Field{
			{
				Name:        "user_id",
				DisplayName: "User",
				Description: "The user to update",
				IsRequired:  true,
				Field: &config.Field_ResourceIdField{
					ResourceIdField: &config.ResourceIdField{
						Rules: &config.ResourceIDRules{
							AllowedResourceTypeIds: []string{"user"},
						},
					},
				},
			},
			{Name: "first_name", DisplayName: "First Name", Description: "The user's first name", Field: &config.Field_StringField{}},
			{Name: "last_name", DisplayName: "Last Name", Description: "The user's last name", Field: &config.Field_StringField{}},
			{Name: "email", DisplayName: "Email", Description: "The user's email address", Field: &config.Field_StringField{}},
			{Name: "department", DisplayName: "Department", Description: "The user's department", Field: &config.Field_StringField{}},
		},
		ReturnTypes: []*config.Field{
			{Name: "success", DisplayName: "Success", Field: &config.Field_BoolField{}},
			{Name: "user_id", DisplayName: "User ID", Field: &config.Field_StringField{}},
			{Name: "first_name", DisplayName: "First Name", Field: &config.Field_StringField{}},
			{Name: "last_name", DisplayName: "Last Name", Field: &config.Field_StringField{}},
			{Name: "email", DisplayName: "Email", Field: &config.Field_StringField{}},
			{Name: "department", DisplayName: "Department", Field: &config.Field_StringField{}},
		},
	}

	return registry.Register(ctx, updateProfileSchema, o.updateUserProfile)
}

func userResource(u *client.User) (*v2.Resource, error) {
	profile := map[string]interface{}{
		"first_name": u.FirstName,
		"last_name":  u.LastName,
	}
	if u.Department != "" {
		profile["department"] = u.Department
	}

	opts := []resource.UserTraitOption{
		resource.WithEmail(u.Email, true),
		resource.WithUserLogin(u.Username),
		resource.WithStatus(mapUserStatus(u.Status)),
		resource.WithUserProfile(profile),
	}

	if u.CreatedAt.Unix() > 0 {
		opts = append(opts, resource.WithCreatedAt(u.CreatedAt))
	}
	if u.LastLogin != nil {
		opts = append(opts, resource.WithLastLogin(*u.LastLogin))
	}

	r, err := resource.NewUserResource(
		displayName(u.FirstName, u.LastName, u.Email, u.ID),
		userResourceType,
		u.ID,
		opts,
		resource.WithAnnotation(&v2.RawId{Id: u.ID}),
	)
	if err != nil {
		return nil, fmt.Errorf("baton-junction: failed to build user resource %s: %w", u.ID, err)
	}

	return r, nil
}