Skip to content

Commit

Permalink
Add methods to create, update, and delete secrets (DelineaXPM#12)
Browse files Browse the repository at this point in the history
- Add methods to create, update, and delete secrets. Add accessors for secret templates and the password generation function.

- Remove all `omitempty` tags with the exception of SecretPolicyID and PasswordTypeWebScriptID, where a value of 0 causes 400 errors complaining about invalid IDs.

Co-authored-by: Chase Barrett <chase.barrett@pegright.com>
  • Loading branch information
chaserb and Chase Barrett authored Jan 11, 2022
1 parent d29883c commit 7d5aa69
Show file tree
Hide file tree
Showing 9 changed files with 576 additions and 78 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@

# IDE
.vscode
.idea
124 changes: 98 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 |
7 changes: 6 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
144 changes: 135 additions & 9 deletions server/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Loading

0 comments on commit 7d5aa69

Please sign in to comment.