Skip to content
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
456 changes: 456 additions & 0 deletions internal/api/handler/agents.go

Large diffs are not rendered by default.

216 changes: 216 additions & 0 deletions internal/api/handler/agents_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//go:build integration

package handler

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/compliance-framework/api/internal/api"
"github.com/compliance-framework/api/internal/service/relational"
"github.com/compliance-framework/api/internal/tests"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"
)

type AgentAPIIntegrationSuite struct {
tests.IntegrationTestSuite
server *api.Server
}

func TestAgentAPI(t *testing.T) {
suite.Run(t, new(AgentAPIIntegrationSuite))
}

func (suite *AgentAPIIntegrationSuite) SetupTest() {
err := suite.Migrator.Refresh()
suite.Require().NoError(err)

logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
RegisterHandlers(suite.server, logger.Sugar(), suite.DB, suite.Config, &APIServices{})
}

func (suite *AgentAPIIntegrationSuite) authedRequest(method, path string, body any) (*httptest.ResponseRecorder, *http.Request) {
token, err := suite.GetAuthToken()
suite.Require().NoError(err)

payload := []byte{}
if body != nil {
data, marshalErr := json.Marshal(body)
suite.Require().NoError(marshalErr)
payload = data
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token))
return rec, req
}

func (suite *AgentAPIIntegrationSuite) TestAgentCRUDAndKeys() {
createRec, createReq := suite.authedRequest(http.MethodPost, "/api/admin/agents", map[string]any{
"name": "agent-one",
"description": "integration agent",
})
suite.server.E().ServeHTTP(createRec, createReq)
require.Equal(suite.T(), http.StatusCreated, createRec.Code)

var created GenericDataResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created))
require.Equal(suite.T(), "agent-one", created.Data.Name)
require.Equal(suite.T(), int64(0), created.Data.ServiceAccountKeys)

listRec, listReq := suite.authedRequest(http.MethodGet, "/api/admin/agents", nil)
suite.server.E().ServeHTTP(listRec, listReq)
require.Equal(suite.T(), http.StatusOK, listRec.Code)

var listed GenericDataListResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(listRec.Body.Bytes(), &listed))
require.Len(suite.T(), listed.Data, 1)

getRec, getReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/admin/agents/%s", created.Data.ID), nil)
suite.server.E().ServeHTTP(getRec, getReq)
require.Equal(suite.T(), http.StatusOK, getRec.Code)

updateRec, updateReq := suite.authedRequest(http.MethodPut, fmt.Sprintf("/api/admin/agents/%s", created.Data.ID), map[string]any{
"name": "agent-one-updated",
"is-active": false,
})
suite.server.E().ServeHTTP(updateRec, updateReq)
require.Equal(suite.T(), http.StatusOK, updateRec.Code)

var updated GenericDataResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(updateRec.Body.Bytes(), &updated))
require.Equal(suite.T(), "agent-one-updated", updated.Data.Name)
require.False(suite.T(), updated.Data.IsActive)

keyCreateRec, keyCreateReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/admin/agents/%s/keys", created.Data.ID), map[string]any{
"name": "primary",
"never-expires": true,
})
suite.server.E().ServeHTTP(keyCreateRec, keyCreateReq)
require.Equal(suite.T(), http.StatusCreated, keyCreateRec.Code)

var keyCreated GenericDataResponse[agentKeyCreateResponse]
require.NoError(suite.T(), json.Unmarshal(keyCreateRec.Body.Bytes(), &keyCreated))
require.NotEmpty(suite.T(), keyCreated.Data.ClientID)
require.NotEmpty(suite.T(), keyCreated.Data.ClientSecret)
require.True(suite.T(), keyCreated.Data.NeverExpires)

keyListRec, keyListReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/admin/agents/%s/keys", created.Data.ID), nil)
suite.server.E().ServeHTTP(keyListRec, keyListReq)
require.Equal(suite.T(), http.StatusOK, keyListRec.Code)

