Files
sailpoint-cli/cmd/connector/client/connector_client.go

713 lines
17 KiB
Go

package connclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"github.com/sailpoint-oss/sailpoint-cli/client"
)
// ConnClient is an sp connect client for a specific connector
type ConnClient struct {
client client.Client
version *int
config json.RawMessage
connectorRef string
endpoint string
}
// NewConnClient returns a client for the provided (connectorID, version, config)
func NewConnClient(client client.Client, version *int, config json.RawMessage, connectorRef string, endpoint string) *ConnClient {
return &ConnClient{
client: client,
version: version,
config: config,
connectorRef: connectorRef,
endpoint: endpoint,
}
}
// TestConnectionWithConfig provides a way to run std:test-connection with an
// arbitrary config
func (cc *ConnClient) TestConnectionWithConfig(ctx context.Context, cfg json.RawMessage) error {
cmdRaw, err := cc.rawInvokeWithConfig("std:test-connection", []byte("{}"), cfg)
if err != nil {
return err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return newResponseError(resp)
}
return nil
}
// TestConnection runs the std:test-connection command
func (cc *ConnClient) TestConnection(ctx context.Context) (rawResponse []byte, err error) {
cmdRaw, err := cc.rawInvoke("std:test-connection", []byte("{}"))
if err != nil {
return nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, newResponseError(resp)
}
return io.ReadAll(resp.Body)
}
type SimpleKey struct {
ID string `json:"id"`
}
type CompoundKey struct {
LookupID string `json:"lookupId"`
UniqueID string `json:"uniqueId"`
}
type Key struct {
Simple *SimpleKey `json:"simple,omitempty"`
Compound *CompoundKey `json:"compound,omitempty"`
}
func NewSimpleKey(id string) Key {
return Key{
Simple: &SimpleKey{
ID: id,
},
}
}
func NewCompoundKey(lookupID string, uniqueID string) Key {
return Key{
Compound: &CompoundKey{
LookupID: lookupID,
UniqueID: uniqueID,
},
}
}
// Account is an sp connect account. The is used for AccountList, AccountRead
// and AccountUpdate commands.
type Account struct {
Identity string `json:"identity"`
UUID string `json:"uuid"`
Key Key `json:"key"`
Attributes map[string]interface{} `json:"attributes"`
}
func (a *Account) ID() string {
if a.Key.Simple != nil {
return a.Key.Simple.ID
}
if a.Key.Compound != nil {
return a.Key.Compound.LookupID
}
return a.Identity
}
func (a *Account) UniqueID() string {
if a.Key.Compound != nil {
return a.Key.Compound.UniqueID
}
if a.UUID != "" {
return a.UUID
}
return ""
}
// AccountList lists all accounts
func (cc *ConnClient) AccountList(ctx context.Context) (accounts []Account, rawResponse []byte, err error) {
cmdRaw, err := cc.rawInvoke("std:account:list", []byte("{}"))
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
for {
acct := &Account{}
err := decoder.Decode(acct)
if err != nil {
if err == io.EOF {
break
}
return nil, nil, err
}
accounts = append(accounts, *acct)
}
return accounts, rawResponse, nil
}
type readInput struct {
Identity string `json:"identity"`
Key Key `json:"key"`
Type string `json:"type,omitempty"`
}
// AccountRead reads a specific account
func (cc *ConnClient) AccountRead(ctx context.Context, id string, uniqueID string) (account *Account, rawResponse []byte, err error) {
input := readInput{
Identity: id,
}
if uniqueID == "" {
input.Key = NewSimpleKey(id)
} else {
input.Key = NewCompoundKey(id, uniqueID)
}
inRaw, err := json.Marshal(input)
if err != nil {
return nil, nil, err
}
cmdRaw, err := cc.rawInvoke("std:account:read", inRaw)
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
acct := &Account{}
err = decoder.Decode(acct)
if err != nil {
return nil, nil, err
}
return acct, rawResponse, nil
}
// AccountCreate creats an account
func (cc *ConnClient) AccountCreate(ctx context.Context, identity *string, attributes map[string]interface{}) (account *Account, raw []byte, err error) {
input, err := json.Marshal(map[string]interface{}{
"identity": identity,
"attributes": attributes,
})
if err != nil {
return nil, nil, err
}
cmdRaw, err := cc.rawInvoke("std:account:create", input)
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
raw, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
acct := &Account{}
err = json.Unmarshal(raw, acct)
if err != nil {
return nil, nil, err
}
return acct, raw, nil
}
// AccountDelete deletes an account
func (cc *ConnClient) AccountDelete(ctx context.Context, id string, uniqueID string) (raw []byte, err error) {
input := readInput{
Identity: id,
}
if uniqueID == "" {
input.Key = NewSimpleKey(id)
} else {
input.Key = NewCompoundKey(id, uniqueID)
}
inRaw, err := json.Marshal(input)
if err != nil {
return nil, err
}
cmdRaw, err := cc.rawInvoke("std:account:delete", inRaw)
if err != nil {
return nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, newResponseError(resp)
}
return nil, nil
}
// AttributeChange describes a change to a specific attribute
type AttributeChange struct {
Op string `json:"op"`
Attribute string `json:"attribute"`
Value interface{} `json:"value"`
}
// AccountUpdate updates an account
func (cc *ConnClient) AccountUpdate(ctx context.Context, id string, uniqueID string, changes []AttributeChange) (account *Account, rawResponse []byte, err error) {
type accountUpdate struct {
readInput
Changes []AttributeChange `json:"changes"`
}
input := readInput{
Identity: id,
}
if uniqueID == "" {
input.Key = NewSimpleKey(id)
} else {
input.Key = NewCompoundKey(id, uniqueID)
}
inRaw, err := json.Marshal(accountUpdate{
readInput: input,
Changes: changes,
})
if err != nil {
return nil, nil, err
}
cmdRaw, err := cc.rawInvoke("std:account:update", inRaw)
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
acct := &Account{}
err = decoder.Decode(acct)
if err != nil {
return nil, nil, err
}
return account, rawResponse, nil
}
// AccountDiscoverSchema discovers schema for accounts
func (cc *ConnClient) AccountDiscoverSchema(ctx context.Context) (accountSchema *AccountSchema, rawResponse []byte, err error) {
cmdRaw, err := cc.rawInvoke("std:account:discover-schema", []byte("{}"))
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
schema := &AccountSchema{}
err = decoder.Decode(schema)
if err != nil {
return nil, nil, err
}
return schema, rawResponse, nil
}
// Entitlement is an sp connect entitlement, used for EntitlementList and
// EntitlementRead
type Entitlement struct {
Identity string `json:"identity"`
UUID string `json:"uuid"`
Key Key `json:"key"`
Attributes map[string]interface{} `json:"attributes"`
}
func (a *Entitlement) ID() string {
if a.Key.Simple != nil {
return a.Key.Simple.ID
}
if a.Key.Compound != nil {
return a.Key.Compound.LookupID
}
return a.Identity
}
func (a *Entitlement) UniqueID() string {
if a.Key.Compound != nil {
return a.Key.Compound.UniqueID
}
if a.UUID != "" {
return a.UUID
}
return ""
}
// EntitlementList lists all entitlements
func (cc *ConnClient) EntitlementList(ctx context.Context, t string) (entitlements []Entitlement, rawResponse []byte, err error) {
cmdRaw, err := cc.rawInvoke("std:entitlement:list", []byte(fmt.Sprintf(`{"type": %q}`, t)))
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
for {
e := &Entitlement{}
err := decoder.Decode(e)
if err != nil {
if err == io.EOF {
break
}
return nil, nil, err
}
entitlements = append(entitlements, *e)
}
return entitlements, rawResponse, nil
}
// EntitlementRead reads all entitlements
func (cc *ConnClient) EntitlementRead(ctx context.Context, id string, uniqueID string, t string) (entitlement *Entitlement, rawResponse []byte, err error) {
input := readInput{
Identity: id,
Type: t,
}
if uniqueID == "" {
input.Key = NewSimpleKey(id)
} else {
input.Key = NewCompoundKey(id, uniqueID)
}
inRaw, err := json.Marshal(input)
if err != nil {
return nil, nil, err
}
cmdRaw, err := cc.rawInvoke("std:entitlement:read", inRaw)
if err != nil {
return nil, nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
decoder := json.NewDecoder(bytes.NewReader(rawResponse))
e := &Entitlement{}
err = decoder.Decode(e)
if err != nil {
return nil, nil, err
}
return e, rawResponse, nil
}
type ReadSpecOutput struct {
Specification *ConnSpec `json:"specification"`
}
// SpecRead issues a custom:config command which is expected to return the
// connector specification. This is an experimental command used by the
// validation suite.
func (cc *ConnClient) SpecRead(ctx context.Context) (connSpec *ConnSpec, err error) {
cmdRaw, err := cc.rawInvoke("std:spec:read", []byte(`{}`))
if err != nil {
return nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, newResponseError(resp)
}
decoder := json.NewDecoder(resp.Body)
cfg := &ReadSpecOutput{}
err = decoder.Decode(cfg)
if err != nil {
return nil, err
}
return cfg.Specification, nil
}
// Invoke allows you to send an arbitrary json payload as a command
func (cc *ConnClient) Invoke(ctx context.Context, cmdType string, input json.RawMessage) (rawResponse []byte, err error) {
cmdRaw, err := cc.rawInvoke(cmdType, input)
if err != nil {
return nil, err
}
resp, err := cc.client.Post(ctx, connResourceUrl(cc.endpoint, cc.connectorRef, "invoke"), "application/json", bytes.NewReader(cmdRaw))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return nil, newResponseError(resp)
}
rawResponse, err = io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return rawResponse, nil
}
func newResponseError(resp *http.Response) error {
body, _ := io.ReadAll(resp.Body)
var errorPayload interface{}
err := json.Unmarshal(body, &errorPayload)
if err != nil {
return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(body))
} else {
pretty, err := json.MarshalIndent(errorPayload, "", "\t")
if err != nil {
return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(body))
} else {
return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(pretty))
}
}
}
type AccountCreateTemplateField struct {
// Deprecated
Name string `json:"name"`
Key string `json:"key"`
Type string `json:"type"`
Required bool `json:"required"`
InitialValue TemplateInitialValue `json:"initialValue"`
}
type TemplateInitialValue struct {
Type string `json:"type"`
Attributes TemplateAttributes `json:"attributes"`
}
type TemplateAttributes struct {
Name string `json:"name"`
Value interface{} `json:"value"`
Template string `json:"template"`
}
type AccountCreateTemplate struct {
Fields []AccountCreateTemplateField `json:"fields"`
}
type AccountSchema struct {
DisplayAttribute string `json:"displayAttribute"`
GroupAttribute string `json:"groupAttribute"`
IdentityAttribute string `json:"identityAttribute"`
Attributes []AccountSchemaAttribute `json:"attributes"`
}
type EntitlementSchema struct {
Type string `json:"type"`
DisplayName string `json:"displayName"`
IdentityAttribute string `json:"identityAttribute"`
Attributes []EntitlementSchemaAttribute `json:"attributes"`
}
type AccountSchemaAttribute struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Entitlement bool `json:"entitlement"`
Managed bool `json:"managed"`
Multi bool `json:"multi"`
// Writable is not a standard spec field, yet
Writable bool `json:"writable"`
}
type EntitlementSchemaAttribute struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Multi bool `json:"multi"`
}
// ConnSpec is a connector config. See ConnConfig method.
type ConnSpec struct {
Name string `json:"name"`
Commands []string `json:"commands"`
AccountCreateTemplate AccountCreateTemplate `json:"accountCreateTemplate"`
AccountSchema AccountSchema `json:"accountSchema"`
EntitlementSchemas []EntitlementSchema `json:"entitlementSchemas"`
}
func (cc *ConnClient) rawInvoke(cmdType string, input json.RawMessage) (json.RawMessage, error) {
return cc.rawInvokeWithConfig(cmdType, input, cc.config)
}
func (cc *ConnClient) rawInvokeWithConfig(cmdType string, input json.RawMessage, cfg json.RawMessage) (json.RawMessage, error) {
log.Printf("Running %q with %q", cmdType, input)
invokeCmd := invokeCommand{
ConnectorRef: cc.connectorRef,
Type: cmdType,
Config: cfg,
Input: input,
}
if cc.version == nil {
invokeCmd.Tag = "latest"
} else {
invokeCmd.Version = cc.version
}
return json.Marshal(invokeCmd)
}
func connResourceUrl(endpoint string, resourceParts ...string) string {
u, err := url.Parse(endpoint)
if err != nil {
log.Fatalf("invalid endpoint: %s (%q)", err, endpoint)
}
u.Path = path.Join(append([]string{u.Path}, resourceParts...)...)
return u.String()
}
type invokeCommand struct {
ConnectorRef string `json:"connectorRef"`
Version *int `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
Type string `json:"type"`
Config json.RawMessage `json:"config"`
Input json.RawMessage `json:"input"`
}