Skip to content

Commit

Permalink
feat: create agent api (#475)
Browse files Browse the repository at this point in the history
* draft: agent create api

[skip ci]

* chore: rbac integration and validation of access token

[skip ci]

* chore: remove agent/controllers_test.go

[skip ci]

* impl: bcrypt

[skip ci]

* refactor: separate generateAgent func

[skip ci]

* chore: bump duty

* chore: address review comment

* feat: use argon2 instead of bcrypt

argon2 allows us to supply the salt whereas bcrypt doesn't.

* fix: uint parsing

* Removed rand & hash utils because it's in commons

* chore: use base64.URLEncoding

* feat: access token cache and better errors for the user

* chore: lint fix

* chore: bump commons and removed some utils that are in commons

* chore: create and save a dummy email for agent person

* feat: only allow admins to generate new agents
  • Loading branch information
adityathebe authored Aug 21, 2023
1 parent 7df19bf commit a9f7d84
Show file tree
Hide file tree
Showing 18 changed files with 342 additions and 237 deletions.
58 changes: 58 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package agent

import (
"fmt"
"time"

"github.com/flanksource/commons/rand"
"github.com/flanksource/incident-commander/api"
"github.com/flanksource/incident-commander/db"
"github.com/flanksource/incident-commander/rbac"
)

// generateAgent creates a new person and a new agent and associates them.
func generateAgent(ctx *api.Context, body api.GenerateAgentRequest) (*api.GeneratedAgent, error) {
username, password, err := genUsernamePassword()
if err != nil {
return nil, fmt.Errorf("failed to generate username and password: %w", err)
}

person, err := db.CreatePerson(ctx, username, fmt.Sprintf("%s@local", username), "agent")
if err != nil {
return nil, fmt.Errorf("failed to create a new person: %w", err)
}

token, err := db.CreateAccessToken(ctx, person.ID, "default", password, time.Hour*24*365)
if err != nil {
return nil, fmt.Errorf("failed to create a new access token: %w", err)
}

if _, err := rbac.Enforcer.AddRoleForUser(person.ID.String(), "agent"); err != nil {
return nil, fmt.Errorf("failed to add 'agent' role to the new person: %w", err)
}

if err := db.CreateAgent(ctx, body.Name, &person.ID, body.Properties); err != nil {
return nil, fmt.Errorf("failed to create a new agent: %w", err)
}

return &api.GeneratedAgent{
ID: person.ID.String(),
Username: username,
AccessToken: token,
}, nil
}

// genUsernamePassword generates a random pair of username and password
func genUsernamePassword() (username, password string, err error) {
username, err = rand.GenerateRandHex(8)
if err != nil {
return "", "", err
}

password, err = rand.GenerateRandHex(32)
if err != nil {
return "", "", err
}

return fmt.Sprintf("agent-%s", username), password, nil
}
28 changes: 28 additions & 0 deletions agent/controllers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package agent

import (
"encoding/json"
"net/http"

"github.com/flanksource/commons/logger"
"github.com/flanksource/incident-commander/api"
"github.com/labstack/echo/v4"
)

// GenerateAgent creates a new person and a new agent and associates them.
func GenerateAgent(c echo.Context) error {
ctx := c.(*api.Context)

var body api.GenerateAgentRequest
if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return c.JSON(http.StatusBadRequest, api.HTTPError{Error: err.Error()})
}

agent, err := generateAgent(ctx, body)
if err != nil {
logger.Errorf("failed to generate a new agent: %v", err)
return c.JSON(http.StatusInternalServerError, api.HTTPError{Error: err.Error(), Message: "error generating agent"})
}

return c.JSON(http.StatusCreated, agent)
}
12 changes: 12 additions & 0 deletions api/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package api

type GenerateAgentRequest struct {
Name string
Properties map[string]string
}

type GeneratedAgent struct {
ID string `json:"id"`
Username string `json:"username"`
AccessToken string `json:"access_token"`
}
5 changes: 4 additions & 1 deletion auth/kratos_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ import (

"github.com/flanksource/incident-commander/db"
client "github.com/ory/client-go"
"gorm.io/gorm"
)

type KratosHandler struct {
client *client.APIClient
adminClient *client.APIClient
jwtSecret string
db *gorm.DB
}

