diff --git a/.gitignore b/.gitignore index 9b42a60..b76da46 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ # IDE .vscode +.idea diff --git a/README.md b/README.md index 35dd4cd..5316b5e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,69 @@ type Configuration struct { } ``` -The unit tests populate `Configuration` from JSON: +## Use + +Define a `Configuration`, use it to create an instance of `Server`: + +```golang +tss := server.New(server.Configuration{ + Credentials: UserCredential{ + Username: os.Getenv("TSS_USERNAME"), + Password: os.Getenv("TSS_PASSWORD"), + }, + // Expecting either the tenant or URL to be set + Tenant: os.Getenv("TSS_API_TENANT"), + ServerURL: os.Getenv("TSS_SERVER_URL"), +}) +``` + +Get a secret by its numeric ID: + +```golang +s, err := tss.Secret(1) + +if err != nil { + log.Fatal("failure calling server.Secret", err) +} + +if pw, ok := secret.Field("password"); ok { + fmt.Print("the password is", pw) +} +``` + +Create a Secret: + +```golang +secretModel := new(Secret) +secretModel.Name = "New Secret" +secretModel.SiteID = 1 +secretModel.FolderID = 6 +secretModel.SecretTemplateID = 8 +secretModel.Fields = make([]SecretField, 1) +secretModel.Fields[0].FieldID = 270 +secretModel.Fields[0].ItemValue = somePassword + +newSecret, err := tss.CreateSecret(*secretModel) +``` + +Update the Secret: + +```golang +secretModel.ID = newSecret.ID +secretModel.Fields[0].ItemValue = someNewPassword + +updatedSecret, err := tss.UpdateSecret(*secretModel) +``` + +Delete the Secret: + +```golang +err := tss.DeleteSecret(newSecret.ID) +``` + +## Test + +The tests populate a `Configuration` from JSON: ```golang config := new(Configuration) @@ -45,28 +107,38 @@ tss := New(*config) } ``` -## Test - -The unit test tries to read the secret with ID `1` and extract the `password` -field from it. - -## Use - -Define a `Configuration`, use it to create an instance of `Server` and get a `Secret`: - -```golang -tss := server.New(server.Configuration{ - Username: os.Getenv("TSS_API_USERNAME"), - Password: os.Getenv("TSS_API_PASSWORD"), - Tenant: os.Getenv("TSS_API_TENANT"), -}) -s, err := tss.Secret(1) - -if err != nil { - log.Fatal("failure calling server.Secret", err) -} - -if pw, ok := secret.Field("password"); ok { - fmt.Print("the password is", pw) -} -``` +The necessary configuration may also be configured from environment variables: + +| Env Var Name | Description | +|----------------|------------------------------------------------------------------------------------------------------------------------------------------| +| TSS_USERNAME | The user name for the Secret Server | +| TSS_PASSWORD | The password for the user | +| TSS_TENANT | Name for tenants hosted in the Secret Server Cloud. This is prepended to the *.secretservercloud.com domain to determine the server URL. | +| TSS_SERVER_URL | URL for servers not hosted in the cloud, eg: https://thycotic.mycompany.com/SecretServer | + +### Test #1 +Reads the secret with ID `1` or the ID passed in the `TSS_SECRET_ID` environment variable +and extracts the `password` field from it. + +### Test #2 +Creates a secret with a fixed password using the values passed in the environment variables +below. It then reads the secret from the server, validates its values, updates it, and deletes +it. + +| Env Var Name | Description | +|-----------------|-------------------------------------------------------------------------------| +| TSS_SITE_ID | The numeric ID of the distributed engine site | +| TSS_FOLDER_ID | The numeric ID of the folder where the secret will be created | +| TSS_TEMPLATE_ID | The numeric ID of the template that defines the secret's fields | +| TSS_FIELD_ID | The numeric ID of a field on the template that happens to be a password field | + +### Test #3 +Creates a secret with a generated password using the values passed in the environment variables +below. It then deletes the secret. + +| Env Var Name | Description | +|-----------------|-------------------------------------------------------------------------------| +| TSS_SITE_ID | The numeric ID of the distributed engine site | +| TSS_FOLDER_ID | The numeric ID of the folder where the secret will be created | +| TSS_TEMPLATE_ID | The numeric ID of the template that defines the secret's fields | +| TSS_FIELD_ID | The numeric ID of a field on the template that happens to be a password field | diff --git a/main.go b/main.go index 91d7657..d13881c 100644 --- a/main.go +++ b/main.go @@ -9,13 +9,18 @@ import ( ) func main() { - tss, _ := server.New(server.Configuration{ + tss, err := server.New(server.Configuration{ Credentials: server.UserCredential{ Username: os.Getenv("TSS_USERNAME"), Password: os.Getenv("TSS_PASSWORD"), }, Tenant: os.Getenv("TSS_TENANT"), }) + + if err != nil { + log.Fatal("Error initializing the server configuration", err) + } + s, err := tss.Secret(1) if err != nil { diff --git a/server/http.go b/server/http.go index 04948f2..9167f55 100644 --- a/server/http.go +++ b/server/http.go @@ -23,9 +23,9 @@ func handleResponse(res *http.Response, err error) ([]byte, *http.Response, erro return data, res, nil } - // truncate the data to 64 bytes before returning it as part of the error - if len(data) > 64 { - data = append(data[:64], []byte("...")...) + // truncate the data to 256 bytes before returning it as part of the error + if len(data) > 256 { + data = append(data[:256], []byte("...")...) } return nil, res, fmt.Errorf("%s: %s", res.Status, string(data)) diff --git a/server/secret.go b/server/secret.go index 91bfcf2..c3dbba3 100644 --- a/server/secret.go +++ b/server/secret.go @@ -12,18 +12,23 @@ const resource = "secrets" // Secret represents a secret from Thycotic Secret Server type Secret struct { - Name string - FolderID, ID, SiteID int - SecretTemplateID, SecretPolicyID int - Active, CheckedOut, CheckOutEnabled bool - Fields []SecretField `json:"Items"` + Name string + FolderID, ID, SiteID, SecretTemplateID int + SecretPolicyID, PasswordTypeWebScriptID int `json:",omitempty"` + LauncherConnectAsSecretID, CheckOutIntervalMinutes int + Active, CheckedOut, CheckOutEnabled bool + AutoChangeEnabled, CheckOutChangePasswordEnabled, DelayIndexing bool + EnableInheritPermissions, EnableInheritSecretPolicy, ProxyEnabled bool + RequiresComment, SessionRecordingEnabled, WebLauncherRequiresIncognitoMode bool + Fields []SecretField `json:"Items"` } // SecretField is an item (field) in the secret type SecretField struct { - ItemID, FieldID, FileAttachmentID int - FieldDescription, FieldName, Filename, ItemValue, Slug string - IsFile, IsNotes, IsPassword bool + ItemID, FieldID, FileAttachmentID int + FieldName, Slug string + FieldDescription, Filename, ItemValue string + IsFile, IsNotes, IsPassword bool } // Secret gets the secret with id from the Secret Server of the given tenant @@ -42,7 +47,7 @@ func (s Server) Secret(id int) (*Secret, error) { // automatically download file attachments and substitute them for the // (dummy) ItemValue, so as to make the process transparent to the caller for index, element := range secret.Fields { - if element.FileAttachmentID != 0 { + if element.IsFile && element.FileAttachmentID != 0 && element.Filename != "" { path := fmt.Sprintf("%d/fields/%s", id, element.Slug) if data, err := s.accessResource("GET", resource, path, nil); err == nil { @@ -56,6 +61,49 @@ func (s Server) Secret(id int) (*Secret, error) { return secret, nil } +func (s Server) CreateSecret(secret Secret) (*Secret, error) { + return s.writeSecret(secret, "POST", "/") +} + +func (s Server) UpdateSecret(secret Secret) (*Secret, error) { + return s.writeSecret(secret, "PUT", strconv.Itoa(secret.ID)) +} + +func (s Server) writeSecret(secret Secret, method string, path string) (*Secret, error) { + writtenSecret := new(Secret) + + template, err := s.SecretTemplate(secret.SecretTemplateID) + if err != nil { + return nil, err + } + + fileFields, nonFileFields, err := secret.separateFileFields(template) + if err != nil { + return nil, err + } + secret.Fields = nonFileFields + + if data, err := s.accessResource(method, resource, path, secret); err == nil { + if err = json.Unmarshal(data, writtenSecret); err != nil { + log.Printf("[DEBUG] error parsing response from /%s: %q", resource, data) + return nil, err + } + } else { + return nil, err + } + + if err := s.updateFiles(writtenSecret.ID, fileFields); err != nil { + return nil, err + } + + return s.Secret(writtenSecret.ID) +} + +func (s Server) DeleteSecret(id int) error { + _, err := s.accessResource("DELETE", resource, strconv.Itoa(id), nil) + return err +} + // Field returns the value of the field with the name fieldName func (s Secret) Field(fieldName string) (string, bool) { for _, field := range s.Fields { @@ -67,3 +115,81 @@ func (s Secret) Field(fieldName string) (string, bool) { log.Printf("[DEBUG] no matching field for name '%s' in secret '%s'", fieldName, s.Name) return "", false } + +// FieldById returns the value of the field with the given field ID +func (s Secret) FieldById(fieldId int) (string, bool) { + for _, field := range s.Fields { + if fieldId == field.FieldID { + log.Printf("[DEBUG] field with name '%s' matches field ID '%d'", field.FieldName, fieldId) + return field.ItemValue, true + } + } + log.Printf("[DEBUG] no matching field for ID '%d' in secret '%s'", fieldId, s.Name) + return "", false +} + +// updateFiles iterates the list of file fields and if the field's item value is empty, +// deletes the file, otherwise, uploads the contents of the item value as the new/updated +// file attachment. +func (s Server) updateFiles(secretId int, fileFields []SecretField) error { + type fieldMod struct { + Slug string + Dirty bool + Value interface{} + } + + type fieldMods struct { + SecretFields []fieldMod + } + + type secretPatch struct { + Data fieldMods + } + + for _, element := range fileFields { + var path string + var input interface{} + if element.ItemValue == "" { + path = fmt.Sprintf("%d/general", secretId) + input = secretPatch{ Data: fieldMods{ SecretFields: []fieldMod{{ Slug: element.Slug, Dirty: true, Value: nil }} } } + if _, err := s.accessResource("PATCH", resource, path, input); err != nil { + return err + } + } else { + if err := s.uploadFile(secretId, element); err != nil { + return err + } + } + } + return nil +} + +// separateFileFields iterates the fields on this secret, and separates them into file +// fields and non-file fields, using the field definitions in the given template as a +// guide. File fields are returned as the first output, non file fields as the second +// output. +func (s Secret) separateFileFields(template *SecretTemplate) ([]SecretField, []SecretField, error) { + var fileFields []SecretField + var nonFileFields []SecretField + + for _, field := range s.Fields { + var templateField *SecretTemplateField + var found bool + fieldSlug := field.Slug + if fieldSlug == "" { + if fieldSlug, found = template.FieldIdToSlug(field.FieldID); !found { + return nil, nil, fmt.Errorf("[ERROR] field id '%d' is not defined on the secret template with id '%d'", field.FieldID, template.ID) + } + } + if templateField, found = template.GetField(fieldSlug); !found { + return nil, nil, fmt.Errorf("[ERROR] field name '%s' is not defined on the secret template with id '%d'", fieldSlug, template.ID) + } + if templateField.IsFile { + fileFields = append(fileFields, field) + } else { + nonFileFields = append(nonFileFields, field) + } + } + + return fileFields, nonFileFields, nil +} diff --git a/server/secret_template.go b/server/secret_template.go new file mode 100644 index 0000000..57c87e2 --- /dev/null +++ b/server/secret_template.go @@ -0,0 +1,97 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "strconv" +) + +// templateResource is the HTTP URL path component for the secret templates resource +const templateResource = "secret-templates" + +// SecretTemplate represents a secret template from Thycotic Secret Server +type SecretTemplate struct { + Name string + ID int + Fields []SecretTemplateField +} + +// SecretTemplateField is a field in the secret template +type SecretTemplateField struct { + SecretTemplateFieldID int + FieldSlugName, DisplayName, Description, Name, ListType string + IsFile, IsList, IsNotes, IsPassword, IsRequired, IsUrl bool +} + +// SecretTemplate gets the secret template with id from the Secret Server of the given tenant +func (s Server) SecretTemplate(id int) (*SecretTemplate, error) { + secretTemplate := new(SecretTemplate) + + if data, err := s.accessResource("GET", templateResource, strconv.Itoa(id), nil); err == nil { + if err = json.Unmarshal(data, secretTemplate); err != nil { + log.Printf("[ERROR] error parsing response from /%s/%d: %q", templateResource, id, data) + return nil, err + } + } else { + return nil, err + } + + return secretTemplate, nil +} + +// GeneratePassword generates and returns a password for the secret field identified by the given slug on the given +// template. The password adheres to the password requirements associated with the field. NOTE: this should only be +// used with fields whose IsPassword property is true. +func (s Server) GeneratePassword(slug string, template *SecretTemplate) (string, error) { + + fieldId, found := template.FieldSlugToId(slug) + + if ! found { + log.Printf("[ERROR] the alias '%s' does not identify a field on the template named '%s'", slug, template.Name) + } + path := fmt.Sprintf("generate-password/%d", fieldId) + + if data, err := s.accessResource("POST", templateResource, path, nil); err == nil { + passwordWithQuotes := string(data) + return passwordWithQuotes[1:len(passwordWithQuotes) - 1], nil + } else { + return "", err + } +} + +// FieldIdToSlug returns the shorthand alias (aka: "slug") of the field with the given field ID, and a boolean +// indicating whether the given ID actually identifies a field for the secret template. +func (s SecretTemplate) FieldIdToSlug(fieldId int) (string, bool) { + for _, field := range s.Fields { + if fieldId == field.SecretTemplateFieldID { + log.Printf("[TRACE] template field with slug '%s' matches the given ID '%d'", field.FieldSlugName, fieldId) + return field.FieldSlugName, true + } + } + log.Printf("[ERROR] no matching template field with id '%d' in template '%s'", fieldId, s.Name) + return "", false +} + +// FieldSlugToId returns the field ID for the given shorthand alias (aka: "slug") of the field, and a boolean indicating +// whether the given slug actually identifies a field for the secret template. +func (s SecretTemplate) FieldSlugToId(slug string) (int, bool) { + field, found := s.GetField(slug) + if found { + return field.SecretTemplateFieldID, found + } + return 0, found +} + +// GetField returns the field with the given shorthand alias (aka: "slug"), and a boolean indicating whether the given +// slug actually identifies a field for the secret template . +func (s SecretTemplate) GetField(slug string) (*SecretTemplateField, bool) { + for _, field := range s.Fields { + if slug == field.FieldSlugName { + log.Printf("[TRACE] template field with ID '%d' matches the given slug '%s'", field.SecretTemplateFieldID, slug) + return &field, true + } + } + log.Printf("[ERROR] no matching template field with slug '%s' in template '%s'", slug, s.Name) + return nil, false +} diff --git a/server/secret_template_test.go b/server/secret_template_test.go new file mode 100644 index 0000000..9c71be1 --- /dev/null +++ b/server/secret_template_test.go @@ -0,0 +1,62 @@ +package server + +import ( + "testing" +) + +// TestSecretTemplate tests SecretTemplate +func TestSecretTemplate(t *testing.T) { + tss, err := initServer() + if err != nil { + t.Error("configuring the Server:", err) + return + } + + id := initIntegerFromEnv("TSS_TEMPLATE_ID", t) + if id < 0 { + return + } + + template, err := tss.SecretTemplate(id) + + if err != nil { + t.Error("calling secrets.SecretTemplate:", err) + return + } + + if template == nil { + t.Error("secret data is nil") + } + + for _, field := range template.Fields { + fieldSlug := field.FieldSlugName + fieldID := field.SecretTemplateFieldID + + lookupFieldId, foundFieldId := template.FieldSlugToId(fieldSlug) + if ! foundFieldId { + t.Errorf("expected to find the field slug '%s', but FieldSlugToId reported %t", fieldSlug, foundFieldId) + } else if fieldID != lookupFieldId { + t.Errorf("expected the field slug '%s' to return a field id of '%d', but '%d' was returned instead", fieldSlug, fieldID, lookupFieldId) + } + + lookupSlug, foundSlug := template.FieldIdToSlug(fieldID) + if ! foundSlug { + t.Errorf("expected to find the field ID '%d', but FieldIdToSlug reported %t", fieldID, foundSlug) + } else if fieldSlug != lookupSlug { + t.Errorf("expected the field id '%d' to return a field slug of '%s', but '%s' was returned instead", fieldID, fieldSlug, lookupSlug) + } + + generatedPassword, err := tss.GeneratePassword(fieldSlug, template) + if field.IsPassword { + if len(generatedPassword) == 0 || err != nil { + t.Errorf("expected to be able to generate a password for the '%s' field; error is '%v'", fieldSlug, err) + } else { + t.Logf("generated '%s' for the '%s' field", generatedPassword, fieldSlug) + } + } else { + if len(generatedPassword) > 0 || err == nil { + t.Errorf("expected an error when generating a password for the '%s' field", fieldSlug) + } + } + } +} diff --git a/server/secret_test.go b/server/secret_test.go index 4e5f8fc..99f180d 100644 --- a/server/secret_test.go +++ b/server/secret_test.go @@ -10,43 +10,17 @@ import ( // TestSecret tests Secret func TestSecret(t *testing.T) { - var config *Configuration - - if cj, err := ioutil.ReadFile("../test_config.json"); err == nil { - config = new(Configuration) - - json.Unmarshal(cj, &config) - } else { - config = &Configuration{ - Credentials: UserCredential{ - Username: os.Getenv("TSS_USERNAME"), - Password: os.Getenv("TSS_PASSWORD"), - }, - Tenant: os.Getenv("TSS_TENANT"), - } - } - - id := 1 - idFromEnv := os.Getenv("TSS_SECRET_ID") - - if idFromEnv != "" { - var err error - - id, err = strconv.Atoi(idFromEnv) - - if err != nil { - t.Errorf("TSS_SECRET_ID must be an integer: %s", err) - return - } - } - - tss, err := New(*config) - + tss, err := initServer() if err != nil { t.Error("configuring the Server:", err) return } + id := initIntegerFromEnv("TSS_SECRET_ID", t) + if id < 0 { + return + } + s, err := tss.Secret(id) if err != nil { @@ -66,3 +40,119 @@ func TestSecret(t *testing.T) { t.Error("s.Field says nonexistent field exists") } } + +// TestSecretCRUD tests the creation, read, update, and delete of a Secret +func TestSecretCRUD(t *testing.T) { + + // Initialize + tss, err := initServer() + if err != nil { + t.Error("configuring the Server:", err) + return + } + siteId := initIntegerFromEnv("TSS_SITE_ID", t) + folderId := initIntegerFromEnv("TSS_FOLDER_ID", t) + templateId := initIntegerFromEnv("TSS_TEMPLATE_ID", t) + fieldId := initIntegerFromEnv("TSS_FIELD_ID", t) + if siteId < 0 || folderId < 0 || templateId < 0 || fieldId < 0 { + return + } + + // Test creation of a new secret + refSecret := new(Secret) + password := "Shhhhhhhhhhh!123" + refSecret.Name = "Test Secret" + refSecret.SiteID = siteId + refSecret.FolderID = folderId + refSecret.SecretTemplateID = templateId + refSecret.Fields = make([]SecretField, 1) + refSecret.Fields[0].FieldID = fieldId + refSecret.Fields[0].ItemValue = password + sc, err := tss.CreateSecret(*refSecret) + if err != nil { t.Error("calling secrets.CreateSecret:", err); return } + if sc == nil { t.Error("created secret data is nil"); return } + if !validate("created secret folder id", folderId, sc.FolderID, t) { return } + if !validate("created secret template id", templateId, sc.SecretTemplateID, t) { return } + if !validate("created secret site id", siteId, sc.SiteID, t) { return } + createdPassword, matched := sc.FieldById(fieldId) + if !matched { t.Error("created secret does not have the given secret:", err); return } + if !validate("created secret password value", password, createdPassword, t) { return } + + // Test the read of the new secret + sr, err := tss.Secret(sc.ID) + if err != nil { t.Error("calling secrets.Secret:", err); return } + if sr == nil { t.Error("read secret data is nil"); return } + if !validate("read secret folder id", folderId, sr.FolderID, t) { return } + if !validate("read secret template id", templateId, sr.SecretTemplateID, t) { return } + if !validate("read secret site id", siteId, sr.SiteID, t) { return } + readPassword, matched := sr.FieldById(fieldId) + if !matched { t.Error("read secret does not have the given secret:", err); return } + if !validate("read secret password value", password, readPassword, t) { return } + + // Test the update of the new secret + newPassword := password + "updated" + refSecret.ID = sc.ID + refSecret.Fields[0].ItemValue = newPassword + su, err := tss.UpdateSecret(*refSecret) + if err != nil { t.Error("calling secrets.UpdateSecret:", err); return } + if su == nil { t.Error("updated secret data is nil"); return } + if !validate("updated secret folder id", folderId, su.FolderID, t) { return } + if !validate("updated secret template id", templateId, su.SecretTemplateID, t) { return } + if !validate("updated secret site id", siteId, su.SiteID, t) { return } + updatedPassword, matched := su.FieldById(fieldId) + if !matched { t.Error("updated secret does not have the given secret:", err); return } + if !validate("updated secret password value", newPassword, updatedPassword, t) { return } + + // Test the deletion of the new secret + err = tss.DeleteSecret(sc.ID) + if err != nil { t.Error("calling secrets.DeleteSecret:", err); return } + + // Test read of the deleted secret fails + s, err := tss.Secret(sc.ID) + if s != nil { t.Errorf("deleted secret with id '%d' returned from read", sc.ID) } +} + +func initServer() (*Server, error) { + var config *Configuration + + if cj, err := ioutil.ReadFile("../test_config.json"); err == nil { + config = new(Configuration) + + json.Unmarshal(cj, &config) + } else { + config = &Configuration{ + Credentials: UserCredential{ + Username: os.Getenv("TSS_USERNAME"), + Password: os.Getenv("TSS_PASSWORD"), + }, + // Expecting either the tenant or URL to be set + Tenant: os.Getenv("TSS_TENANT"), + ServerURL: os.Getenv("TSS_SERVER_URL"), + } + } + return New(*config) +} + +// initIntegerFromEnv reads the given environment variable and if it's declared, parses it to an integer. Otherwise, +// returns a default integer of '1'. +func initIntegerFromEnv(envVarName string, t *testing.T) int { + intValue := 1 + valueFromEnv := os.Getenv(envVarName) + if valueFromEnv != "" { + var err error + intValue, err = strconv.Atoi(valueFromEnv) + if err != nil { + t.Errorf("%s must be an integer: %s", envVarName, err) + return -1 + } + } + return intValue +} + +func validate(label string, expected interface{}, found interface{}, t *testing.T) bool { + if expected != found { + t.Errorf("expecting '%s' to be '%q', but found '%q' instead.", label, expected, found) + return false + } + return true +} \ No newline at end of file diff --git a/server/server.go b/server/server.go index 6249c9b..9ca17d9 100644 --- a/server/server.go +++ b/server/server.go @@ -4,7 +4,9 @@ import ( "bytes" "encoding/json" "fmt" + "io" "log" + "mime/multipart" "net/http" "net/url" "strings" @@ -37,7 +39,7 @@ type Server struct { // New returns an initialized Secrets object func New(config Configuration) (*Server, error) { if config.ServerURL == "" && config.Tenant == "" || config.ServerURL != "" && config.Tenant != "" { - return nil, fmt.Errorf("Either ServerURL or Tenant must be set") + return nil, fmt.Errorf("either ServerURL or Tenant must be set") } if config.TLD == "" { config.TLD = defaultTLD @@ -66,11 +68,12 @@ func (s Server) urlFor(resource, path string) string { switch { case resource == "token": return fmt.Sprintf("%s/%s", baseURL, s.tokenPathURI) - case path != "/": - path = strings.TrimLeft(path, "/") - fallthrough default: - return fmt.Sprintf("%s/%s/%s/%s", baseURL, s.apiPathURI, strings.Trim(resource, "/"), path) + return fmt.Sprintf("%s/%s/%s/%s", + strings.Trim(baseURL, "/"), + strings.Trim(s.apiPathURI, "/"), + strings.Trim(resource, "/"), + strings.Trim(path, "/")) } } @@ -79,6 +82,7 @@ func (s Server) urlFor(resource, path string) string { func (s Server) accessResource(method, resource, path string, input interface{}) ([]byte, error) { switch resource { case "secrets": + case "secret-templates": default: message := "unknown resource" @@ -114,17 +118,58 @@ func (s Server) accessResource(method, resource, path string, input interface{}) req.Header.Add("Authorization", "Bearer "+accessToken) switch method { - case "POST", "PUT": + case "POST", "PUT", "PATCH": req.Header.Set("Content-Type", "application/json") } - log.Printf("[DEBUG] calling %s", req.URL.String()) + log.Printf("[DEBUG] calling %s %s", method, req.URL.String()) data, _, err := handleResponse((&http.Client{}).Do(req)) return data, err } +// uploadFile uploads the file described in the given fileField to the +// secret at the given secretId as a multipart/form-data request. +func (s Server) uploadFile(secretId int, fileField SecretField) error { + body := bytes.NewBuffer([]byte{}) + path := fmt.Sprintf("%d/fields/%s", secretId, fileField.Slug) + + // Fetch the access token + accessToken, err := s.getAccessToken() + if err != nil { + log.Print("[DEBUG] error getting accessToken:", err) + return err + } + + // Create the multipart form + multipartWriter := multipart.NewWriter(body) + form, err := multipartWriter.CreateFormFile("file", fileField.Filename) + if err != nil { + return err + } + _, err = io.Copy(form, strings.NewReader(fileField.ItemValue)) + if err != nil { + return err + } + err = multipartWriter.Close() + if err != nil { + return err + } + + // Make the request + req, err := http.NewRequest("PUT", s.urlFor(resource, path), body) + if err != nil { + return err + } + req.Header.Add("Authorization", "Bearer " + accessToken) + req.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + log.Printf("[DEBUG] calling PUT %s", req.URL.String()) + _, _, err = handleResponse((&http.Client{}).Do(req)) + + return err +} + // getAccessToken gets an OAuth2 Access Grant and returns the token // endpoint and get an accessGrant. func (s Server) getAccessToken() (string, error) {