Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create agent api #475

Merged
merged 15 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
yashmehrotra marked this conversation as resolved.
Show resolved Hide resolved
}
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 @@ -141,12 +143,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 @@ -240,7 +244,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,
moshloop marked this conversation as resolved.
Show resolved Hide resolved
}

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