var keyList GenericDataListResponse[agentKeyResponse]
require.NoError(suite.T(), json.Unmarshal(keyListRec.Body.Bytes(), &keyList))
require.Len(suite.T(), keyList.Data, 1)
require.Equal(suite.T(), keyCreated.Data.ClientID, keyList.Data[0].ClientID)
require.True(suite.T(), keyList.Data[0].NeverExpires)

keyGetRec, keyGetReq := suite.authedRequest(http.MethodGet, fmt.Sprintf("/api/admin/agents/%s/keys/%s", created.Data.ID, keyCreated.Data.ID), nil)
suite.server.E().ServeHTTP(keyGetRec, keyGetReq)
require.Equal(suite.T(), http.StatusOK, keyGetRec.Code)

keyDeleteRec, keyDeleteReq := suite.authedRequest(http.MethodDelete, fmt.Sprintf("/api/admin/agents/%s/keys/%s", created.Data.ID, keyCreated.Data.ID), nil)
suite.server.E().ServeHTTP(keyDeleteRec, keyDeleteReq)
require.Equal(suite.T(), http.StatusNoContent, keyDeleteRec.Code)

deleteRec, deleteReq := suite.authedRequest(http.MethodDelete, fmt.Sprintf("/api/admin/agents/%s", created.Data.ID), nil)
suite.server.E().ServeHTTP(deleteRec, deleteReq)
require.Equal(suite.T(), http.StatusNoContent, deleteRec.Code)
}

func (suite *AgentAPIIntegrationSuite) TestCreateAgentKeyWithExpiry() {
err := suite.Migrator.Refresh()
suite.Require().NoError(err)

createRec, createReq := suite.authedRequest(http.MethodPost, "/api/admin/agents", map[string]any{
"name": "agent-two",
})
suite.server.E().ServeHTTP(createRec, createReq)
require.Equal(suite.T(), http.StatusCreated, createRec.Code)

var created GenericDataResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created))

expiresAt := time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339)
keyCreateRec, keyCreateReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/admin/agents/%s/keys", created.Data.ID), map[string]any{
"name": "expiring",
"expires-at": expiresAt,
})
suite.server.E().ServeHTTP(keyCreateRec, keyCreateReq)
require.Equal(suite.T(), http.StatusCreated, keyCreateRec.Code)

var keyCreated GenericDataResponse[agentKeyCreateResponse]
require.NoError(suite.T(), json.Unmarshal(keyCreateRec.Body.Bytes(), &keyCreated))
require.False(suite.T(), keyCreated.Data.NeverExpires)
require.NotNil(suite.T(), keyCreated.Data.ExpiresAt)
}

func (suite *AgentAPIIntegrationSuite) TestCreateAgentKeyRequiresExplicitExpiryDecision() {
err := suite.Migrator.Refresh()
suite.Require().NoError(err)

createRec, createReq := suite.authedRequest(http.MethodPost, "/api/admin/agents", map[string]any{
"name": "agent-three",
})
suite.server.E().ServeHTTP(createRec, createReq)
require.Equal(suite.T(), http.StatusCreated, createRec.Code)

var created GenericDataResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created))

keyCreateRec, keyCreateReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/admin/agents/%s/keys", created.Data.ID), map[string]any{
"name": "missing-expiry-choice",
})
suite.server.E().ServeHTTP(keyCreateRec, keyCreateReq)
require.Equal(suite.T(), http.StatusBadRequest, keyCreateRec.Code)
require.Contains(suite.T(), keyCreateRec.Body.String(), "expires-at is required unless never-expires is true")
}

func (suite *AgentAPIIntegrationSuite) TestDeleteAgentRevokesKeysAndDeactivatesAgent() {
createRec, createReq := suite.authedRequest(http.MethodPost, "/api/admin/agents", map[string]any{
"name": "agent-delete-test",
})
suite.server.E().ServeHTTP(createRec, createReq)
require.Equal(suite.T(), http.StatusCreated, createRec.Code)

var created GenericDataResponse[agentResponse]
require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created))

