Skip to content

Commit

Permalink
feat: webhooks (#604)
Browse files Browse the repository at this point in the history
- Option to register a webhook endpoints via "/admin/webhook" API
- Each webhook is signed by a secret autogenerated when the
webhook is created

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma authored May 4, 2024
1 parent c000137 commit 86da957
Show file tree
Hide file tree
Showing 38 changed files with 5,758 additions and 738 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ GOVERSION := $(shell go version | cut -d ' ' -f 3 | cut -d '.' -f 2)
NAME=github.com/raystack/frontier
TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "acaad106cbc6ee1517eab2eecf8337b5c2d690ec"
PROTON_COMMIT := "608c3ccfe2f66db16bb82877037bc1edff795c63"

ui:
@echo " > generating ui build"
Expand Down
10 changes: 9 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"syscall"
"time"

"github.com/raystack/frontier/core/webhook"

"github.com/raystack/frontier/core/event"

"github.com/stripe/stripe-go/v75"
Expand Down Expand Up @@ -449,7 +451,12 @@ func buildAPIDependencies(
eventChannel := make(chan audit.Log, 0)
logPublisher := event.NewChanPublisher(eventChannel)
logListener := event.NewChanListener(eventChannel, eventProcessor)
auditService := audit.NewService("frontier", auditRepository, audit.WithLogPublisher(logPublisher))

webhookService := webhook.NewService(postgres.NewWebhookEndpointRepository(dbc, []byte(cfg.App.Webhook.EncryptionKey)))
auditService := audit.NewService("frontier",
auditRepository, webhookService,
audit.WithLogPublisher(logPublisher),
)

dependencies := api.Deps{
OrgService: organizationService,
Expand Down Expand Up @@ -482,6 +489,7 @@ func buildAPIDependencies(
UsageService: usageService,
InvoiceService: invoiceService,
LogListener: logListener,
WebhookService: webhookService,
}
return dependencies, nil
}
Expand Down
2 changes: 1 addition & 1 deletion core/audit/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func GetService(ctx context.Context) *Service {
u, ok := ctx.Value(consts.AuditServiceContextKey).(*Service)
if !ok {
return NewService("default", NewNoopRepository())
return NewService("default", NewNoopRepository(), NewNoopWebhookService())
}
return u
}
Expand Down
12 changes: 12 additions & 0 deletions core/audit/noop_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package audit

import (
"context"

"github.com/raystack/frontier/core/webhook"
)

type NoopRepository struct{}
Expand All @@ -21,3 +23,13 @@ func (r NoopRepository) List(ctx context.Context, filter Filter) ([]Log, error)
func (r NoopRepository) GetByID(ctx context.Context, s string) (Log, error) {
return Log{}, nil
}

type NoopWebhookService struct{}

func NewNoopWebhookService() *NoopWebhookService {
return &NoopWebhookService{}
}

func (s NoopWebhookService) Publish(ctx context.Context, e webhook.Event) error {
return nil
}
63 changes: 59 additions & 4 deletions core/audit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package audit

import (
"context"

"github.com/google/uuid"
"github.com/raystack/frontier/core/webhook"
)

type Repository interface {
Expand All @@ -14,6 +17,10 @@ type Publisher interface {
Publish(context.Context, Log)
}

type WebhookService interface {
Publish(ctx context.Context, e webhook.Event) error
}

type Option func(*Service)

func WithMetadataExtractor(fn func(context.Context) (map[string]string, bool)) Option {
Expand All @@ -35,20 +42,22 @@ func WithLogPublisher(p Publisher) Option {
}

type Service struct {
source string
repository Repository
publisher Publisher
source string
repository Repository
publisher Publisher
webhookService WebhookService

actorExtractor func(context.Context) (Actor, bool)
metadataExtractor func(context.Context) (map[string]string, bool)
}

func NewService(source string, repository Repository, opts ...Option) *Service {
func NewService(source string, repository Repository, webhookService WebhookService, opts ...Option) *Service {
svc := &Service{
source: source,
repository: repository,
actorExtractor: defaultActorExtractor,
metadataExtractor: defaultMetadataExtractor,
webhookService: webhookService,
}
for _, o := range opts {
o(svc)
Expand All @@ -57,13 +66,25 @@ func NewService(source string, repository Repository, opts ...Option) *Service {
}

func (s *Service) Create(ctx context.Context, l *Log) error {
if l.ID == "" {
l.ID = uuid.NewString()
}
err := s.repository.Create(ctx, l)
if err != nil {
return err
}

if s.publisher != nil {
s.publisher.Publish(ctx, *l)
}
if err := s.webhookService.Publish(ctx, webhook.Event{
ID: l.ID,
Action: l.Action,
Data: TransformToEventData(l),
CreatedAt: l.CreatedAt,
}); err != nil {
return err
}
return nil
}

Expand All @@ -74,3 +95,37 @@ func (s *Service) List(ctx context.Context, flt Filter) ([]Log, error) {
func (s *Service) GetByID(ctx context.Context, id string) (Log, error) {
return s.repository.GetByID(ctx, id)
}

func TransformToEventData(l *Log) map[string]interface{} {
anyMap := make(map[string]any)
for k, v := range l.Metadata {
anyMap[k] = v
}
result := map[string]any{
"target": map[string]any{},
"actor": map[string]any{},
}
if l.Target.Name != "" {
result["target"].(map[string]any)["name"] = l.Target.Name
}
if l.Target.ID != "" {
result["target"].(map[string]any)["id"] = l.Target.ID
}
if l.Target.Type != "" {
result["target"].(map[string]any)["type"] = l.Target.Type
}
if l.Actor.Name != "" {
result["actor"].(map[string]any)["name"] = l.Actor.Name
}
if l.Actor.ID != "" {
result["actor"].(map[string]any)["id"] = l.Actor.ID
}
if l.Actor.Type != "" {
result["actor"].(map[string]any)["type"] = l.Actor.Type
}
if l.Source != "" {
result["source"] = l.Source
}
result["metadata"] = anyMap
return result
}
119 changes: 119 additions & 0 deletions core/audit/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package audit

import (
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/mitchellh/mapstructure"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/structpb"
)

func TestStructPB(t *testing.T) {
input := make(map[string]interface{})
input["key"] = "value"
input["data"] = map[string]interface{}{
"key2": "value2",
}

result, err := structpb.NewStruct(input)
assert.NoError(t, err)
assert.NotNil(t, result)

// map of string fails
input["data2"] = map[string]string{
"key3": "value3",
}
_, err = structpb.NewStruct(input)
assert.Error(t, err)
delete(input, "data2")

now := time.Now()
logDecoded := map[string]interface{}{}
err = mapstructure.Decode(&Log{
Source: "source",
Target: Target{
ID: "target-id",
Type: "target-type",
},
Actor: Actor{
ID: "actor-id",
Type: "actor-type",
Name: "actor-name",
},
Metadata: map[string]string{},
Action: "action",
ID: "id",
CreatedAt: now,
}, &logDecoded)
assert.NoError(t, err)
assert.NotNil(t, logDecoded)
}

func TestTransformToEventData(t *testing.T) {
now := time.Now()
type args struct {
l *Log
}
tests := []struct {
name string
args args
want map[string]interface{}
}{
{
name: "should decode everything except metadata",
args: args{
l: &Log{
Source: "source",
Target: Target{
ID: "target-id",
Type: "target-type",
},
Actor: Actor{
ID: "actor-id",
Type: "actor-type",
Name: "actor-name",
},
Metadata: map[string]string{},
Action: "action",
ID: "id",
CreatedAt: now,
},
},
want: map[string]interface{}{
"source": "source",
"target": map[string]any{"id": "target-id", "type": "target-type"},
"actor": map[string]any{"id": "actor-id", "type": "actor-type", "name": "actor-name"},
"metadata": map[string]any{},
},
},
{
name: "should decode metadata correctly",
args: args{
l: &Log{
Source: "source",
Metadata: map[string]string{
"key": "value",
},
},
},
want: map[string]interface{}{
"source": "source",
"actor": map[string]any{},
"target": map[string]any{},
"metadata": map[string]any{
"key": "value",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TransformToEventData(tt.args.l)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("TransformToEventData() mismatch (-want +got):\n%s", diff)
}
})
}
}
5 changes: 2 additions & 3 deletions core/serviceuser/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,6 @@ func (s Service) CreateToken(ctx context.Context, credential Credential) (Token,
return Token{}, err
}

// encode cred val to hex bytes
credVal := hex.EncodeToString(secretBytes)

// Hash the random bytes using SHA3-256
hash := sha3.Sum256(secretBytes)
credential.SecretHash = hex.EncodeToString(hash[:])
Expand All @@ -302,6 +299,8 @@ func (s Service) CreateToken(ctx context.Context, credential Credential) (Token,
return Token{}, err
}

// encode cred val to hex bytes
credVal := hex.EncodeToString(secretBytes)
return Token{
ID: createdCred.ID,
Title: createdCred.Title,
Expand Down
5 changes: 5 additions & 0 deletions core/webhook/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package webhook

type Config struct {
EncryptionKey string `yaml:"encryption_key" mapstructure:"encryption_key" default:"hash-secret-should-be-32-chars--"`
}
11 changes: 11 additions & 0 deletions core/webhook/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package webhook

import "errors"

var (
ErrNotFound = errors.New("webhook doesn't exist")
ErrInvalidDetail = errors.New("invalid webhook details")
ErrConflict = errors.New("webhook already exist")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrDisabled = errors.New("webhook is disabled")
)
Loading

0 comments on commit 86da957

Please sign in to comment.