Skip to content

Commit

Permalink
Add guard rail rules feature that blocks executions based on rules (#524
Browse files Browse the repository at this point in the history
)

- [logging] Change to not output stack trace when logging error
- [gateway] Add CRUD routes to create guard rail rules resources (GET, POST, PUT, DELETE)
- [gateway] Add connection attribute to create rule associations
- [gateway] Refactor connections to use gorm over postgrest
  • Loading branch information
sandromello authored Nov 5, 2024
1 parent d13edf9 commit 333cab1
Show file tree
Hide file tree
Showing 35 changed files with 1,393 additions and 320 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ rootfs/app/ui
build
go.work.sum
.env
client/cmd/exec.go

.DS_Store
libhoop
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ test-enterprise:
generate-openapi-docs:
cd ./gateway/ && go run github.com/swaggo/swag/cmd/swag@v1.16.3 init -g api/server.go -o api/openapi/autogen --outputTypes go --markdownFiles api/openapi/docs/

swag-fmt:
swag fmt

publish:
./scripts/publish-release.sh

Expand Down Expand Up @@ -114,4 +117,4 @@ release-aws-cf-templates:
aws s3 cp --region ap-southeast-2 ${DIST_FOLDER}/hoopdev-platform.template.yaml s3://hoopdev-platform-cf-ap-southeast-2/${VERSION}/hoopdev-platform.template.yaml
aws s3 cp --region ap-southeast-2 ${DIST_FOLDER}/hoopdev-platform.template.yaml s3://hoopdev-platform-cf-ap-southeast-2/latest/hoopdev-platform.template.yaml

