Skip to content

Commit

Permalink
Add optional service authentication (#54)
Browse files Browse the repository at this point in the history
* update docs

* set the "x-api-key" header in all outgoing http requests to services

* parse optional api secret from environment variable

* fix broken tests & improve service config string validation
  • Loading branch information
utkuufuk committed Apr 23, 2022
1 parent 1b93e5a commit 51f5c55
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 71 deletions.
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
APP_ENV=development
PORT=XXXX
USERNAME=user
PASSWORD=pwd
SERVICES=<service_1_label_id>@<service_1_endpoint_url>,<service_2_label_id>@<service_2_endpoint_url>
SERVICES=<s1_trello_label_id>:<s1_secret>@<s1_endpoint_url>,<s2_trello_label_id>@<s2_endpoint_url>
TRELLO_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TRELLO_API_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TRELLO_BOARD_ID=xxxxxxxxxxxxxxxxxxxxxxxx
Expand Down
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ Automation feature is supported only by the [server](#server-mode-configuration)
---

## Runner Mode Configuration
Create a [service configuration](#service-configuration) file based on `config.example.json`. By default, the runner looks for a file called `config.json` in the current working directory.

You can trigger a synchronization by simply executing the runner:
Create a [service configuration](#service-configuration) file based on `config.example.json`. You can trigger a synchronization by simply executing the runner:
```sh
# run this as a scheduled (cron) job
go run ./cmd/runner
go run ./cmd/runner -c /path/to/config/file
```

Alternatively, you can specify a custom config file path using the `-c` flag:
If the `-c` flag is omitted, the runner looks for a file called `config.json` in the current working directory:
```sh
go run ./cmd/runner -c /path/to/config/file
# these two are equivalent:
go run ./cmd/runner
go run ./cmd/runner -c ./config.json
```

---
Expand All @@ -65,28 +65,38 @@ curl <SERVER_URL> \
-H "Authorization: Basic <base64(<USERNAME>:<PASSWORD>)>"
```

### Automation
To enable automation for one or more services:
1. Create a [Trello webhook](#trello-webhooks-reference), where the callback URL is `<ENTRELLO_SERVER_URL>/trello-webhook`.
2. Set the `SERVICES` environment variable, configuring a 1-on-1 mapping of Trello labels to service endpoints.
2. Set the `SERVICES` environment variable, a comma-separated list of service configuration strings:
* A service configuration string must contain the Trello label ID and the service endpoint:
```sh
# trello label ID: 1234
# service enpoint URL: http://localhost:3333/entrello
1234@http://localhost:3333/entrello
```
* It may additionally contain an API secret &ndash; _alphanumeric only_ &ndash; for authentication purposes:
```sh
# the HTTP header "X-Api-Key" will be set to "SuPerSecRetPassW0rd" in each request
1234:SuPerSecRetPassW0rd@http://localhost:3333/entrello
```

---

## Service Configuration
Each service must return a JSON array of [Trello card objects][1] upon a `GET` request.

For each service, you must set the following configuration parameters:
#### Mandatory configuration parameters

- `name` &mdash; Service name.

- `endpoint` &mdash; Service endpoint.
- `endpoint` &mdash; Service endpoint URL.

- `strict` &mdash; Whether stale cards should be deleted from the board upon synchronization (boolean).
- `label_id` &mdash; Trello label ID. A label ID can be associated with no more than one service.

- `label_id` &mdash; Trello label ID. A label ID must not be associated for more than one service.
- `list_id` &mdash; Trello list ID, i.e. where to insert new cards. The list must be in the board specified by the root-level `board_id` config parameter.

- `list_id` &mdash; Trello list ID, specifying where to insert new cards. The list must be in the board specified by the root-level `board_id` config parameter.

- `period` &mdash; Polling period for the service. Determines how often a service should be polled. A few examples:
- `period` &mdash; Polling period. A few examples:
```json
// poll on 3rd, 6th, 9th, ... of each month, at 00:00
"period": {
Expand All @@ -108,11 +118,16 @@ For each service, you must set the following configuration parameters:

// poll on each execution
"period": {
"type": "default",
"interval": 0
"type": "default"
}
```

#### Optional configuration parameters

- `secret` &mdash; Alphanumeric API secret. If present, `entrello` will put it in the `X-Api-Key` HTTP header.

- `strict` &mdash; Whether stale cards should be deleted from the board upon synchronization. `false` by default.

---

## Running With Docker
Expand Down
5 changes: 4 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ func main() {

http.HandleFunc("/", handlePollRequest)
http.HandleFunc("/trello-webhook", handleTrelloWebhookRequest)
http.ListenAndServe(fmt.Sprintf(":%s", config.ServerCfg.Port), nil)

if err := http.ListenAndServe(fmt.Sprintf(":%s", config.ServerCfg.Port), nil); err != nil {
logger.Error("Could not start server: %v", err)
}
}

func handlePollRequest(w http.ResponseWriter, req *http.Request) {
Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
{
"name": "Github Issues",
"endpoint": "http://<github-issues-endpoint>",
"secret": "youwish",
"strict": true,
"label_id": "xxxxxxxxxxxxxxxxxxxxxxxx",
"list_id": "xxxxxxxxxxxxxxxxxxxxxxxx",
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Period struct {
type Service struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Secret string `json:"secret"`
Strict bool `json:"strict"`
Label string `json:"label_id"`
List string `json:"list_id"`
Expand Down
26 changes: 5 additions & 21 deletions internal/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,17 @@ package config
import (
"fmt"
"os"
"strings"

"github.com/joho/godotenv"
)

func init() {
appEnv := os.Getenv("APP_ENV")
if appEnv != "production" {
godotenv.Load()
}

serializedServices := strings.Split(os.Getenv("SERVICES"), ",")
if appEnv != "production" && os.Getenv("SERVICES") == "" {
serializedServices = []string{}
}

services := make([]Service, 0, len(serializedServices))
godotenv.Load()

for _, service := range serializedServices {
parts := strings.Split(service, "@")
if len(parts) != 2 {
panic(fmt.Sprintf("invalid service configuration string: %s", service))
}
services = append(services, Service{
Label: parts[0],
Endpoint: parts[1],
})
services, err := parseServices(os.Getenv("SERVICES"))
if err != nil {
fmt.Println("Could not parse the environment variable 'SERVICES':", err)
os.Exit(1)
}

ServerCfg = ServerConfig{
Expand Down
62 changes: 62 additions & 0 deletions internal/config/parsers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package config

import (
"fmt"
"regexp"
"strings"
)

var alphaNumeric = regexp.MustCompile(`^[a-zA-Z0-9]*$`)

func parseServices(input string) ([]Service, error) {
if input == "" {
return []Service{}, nil
}

serializedServices := strings.Split(input, ",")
services := make([]Service, 0, len(serializedServices))

for _, service := range serializedServices {
majorParts := strings.Split(service, "@")
if len(majorParts) != 2 {
return nil, fmt.Errorf(
"expected only one occurrence of '@', got %d in %s",
len(majorParts)-1,
service,
)
}

minorParts := strings.Split(majorParts[0], ":")
if len(minorParts) > 2 {
return nil, fmt.Errorf(
"expected at most one occurrence of ':', got %d in %s",
len(minorParts)-1,
service,
)
}

if !alphaNumeric.MatchString(minorParts[0]) {
return nil, fmt.Errorf("unexpected non-alphanumeric characters in %s", service)
}

secret := ""
if len(minorParts) > 1 {
if !alphaNumeric.MatchString(minorParts[1]) {
return nil, fmt.Errorf("unexpected non-alphanumeric characters in %s", service)
}
secret = minorParts[1]
}

if !strings.HasPrefix(majorParts[1], "http") {
return nil, fmt.Errorf("service endpoint URL does not start with 'http' in %s", service)
}

services = append(services, Service{
Label: minorParts[0],
Secret: secret,
Endpoint: majorParts[1],
})
}

return services, nil
}
78 changes: 78 additions & 0 deletions internal/config/parsers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package config

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func TestParseServices(t *testing.T) {
tt := []struct {
name string
input string
isValid bool
services []Service
}{
{
name: "simple service without secret",
input: "label@http://example.com",
isValid: true,
services: []Service{{Label: "label", Secret: "", Endpoint: "http://example.com"}},
},
{
name: "simple service with secret",
input: "label:secret@http://example.com",
isValid: true,
services: []Service{{Label: "label", Secret: "secret", Endpoint: "http://example.com"}},
},
{
name: "service with secret containing numbers and uppercase letters",
input: "label:aBcD1230XyZ@http://example.com",
isValid: true,
services: []Service{{Label: "label", Secret: "aBcD1230XyZ", Endpoint: "http://example.com"}},
},
{
name: "endpoint URL does not start with 'http'",
input: "label@example.com",
isValid: false,
},
{
name: "no '@' delimiter",
input: "label-http://example.com",
isValid: false,
},
{
name: "multiple '@' delimiters",
input: "label@joe@example.com",
isValid: false,
},
{
name: "multiple ':' delimiters",
input: "label:super:secret:password@http://example.com",
isValid: false,
},
{
name: "non-alphanumeric characters in label",
input: "definitely$$not_*?a+Trello.Label@http://example.com",
isValid: false,
},
{
name: "non-alphanumeric characters in secret",
input: "label:-?_*@http://example.com",
isValid: false,
},
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
services, err := parseServices(tc.input)
if tc.isValid != (err == nil) {
t.Errorf("expected valid output? %v. Got error: %s", tc.isValid, err)
return
}
if diff := cmp.Diff(services, tc.services); diff != "" {
t.Errorf("services diff: %s", diff)
}
})
}
}
Loading

0 comments on commit 51f5c55

Please sign in to comment.