func NewKratosHandler(kratosAPI, kratosAdminAPI, jwtSecret string) *KratosHandler {
func NewKratosHandler(db *gorm.DB, kratosAPI, kratosAdminAPI, jwtSecret string) *KratosHandler {
return &KratosHandler{
db: db,
client: newAPIClient(kratosAPI),
adminClient: newAdminAPIClient(kratosAdminAPI),
jwtSecret: jwtSecret,
Expand Down
102 changes: 99 additions & 3 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,61 @@ package auth

import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/flanksource/commons/collections"
"github.com/flanksource/commons/hash"
"github.com/flanksource/commons/logger"
"github.com/flanksource/incident-commander/utils"
"github.com/flanksource/commons/rand"
"github.com/flanksource/commons/utils"
"github.com/flanksource/duty/models"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
client "github.com/ory/client-go"
"github.com/patrickmn/go-cache"
"golang.org/x/crypto/argon2"
"gorm.io/gorm"
)

const (
DefaultPostgrestRole = "postgrest_api"
UserIDHeaderKey = "X-User-ID"
)

var (
errInvalidTokenFormat = errors.New("invalid access token format")
errTokenExpired = errors.New("access token has expired")
)

type kratosMiddleware struct {
client *client.APIClient
jwtSecret string
tokenCache *cache.Cache
accessTokenCache *cache.Cache
authSessionCache *cache.Cache
basicAuthSeparator string
db *gorm.DB
}

func (k *KratosHandler) KratosMiddleware() (*kratosMiddleware, error) {
randString, err := utils.GenerateRandString(30)
randString, err := rand.GenerateRandString(30)
if err != nil {
return nil, fmt.Errorf("failed to generate random string: %w", err)
}

return &kratosMiddleware{
client: k.client,
db: k.db,
jwtSecret: k.jwtSecret,
tokenCache: cache.New(3*24*time.Hour, 12*time.Hour),
accessTokenCache: cache.New(3*24*time.Hour, 24*time.Hour),
authSessionCache: cache.New(30*time.Minute, time.Hour),
basicAuthSeparator: randString,
}, nil
Expand All @@ -58,8 +75,15 @@ func (k *kratosMiddleware) Session(next echo.HandlerFunc) echo.HandlerFunc {
}
session, err := k.validateSession(c.Request())
if err != nil {
if errors.Is(err, errInvalidTokenFormat) {
return c.String(http.StatusBadRequest, "invalid access token")
} else if errors.Is(err, errTokenExpired) {
return c.String(http.StatusUnauthorized, "access token has expired")
}

return c.String(http.StatusUnauthorized, "Unauthorized")
}

if !*session.Active {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
Expand All @@ -76,6 +100,54 @@ func (k *kratosMiddleware) Session(next echo.HandlerFunc) echo.HandlerFunc {
}
}

func (k *kratosMiddleware) getAccessToken(ctx context.Context, token string) (*models.AccessToken, error) {
if token, ok := k.accessTokenCache.Get(token); ok {
return token.(*models.AccessToken), nil
}

fields := strings.Split(token, ".")
if len(fields) != 5 {
return nil, errInvalidTokenFormat
}

var (
password = fields[0]
salt = fields[1]
)

timeCost, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return nil, errInvalidTokenFormat
}

memoryCost, err := strconv.ParseUint(fields[3], 10, 32)
if err != nil {
return nil, errInvalidTokenFormat
}

parallelism, err := strconv.ParseUint(fields[4], 10, 8)
if err != nil {
return nil, errInvalidTokenFormat
}

hash := argon2.IDKey([]byte(password), []byte(salt), uint32(timeCost), uint32(memoryCost), uint8(parallelism), 20)
encodedHash := base64.URLEncoding.EncodeToString(hash)

query := `SELECT access_tokens.* FROM access_tokens WHERE value = ?`
var acessToken models.AccessToken
if err := k.db.Raw(query, encodedHash).First(&acessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}

return nil, err
}

k.accessTokenCache.Set(token, &acessToken, time.Until(acessToken.ExpiresAt))

return &acessToken, nil
}

func (k *kratosMiddleware) validateSession(r *http.Request) (*client.Session, error) {
// Skip all kratos calls
if strings.HasPrefix(r.URL.Path, "/kratos") {
Expand All @@ -84,6 +156,30 @@ func (k *kratosMiddleware) validateSession(r *http.Request) (*client.Session, er
}

if username, password, ok := r.BasicAuth(); ok {
if username == "TOKEN" {
accessToken, err := k.getAccessToken(r.Context(), password)
if err != nil {
return nil, err
} else if accessToken == nil {
return &client.Session{Active: utils.Ptr(false)}, nil
}

if accessToken.ExpiresAt.Before(time.Now()) {
return nil, errTokenExpired
}

s := &client.Session{
Id: uuid.NewString(),
Active: utils.Ptr(true),
ExpiresAt: &accessToken.ExpiresAt,
Identity: client.Identity{
Id: accessToken.PersonID.String(),
},
}

return s, nil
}

sess, err := k.kratosLoginWithCache(r.Context(), username, password)
if err != nil {
return nil, fmt.Errorf("failed to login: %w", err)
Expand Down Expand Up @@ -187,5 +283,5 @@ func (k *kratosMiddleware) getDBToken(sessionID, userID string) (string, error)
}

func basicAuthCacheKey(username, separator, password string) string {
return utils.Sha256Hex(fmt.Sprintf("%s:%s:%s", username, separator, password))
return hash.Sha256Hex(fmt.Sprintf("%s:%s:%s", username, separator, password))
}
10 changes: 7 additions & 3 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/flanksource/commons/logger"
cutils "github.com/flanksource/commons/utils"
"github.com/flanksource/duty/schema/openapi"
"github.com/flanksource/kopper"
"github.com/labstack/echo-contrib/echoprometheus"
Expand All @@ -18,6 +19,7 @@ import (
ctrl "sigs.k8s.io/controller-runtime"

"github.com/flanksource/duty/models"
"github.com/flanksource/incident-commander/agent"
"github.com/flanksource/incident-commander/api"
v1 "github.com/flanksource/incident-commander/api/v1"
"github.com/flanksource/incident-commander/auth"
Expand Down Expand Up @@ -68,7 +70,7 @@ func createHTTPServer(gormDB *gorm.DB) *echo.Echo {

switch authMode {
case "kratos":
kratosHandler := auth.NewKratosHandler(kratosAPI, kratosAdminAPI, db.PostgRESTJWTSecret)
kratosHandler := auth.NewKratosHandler(gormDB, kratosAPI, kratosAdminAPI, db.PostgRESTJWTSecret)
adminUserID, err = kratosHandler.CreateAdminUser(context.Background())
if err != nil {
logger.Fatalf("Failed to created admin user: %v", err)
Expand Down Expand Up @@ -142,12 +144,14 @@ func createHTTPServer(gormDB *gorm.DB) *echo.Echo {
if api.UpstreamConf.IsPartiallyFilled() {
logger.Warnf("Please ensure that all the required flags for upstream is supplied.")
}
upstreamGroup := e.Group("/upstream")
upstreamGroup := e.Group("/upstream", rbac.Authorization(rbac.ObjectAgentPush, rbac.ActionWrite))
upstreamGroup.POST("/push", upstream.PushUpstream)
upstreamGroup.GET("/pull/:agent_name", upstream.Pull)
upstreamGroup.GET("/canary/pull/:agent_name", canary.Pull)
upstreamGroup.GET("/status/:agent_name", upstream.Status)

e.POST("/agent/generate", agent.GenerateAgent, rbac.Authorization(rbac.ObjectAgentCreate, rbac.ActionWrite))

forward(e, "/config", configDb)
forward(e, "/canary", api.CanaryCheckerPath)
forward(e, "/kratos", kratosAPI)
Expand Down Expand Up @@ -241,7 +245,7 @@ func ModifyKratosRequestHeaders(next echo.HandlerFunc) echo.HandlerFunc {
if strings.HasPrefix(c.Request().URL.Path, "/kratos") {
// Kratos requires the header X-Forwarded-Proto but Nginx sets it as "https,http"
// This leads to URL malformation further upstream
val := utils.Coalesce(
val := cutils.Coalesce(
c.Request().Header.Get("X-Forwarded-Scheme"),
c.Request().Header.Get("X-Scheme"),
"https",
Expand Down
14 changes: 14 additions & 0 deletions db/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"errors"
"fmt"

"github.com/flanksource/commons/collections"
"github.com/flanksource/duty/models"
"github.com/flanksource/incident-commander/api"
"github.com/google/uuid"
"gorm.io/gorm"
)

Expand Down Expand Up @@ -50,3 +52,15 @@ func GetOrCreateAgent(ctx *api.Context, name string) (*models.Agent, error) {

return a, nil
}

func CreateAgent(ctx *api.Context, name string, personID *uuid.UUID, properties map[string]string) error {
properties = collections.MergeMap(properties, map[string]string{"type": "agent"})

a := models.Agent{
Name: name,
PersonID: personID,
Properties: properties,
}

return ctx.DB().Create(&a).Error
}
Loading

0 comments on commit a9f7d84

Please sign in to comment.