keyCreateRec, keyCreateReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/admin/agents/%s/keys", created.Data.ID), map[string]any{
"name": "primary",
"never-expires": true,
})
suite.server.E().ServeHTTP(keyCreateRec, keyCreateReq)
require.Equal(suite.T(), http.StatusCreated, keyCreateRec.Code)

var keyCreated GenericDataResponse[agentKeyCreateResponse]
require.NoError(suite.T(), json.Unmarshal(keyCreateRec.Body.Bytes(), &keyCreated))

deleteRec, deleteReq := suite.authedRequest(http.MethodDelete, fmt.Sprintf("/api/admin/agents/%s", created.Data.ID), nil)
suite.server.E().ServeHTTP(deleteRec, deleteReq)
require.Equal(suite.T(), http.StatusNoContent, deleteRec.Code)

var agent relational.Agent
err := suite.DB.Unscoped().First(&agent, "id = ?", created.Data.ID).Error
require.NoError(suite.T(), err)
require.False(suite.T(), agent.IsActive)
require.NotNil(suite.T(), agent.DeletedAt)
require.True(suite.T(), agent.DeletedAt.Valid)

var key relational.AgentServiceAccountKey
err = suite.DB.First(&key, "id = ?", keyCreated.Data.ID).Error
require.NoError(suite.T(), err)
require.NotNil(suite.T(), key.RevokedAt)
}
21 changes: 15 additions & 6 deletions internal/api/handler/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,15 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
filterHandler.Register(server.API().Group("/filters"))

heartbeatHandler := NewHeartbeatHandler(logger, db)
heartbeatHandler.Register(server.API().Group("/agent/heartbeat"))
agentIngestMiddleware := middleware.AgentJWTOrPublicMiddleware(db, config.JWTPublicKey, !config.StrictDisablePublicAgentEndpoints)
heartbeatHandler.RegisterCreate(server.API().Group("/agent/heartbeat"), agentIngestMiddleware)
// Keep the legacy operator-facing metrics route stable while protecting it with user auth.
heartbeatHandler.RegisterOverTime(server.API().Group("/agent/heartbeat"), middleware.JWTMiddleware(config.JWTPublicKey))
Comment thread
gusfcarvalho marked this conversation as resolved.

evidenceHandler := NewEvidenceHandler(logger, services.EvidenceService)
evidenceHandler.Register(server.API().Group("/evidence"))
evidenceGroup := server.API().Group("/evidence")
evidenceHandler.RegisterCreate(evidenceGroup, agentIngestMiddleware)
evidenceHandler.RegisterReadRoutes(evidenceGroup)

poamService := poamsvc.NewPoamService(db)
riskService := riskrel.NewRiskService(db)
Expand Down Expand Up @@ -78,8 +83,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
riskTemplateHandler.Register(riskTemplateGroup)

agentRiskTemplateGroup := server.API().Group("/agent/risk-templates")
agentRiskTemplateGroup.Use(middleware.AgentJWTMiddleware(config.JWTPublicKey))
riskTemplateHandler.RegisterAgent(agentRiskTemplateGroup)
riskTemplateHandler.RegisterAgent(agentRiskTemplateGroup, agentIngestMiddleware)

subjectTemplateHandler := templatehandlers.NewSubjectTemplateHandler(logger, db)
subjectTemplateGroup := server.API().Group("/admin/subject-templates")
Expand All @@ -88,8 +92,13 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
subjectTemplateHandler.Register(subjectTemplateGroup)

agentSubjectTemplateGroup := server.API().Group("/agent/subject-templates")
agentSubjectTemplateGroup.Use(middleware.AgentJWTMiddleware(config.JWTPublicKey))
subjectTemplateHandler.RegisterAgent(agentSubjectTemplateGroup)
subjectTemplateHandler.RegisterAgent(agentSubjectTemplateGroup, agentIngestMiddleware)

agentHandler := NewAgentHandler(logger, db)
agentsGroup := server.API().Group("/admin/agents")
agentsGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey))
agentsGroup.Use(middleware.RequireAdminGroups(db, config, logger))
agentHandler.Register(agentsGroup)

userHandler := NewUserHandler(logger, db)

Expand Down
Loading
Loading