.PHONY: run-dev run-dev-postgres test-enterprise test-oss test generate-openapi-docs build build-dev-client build-webapp build-helm-chart build-gateway-bundle extract-webapp publish release release-aws-cf-templates
.PHONY: run-dev run-dev-postgres test-enterprise test-oss test generate-openapi-docs build build-dev-client build-webapp build-helm-chart build-gateway-bundle extract-webapp publish release release-aws-cf-templates swag-fmt
3 changes: 3 additions & 0 deletions client/cmd/admin/create-conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var (
connSecretFlag []string
connAccessModesFlag []string
connSchemaFlag string
connGuardRailRules []string
skipStrictValidation bool
connOverwriteFlag bool

Expand All @@ -41,6 +42,7 @@ func init() {
createConnectionCmd.Flags().StringSliceVar(&connTagsFlag, "tags", nil, "Tags to identify connections in a key=value format")
createConnectionCmd.Flags().StringSliceVar(&connAccessModesFlag, "access-modes", defaultAccessModes, "Access modes enabled for this connection. Accepted values: [runbooks, exec, connect]")
createConnectionCmd.Flags().StringVar(&connSchemaFlag, "schema", "", "Enable or disable the schema for this connection on the WebClient. Accepted values: [disabled, enabled]")
createConnectionCmd.Flags().StringSliceVar(&connGuardRailRules, "guardrail-rules", nil, "The id of the guard rail rules for this connection")
createConnectionCmd.MarkFlagRequired("agent")
}

Expand Down Expand Up @@ -137,6 +139,7 @@ var createConnectionCmd = &cobra.Command{
"redact_enabled": redactEnabled,
"redact_types": connRedactTypesFlag,
"tags": connTagsFlag,
"guardrail_rules": connGuardRailRules,
"access_mode_runbooks": verifyAccessModeStatus("runbooks"),
"access_mode_exec": verifyAccessModeStatus("exec"),
"access_mode_connect": verifyAccessModeStatus("connect"),
Expand Down
2 changes: 1 addition & 1 deletion common/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func NewDefaultLogger(additionalWriterLogger io.Writer) *zap.Logger {
}),
zap.ErrorOutput(stderrSink),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.AddStacktrace(zapcore.DPanicLevel),
)
zap.ReplaceGlobals(logger)
return logger
Expand Down
7 changes: 6 additions & 1 deletion gateway/analytics/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ const (
EventUpdatePluginConfig = "hoop-update-plugin-config"
EventOpenWebhooksDashboard = "hoop-open-webhooks-dashboard"

//Jira
// Jira
EventCreateJiraIntegration = "hoop-create-jira-integration"
EventUpdateJiraIntegration = "hoop-update-jira-integration"

// Guard Rail Rules
EventCreateGuardRailRules = "hoop-create-guardrail-rules"
EventUpdateGuardRailRules = "hoop-update-guardrail-rules"
EventDeleteGuardRailRules = "hoop-delete-guardrail-rules"

// features
EventOrgFeatureUpdate = "hoop-org-feature-update"
EventFeatureAskAIChatCompletions = "hoop-feature-askai-chat-completions"
Expand Down
112 changes: 55 additions & 57 deletions gateway/api/connections/connections.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package apiconnections

import (
"database/sql"
"net/http"

"github.com/getsentry/sentry-go"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/hoophq/hoop/common/log"
"github.com/hoophq/hoop/gateway/api/openapi"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/pgrest"
pgconnections "github.com/hoophq/hoop/gateway/pgrest/connections"
pgplugins "github.com/hoophq/hoop/gateway/pgrest/plugins"
"github.com/hoophq/hoop/gateway/storagev2"
"github.com/hoophq/hoop/gateway/storagev2/types"
Expand Down Expand Up @@ -45,7 +46,7 @@ func Post(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
return
}
existingConn, err := pgconnections.New().FetchOneByNameOrID(ctx, req.Name)
existingConn, err := models.GetConnectionByNameOrID(ctx.OrgID, req.Name)
if err != nil {
log.Errorf("failed fetching existing connection, err=%v", err)
sentry.CaptureException(err)
Expand All @@ -65,22 +66,23 @@ func Post(c *gin.Context) {
req.Status = pgrest.ConnectionStatusOnline
}

err = pgconnections.New().Upsert(ctx, pgrest.Connection{
err = models.UpsertConnection(&models.Connection{
ID: req.ID,
OrgID: ctx.OrgID,
AgentID: req.AgentId,
AgentID: sql.NullString{String: req.AgentId, Valid: true},
Name: req.Name,
Command: req.Command,
Type: string(req.Type),
SubType: req.SubType,
SubType: sql.NullString{String: req.SubType, Valid: true},
Envs: coerceToMapString(req.Secrets),
Status: req.Status,
ManagedBy: nil,
ManagedBy: sql.NullString{},
Tags: req.Tags,
AccessModeRunbooks: req.AccessModeRunbooks,
AccessModeExec: req.AccessModeExec,
AccessModeConnect: req.AccessModeConnect,
AccessSchema: req.AccessSchema,
GuardRailRules: req.GuardRailRules,
})
if err != nil {
log.Errorf("failed creating connection, err=%v", err)
Expand Down Expand Up @@ -125,7 +127,7 @@ func Post(c *gin.Context) {
func Put(c *gin.Context) {
ctx := storagev2.ParseContext(c)
connNameOrID := c.Param("nameOrID")
conn, err := pgconnections.New().FetchOneByNameOrID(ctx, connNameOrID)
conn, err := models.GetConnectionByNameOrID(ctx.OrgID, connNameOrID)
if err != nil {
log.Errorf("failed fetching connection, err=%v", err)
sentry.CaptureException(err)
Expand All @@ -137,7 +139,7 @@ func Put(c *gin.Context) {
return
}
// when the connection is managed by the agent, make sure to deny any change
if conn.ManagedBy != nil {
if conn.ManagedBy.String != "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "unable to update a connection managed by its agent"})
return
}
Expand All @@ -157,27 +159,33 @@ func Put(c *gin.Context) {
req.ID = conn.ID
req.Name = conn.Name
req.Status = conn.Status
err = pgconnections.New().Upsert(ctx, pgrest.Connection{
err = models.UpsertConnection(&models.Connection{
ID: conn.ID,
OrgID: conn.OrgID,
AgentID: req.AgentId,
AgentID: sql.NullString{String: req.AgentId, Valid: true},
Name: conn.Name,
Command: req.Command,
Type: req.Type,
SubType: req.SubType,
SubType: sql.NullString{String: req.SubType, Valid: true},
Envs: coerceToMapString(req.Secrets),
Status: conn.Status,
ManagedBy: nil,
ManagedBy: sql.NullString{},
Tags: req.Tags,
AccessModeRunbooks: req.AccessModeRunbooks,
AccessModeExec: req.AccessModeExec,
AccessModeConnect: req.AccessModeConnect,
AccessSchema: req.AccessSchema,
GuardRailRules: req.GuardRailRules,
})
if err != nil {
log.Errorf("failed updating connection, err=%v", err)
sentry.CaptureException(err)
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
switch err.(type) {
case *models.ErrNotFoundGuardRailRules:
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
default:
log.Errorf("failed updating connection, err=%v", err)
sentry.CaptureException(err)
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
return
}
connectionrequests.InvalidateSyncCache(ctx.OrgID, conn.Name)
Expand Down Expand Up @@ -219,7 +227,7 @@ func Delete(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": "missing connection name"})
return
}
err := pgconnections.New().Delete(ctx, connName)
err := models.DeleteConnection(ctx.OrgID, connName)
switch err {
case pgrest.ErrNotFound:
c.JSON(http.StatusNotFound, gin.H{"message": "not found"})
Expand Down Expand Up @@ -249,17 +257,13 @@ func Delete(c *gin.Context) {
// @Router /connections [get]
func List(c *gin.Context) {
ctx := storagev2.ParseContext(c)
var opts []*pgconnections.ConnectionOption
for key, values := range c.Request.URL.Query() {
opts = append(opts, pgconnections.WithOption(key, values[0]))
}
connList, err := pgconnections.New().FetchAll(ctx, opts...)
switch err {
case pgconnections.ErrInvalidOptionVal:
filterOpts, err := validateListOptions(c.Request.URL.Query())
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error()})
return
case nil:
default:
}
connList, err := models.ListConnections(ctx.OrgID, filterOpts)
if err != nil {
sentry.CaptureException(err)
c.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
return
Expand All @@ -274,33 +278,29 @@ func List(c *gin.Context) {
responseConnList := []openapi.Connection{}
for _, conn := range connList {
if allowedFn(conn.Name) {
reviewers, redactTypes := []string{}, []string{}
for _, pluginConn := range conn.PluginConnection {
switch pluginConn.Plugin.Name {
case plugintypes.PluginReviewName:
reviewers = pluginConn.ConnectionConfig
case plugintypes.PluginDLPName:
redactTypes = pluginConn.ConnectionConfig
}
var managedBy *string
if conn.ManagedBy.Valid {
managedBy = &conn.ManagedBy.String
}
responseConnList = append(responseConnList, openapi.Connection{
ID: conn.ID,
Name: conn.Name,
Command: conn.Command,
Type: conn.Type,
SubType: conn.SubType,
SubType: conn.SubType.String,
Secrets: coerceToAnyMap(conn.Envs),
AgentId: conn.AgentID,
AgentId: conn.AgentID.String,
Status: conn.Status,
Reviewers: reviewers,
RedactEnabled: len(redactTypes) > 0,
RedactTypes: redactTypes,
ManagedBy: conn.ManagedBy,
Reviewers: conn.Reviewers,
RedactEnabled: conn.RedactEnabled,
RedactTypes: conn.RedactTypes,
ManagedBy: managedBy,
Tags: conn.Tags,
AccessModeRunbooks: conn.AccessModeRunbooks,
AccessModeExec: conn.AccessModeExec,
AccessModeConnect: conn.AccessModeConnect,
AccessSchema: conn.AccessSchema,
GuardRailRules: conn.GuardRailRules,
})
}

Expand All @@ -320,7 +320,8 @@ func List(c *gin.Context) {
// @Router /connections/{nameOrID} [get]
func Get(c *gin.Context) {
ctx := storagev2.ParseContext(c)
conn, err := pgconnections.New().FetchOneByNameOrID(ctx, c.Param("nameOrID"))
conn, err := models.GetConnectionByNameOrID(ctx.OrgID, c.Param("nameOrID"))
// conn, err := pgconnections.New().FetchOneByNameOrID(ctx, c.Param("nameOrID"))
if err != nil {
log.Errorf("failed fetching connection, err=%v", err)
sentry.CaptureException(err)
Expand All @@ -338,40 +339,37 @@ func Get(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"message": "not found"})
return
}
reviewers, redactTypes := []string{}, []string{}
// var redactTypes []string
for _, pluginConn := range conn.PluginConnection {
switch pluginConn.Plugin.Name {
case plugintypes.PluginReviewName:
reviewers = pluginConn.ConnectionConfig
case plugintypes.PluginDLPName:
redactTypes = pluginConn.ConnectionConfig
}

var managedBy *string
if conn.ManagedBy.Valid {
managedBy = &conn.ManagedBy.String
}
c.JSON(http.StatusOK, openapi.Connection{
ID: conn.ID,
Name: conn.Name,
Command: conn.Command,
Type: conn.Type,
SubType: conn.SubType,
SubType: conn.SubType.String,
Secrets: coerceToAnyMap(conn.Envs),
AgentId: conn.AgentID,
AgentId: conn.AgentID.String,
Status: conn.Status,
Reviewers: reviewers,
RedactEnabled: len(redactTypes) > 0,
RedactTypes: redactTypes,
ManagedBy: conn.ManagedBy,
Reviewers: conn.Reviewers,
RedactEnabled: conn.RedactEnabled,
RedactTypes: conn.RedactTypes,
ManagedBy: managedBy,
Tags: conn.Tags,
AccessModeRunbooks: conn.AccessModeRunbooks,
AccessModeExec: conn.AccessModeExec,
AccessModeConnect: conn.AccessModeConnect,
AccessSchema: conn.AccessSchema,
GuardRailRules: conn.GuardRailRules,
})
}

// FetchByName fetches a connection based in access control rules
func FetchByName(ctx pgrest.Context, connectionName string) (*pgrest.Connection, error) {
conn, err := pgconnections.New().FetchOneByNameOrID(ctx, connectionName)
func FetchByName(ctx pgrest.Context, connectionName string) (*models.Connection, error) {
// conn, err := pgconnections.New().FetchOneByNameOrID(ctx, connectionName)
conn, err := models.GetConnectionByNameOrID(ctx.GetOrgID(), connectionName)
if err != nil {
return nil, err
}
Expand Down
40 changes: 40 additions & 0 deletions gateway/api/connections/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package apiconnections

import (
"encoding/base64"
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"

pb "github.com/hoophq/hoop/common/proto"
"github.com/hoophq/hoop/gateway/api/openapi"
apivalidation "github.com/hoophq/hoop/gateway/api/validation"
"github.com/hoophq/hoop/gateway/models"
"github.com/hoophq/hoop/gateway/pgrest"
pgplugins "github.com/hoophq/hoop/gateway/pgrest/plugins"
plugintypes "github.com/hoophq/hoop/gateway/transport/plugins/types"
Expand Down Expand Up @@ -112,3 +115,40 @@ func validateConnectionRequest(req openapi.Connection) error {
}
return nil
}

var reSanitize, _ = regexp.Compile(`^[a-zA-Z0-9_]+(?:[-\.]?[a-zA-Z0-9_]+){1,128}$`)
var errInvalidOptionVal = errors.New("option values must contain between 1 and 127 alphanumeric characters, it may include (-), (_) or (.) characters")

func validateListOptions(urlValues url.Values) (o models.ConnectionFilterOption, err error) {
if reSanitize == nil {
return o, fmt.Errorf("failed compiling sanitize regex on listing connections")
}
for key, values := range urlValues {
switch key {
case "agent_id":
o.AgentID = values[0]
case "type":
o.Type = values[0]
case "subtype":
o.SubType = values[0]
case "managed_by":
o.ManagedBy = values[0]
case "tags":
if len(values[0]) > 0 {
for _, tagVal := range strings.Split(values[0], ",") {
if !reSanitize.MatchString(tagVal) {
return o, errInvalidOptionVal
}
o.Tags = append(o.Tags, tagVal)
}
}
continue
default:
continue
}
if !reSanitize.MatchString(values[0]) {
return o, errInvalidOptionVal
}
}
return
}
Loading

0 comments on commit 333cab1

Please sign in to comment.