Resources segregation by tenant #20

Merged
wpetit merged 7 commits from tenant into master 2024-02-29 15:33:30 +01:00
16 changed files with 916 additions and 711 deletions
Showing only changes of commit 15a0bf6ecc - Show all commits

View File

@ -48,7 +48,7 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI
now := time.Now().UTC()
query = `
UPDATE agents SET tenant_id = $1, updated_at = $2
UPDATE agents SET tenant_id = $1, updated_at = $2 WHERE id = $3
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
`
@ -56,6 +56,7 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI
ctx, query,
tenantID,
now,
agentID,
)
metadata := JSONMap{}
@ -85,8 +86,47 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI
}
// Detach implements datastore.AgentRepository.
func (*AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) {
panic("unimplemented")
func (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) {
var agent datastore.Agent
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
UPDATE agents SET tenant_id = null, updated_at = $1 WHERE id = $2
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
`
row := tx.QueryRowContext(
ctx, query,
now,
agentID,
)
metadata := JSONMap{}
var rawKeySet []byte
err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
if err != nil {
return errors.WithStack(err)
}
agent.Metadata = metadata
keySet, err := jwk.Parse(rawKeySet)
if err != nil {
return errors.WithStack(err)
}
agent.KeySet = &datastore.SerializableKeySet{keySet}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &agent, nil
}
// DeleteSpec implements datastore.AgentRepository.

View File

@ -1,504 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnknownError api.ErrorCode = "unknown-error"
ErrCodeNotFound api.ErrorCode = "not-found"
ErrCodeInvalidSignature api.ErrorCode = "invalid-signature"
ErrCodeConflict api.ErrorCode = "conflict"
ErrCodeMultipleResults api.ErrorCode = "multiple-results"
ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed"
)
type registerAgentRequest struct {
KeySet json.RawMessage `json:"keySet" validate:"required"`
Metadata []metadata.Tuple `json:"metadata" validate:"required"`
Thumbprint string `json:"thumbprint" validate:"required"`
Signature string `json:"signature" validate:"required"`
}
func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) {
registerAgentReq := &registerAgentRequest{}
if ok := api.Bind(w, r, registerAgentReq); !ok {
return
}
ctx := r.Context()
keySet, err := jwk.Parse(registerAgentReq.KeySet)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not parse key set", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint))
// Validate that the existing signature validates the request
validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if !validSignature {
logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
metadata := metadata.FromSorted(registerAgentReq.Metadata)
agent, err := s.agentRepo.Create(
ctx,
registerAgentReq.Thumbprint,
keySet,
metadata,
)
if err != nil {
if !errors.Is(err, datastore.ErrAlreadyExist) {
err = errors.WithStack(err)
logger.Error(ctx, "could not create agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agents, _, err := s.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint),
datastore.WithAgentQueryLimit(1),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(agents) == 0 {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil)
return
}
agentID := agents[0].ID
agent, err = s.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(
ctx, "could not retrieve agent",
logger.CapturedE(err), logger.F("agentID", agentID),
)
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
agent, err = s.agentRepo.Update(
ctx,
agents[0].ID,
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
}
api.DataResponse(w, http.StatusCreated, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
type updateAgentRequest struct {
Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"`
Label *string `json:"label" validate:"omitempty"`
}
func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateAgentReq := &updateAgentRequest{}
if ok := api.Bind(w, r, updateAgentReq); !ok {
return
}
options := make([]datastore.AgentUpdateOptionFunc, 0)
if updateAgentReq.Status != nil {
options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status))
}
if updateAgentReq.Label != nil {
options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label))
}
agent, err := s.agentRepo.Update(
ctx,
datastore.AgentID(agentID),
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
func (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.AgentQueryOptionFunc{
datastore.WithAgentQueryLimit(int(limit)),
datastore.WithAgentQueryOffset(int(offset)),
datastore.WithAgentQueryTenantID(user.Tenant()),
}
ids, ok := getIntSliceValues(w, r, "ids", nil)
if !ok {
return
}
if ids != nil {
agentIDs := func(ids []int64) []datastore.AgentID {
agentIDs := make([]datastore.AgentID, 0, len(ids))
for _, id := range ids {
agentIDs = append(agentIDs, datastore.AgentID(id))
}
return agentIDs
}(ids)
options = append(options, datastore.WithAgentQueryID(agentIDs...))
}
thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil)
if !ok {
return
}
if thumbprints != nil {
options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...))
}
statuses, ok := getIntSliceValues(w, r, "statuses", nil)
if !ok {
return
}
if statuses != nil {
agentStatuses := func(statuses []int64) []datastore.AgentStatus {
agentStatuses := make([]datastore.AgentStatus, 0, len(statuses))
for _, status := range statuses {
agentStatuses = append(agentStatuses, datastore.AgentStatus(status))
}
return agentStatuses
}(statuses)
options = append(options, datastore.WithAgentQueryStatus(agentStatuses...))
}
ctx := r.Context()
agents, total, err := s.agentRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agents []*datastore.Agent `json:"agents"`
Total int `json:"total"`
}{
Agents: agents,
Total: total,
})
}
func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := s.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
err := s.agentRepo.Delete(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
AgentID datastore.AgentID `json:"agentId"`
}{
AgentID: datastore.AgentID(agentID),
})
}
func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := s.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
agent, err := s.agentRepo.Get(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
type claimAgentRequest struct {
Thumbprint string `json:"thumbprint" validate:"required"`
}
func (s *Server) claimAgent(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
claimAgentReq := &claimAgentRequest{}
if ok := api.Bind(w, r, claimAgentReq); !ok {
return
}
results, _, err := s.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not query agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(results) == 0 {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if len(results) > 1 {
logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil)
return
}
agent := results[0]
if agent.TenantID != nil {
logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID))
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil)
return
}
agent, err = s.agentRepo.Attach(ctx, user.Tenant(), agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not attach agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}
func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) {
rawAgentID := chi.URLParam(r, "agentID")
agentID, err := strconv.ParseInt(rawAgentID, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.AgentID(agentID), true
}
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return value, true
}
return defaultValue, true
}
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
values := strings.Split(rawValue, ",")
return values, true
}
return defaultValue, true
}
func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
rawValues := strings.Split(rawValue, ",")
values := make([]int64, 0, len(rawValues))
for _, rv := range rawValues {
value, err := strconv.ParseInt(rv, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false
}
values = append(values, value)
}
return values, true
}
return defaultValue, true
}

View File

@ -1,4 +1,4 @@
package server
package api
import (
"context"
@ -142,7 +142,7 @@ func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool)
return user, true
}
func (s *Server) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool {
func (m *Mount) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool {
ctx := r.Context()
user, ok := assertRequestUser(w, r)
@ -150,7 +150,7 @@ func (s *Server) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentI
return false
}
agent, err := s.agentRepo.Get(ctx, agentID)
agent, err := m.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))

View File

@ -0,0 +1,77 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type claimAgentRequest struct {
Thumbprint string `json:"thumbprint" validate:"required"`
}
func (m *Mount) claimAgent(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
ctx := r.Context()
claimAgentReq := &claimAgentRequest{}
if ok := api.Bind(w, r, claimAgentReq); !ok {
return
}
results, _, err := m.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not query agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(results) == 0 {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if len(results) > 1 {
logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil)
return
}
agent := results[0]
if agent.TenantID != nil {
logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID))
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil)
return
}
agent, err = m.agentRepo.Attach(ctx, user.Tenant(), agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not attach agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,47 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) deleteAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
err := m.agentRepo.Delete(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
AgentID datastore.AgentID `json:"agentId"`
}{
AgentID: datastore.AgentID(agentID),
})
}

View File

@ -0,0 +1,47 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
ctx := r.Context()
agent, err := m.agentRepo.Get(
ctx,
datastore.AgentID(agentID),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,85 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specs, err := m.agentRepo.GetSpecs(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not list specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}
type deleteSpecRequest struct {
Name string `json:"name"`
}
func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
deleteSpecReq := &deleteSpecRequest{}
if ok := api.Bind(w, r, deleteSpecReq); !ok {
return
}
ctx := r.Context()
err := m.agentRepo.DeleteSpec(
ctx,
agentID,
deleteSpecReq.Name,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Name string `json:"name"`
}{
Name: deleteSpecReq.Name,
})
}

View File

@ -0,0 +1,107 @@
package api
import (
"net/http"
"strconv"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnknownError api.ErrorCode = "unknown-error"
ErrCodeNotFound api.ErrorCode = "not-found"
ErrCodeInvalidSignature api.ErrorCode = "invalid-signature"
ErrCodeConflict api.ErrorCode = "conflict"
ErrCodeMultipleResults api.ErrorCode = "multiple-results"
ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed"
)
func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) {
rawAgentID := chi.URLParam(r, "agentID")
agentID, err := strconv.ParseInt(rawAgentID, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.AgentID(agentID), true
}
func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) {
rawSpecID := chi.URLParam(r, "")
specID, err := strconv.ParseInt(rawSpecID, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.SpecID(specID), true
}
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return value, true
}
return defaultValue, true
}
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
values := strings.Split(rawValue, ",")
return values, true
}
return defaultValue, true
}
func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) {
rawValue := r.URL.Query().Get(param)
if rawValue != "" {
rawValues := strings.Split(rawValue, ",")
values := make([]int64, 0, len(rawValues))
for _, rv := range rawValues {
value, err := strconv.ParseInt(rv, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false
}
values = append(values, value)
}
return values, true
}
return defaultValue, true
}

View File

@ -0,0 +1,47 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/go-chi/chi/v5"
"gitlab.com/wpetit/goweb/api"
)
type Mount struct {
agentRepo datastore.AgentRepository
authenticators []auth.Authenticator
}
func (m *Mount) Mount(r chi.Router) {
r.NotFound(m.notFound)
r.Post("/register", m.registerAgent)
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(m.authenticators...))
r.Route("/agents", func(r chi.Router) {
r.Post("/claim", m.claimAgent)
r.With(assertGlobalReadAccess).Get("/", m.queryAgents)
r.With(assertAgentReadAccess).Get("/{agentID}", m.getAgent)
r.With(assertAgentWriteAccess).Put("/{agentID}", m.updateAgent)
r.With(assertAgentWriteAccess).Delete("/{agentID}", m.deleteAgent)
r.With(assertAgentReadAccess).Get("/{agentID}/specs", m.getAgentSpecs)
r.With(assertAgentWriteAccess).Post("/{agentID}/specs", m.updateSpec)
r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", m.deleteSpec)
})
})
}
func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
}
func NewMount(agentRepo datastore.AgentRepository, authenticators ...auth.Authenticator) *Mount {
return &Mount{agentRepo, authenticators}
}

View File

@ -0,0 +1,100 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) queryAgents(w http.ResponseWriter, r *http.Request) {
user, ok := assertRequestUser(w, r)
if !ok {
return
}
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.AgentQueryOptionFunc{
datastore.WithAgentQueryLimit(int(limit)),
datastore.WithAgentQueryOffset(int(offset)),
datastore.WithAgentQueryTenantID(user.Tenant()),
}
ids, ok := getIntSliceValues(w, r, "ids", nil)
if !ok {
return
}
if ids != nil {
agentIDs := func(ids []int64) []datastore.AgentID {
agentIDs := make([]datastore.AgentID, 0, len(ids))
for _, id := range ids {
agentIDs = append(agentIDs, datastore.AgentID(id))
}
return agentIDs
}(ids)
options = append(options, datastore.WithAgentQueryID(agentIDs...))
}
thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil)
if !ok {
return
}
if thumbprints != nil {
options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...))
}
statuses, ok := getIntSliceValues(w, r, "statuses", nil)
if !ok {
return
}
if statuses != nil {
agentStatuses := func(statuses []int64) []datastore.AgentStatus {
agentStatuses := make([]datastore.AgentStatus, 0, len(statuses))
for _, status := range statuses {
agentStatuses = append(agentStatuses, datastore.AgentStatus(status))
}
return agentStatuses
}(statuses)
options = append(options, datastore.WithAgentQueryStatus(agentStatuses...))
}
ctx := r.Context()
agents, total, err := m.agentRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agents []*datastore.Agent `json:"agents"`
Total int `json:"total"`
}{
Agents: agents,
Total: total,
})
}

View File

@ -0,0 +1,151 @@
package api
import (
"encoding/json"
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type registerAgentRequest struct {
KeySet json.RawMessage `json:"keySet" validate:"required"`
Metadata []metadata.Tuple `json:"metadata" validate:"required"`
Thumbprint string `json:"thumbprint" validate:"required"`
Signature string `json:"signature" validate:"required"`
}
func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) {
registerAgentReq := &registerAgentRequest{}
if ok := api.Bind(w, r, registerAgentReq); !ok {
return
}
ctx := r.Context()
keySet, err := jwk.Parse(registerAgentReq.KeySet)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not parse key set", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint))
// Validate that the existing signature validates the request
validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if !validSignature {
logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
metadata := metadata.FromSorted(registerAgentReq.Metadata)
agent, err := m.agentRepo.Create(
ctx,
registerAgentReq.Thumbprint,
keySet,
metadata,
)
if err != nil {
if !errors.Is(err, datastore.ErrAlreadyExist) {
err = errors.WithStack(err)
logger.Error(ctx, "could not create agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agents, _, err := m.agentRepo.Query(
ctx,
datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint),
datastore.WithAgentQueryLimit(1),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
if len(agents) == 0 {
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil)
return
}
agentID := agents[0].ID
agent, err = m.agentRepo.Get(ctx, agentID)
if err != nil {
err = errors.WithStack(err)
logger.Error(
ctx, "could not retrieve agent",
logger.CapturedE(err), logger.F("agentID", agentID),
)
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil)
return
}
if !validSignature {
logger.Error(ctx, "invalid signature")
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
return
}
agent, err = m.agentRepo.Update(
ctx,
agents[0].ID,
datastore.WithAgentUpdateKeySet(keySet),
datastore.WithAgentUpdateMetadata(metadata),
datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint),
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
}
api.DataResponse(w, http.StatusCreated, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,58 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type releaseAgentRequest struct {
AgentID int64 `json:"agentId" validate:"required"`
}
func (m *Mount) releaseAgent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
releaseAgentReq := &releaseAgentRequest{}
if ok := api.Bind(w, r, releaseAgentReq); !ok {
return
}
agentID := datastore.AgentID(releaseAgentReq.AgentID)
if ok := m.assertTenantOwns(w, r, agentID); !ok {
return
}
agent, err := m.agentRepo.Get(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
agent, err = m.agentRepo.Detach(ctx, agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not detach agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,58 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type updateAgentRequest struct {
Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"`
Label *string `json:"label" validate:"omitempty"`
}
func (m *Mount) updateAgent(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateAgentReq := &updateAgentRequest{}
if ok := api.Bind(w, r, updateAgentReq); !ok {
return
}
options := make([]datastore.AgentUpdateOptionFunc, 0)
if updateAgentReq.Status != nil {
options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status))
}
if updateAgentReq.Label != nil {
options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label))
}
agent, err := m.agentRepo.Update(
ctx,
datastore.AgentID(agentID),
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not update agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Agent *datastore.Agent `json:"agent"`
}{
Agent: agent,
})
}

View File

@ -0,0 +1,86 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision"
)
type updateSpecRequest struct {
spec.RawSpec
}
func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateSpecReq := &updateSpecRequest{}
if ok := api.Bind(w, r, updateSpecReq); !ok {
return
}
if err := spec.Validate(ctx, updateSpecReq); err != nil {
data := struct {
Message string `json:"message"`
}{}
var validationErr *spec.ValidationError
if errors.As(err, &validationErr) {
data.Message = validationErr.Error()
}
err = errors.WithStack(err)
logger.Error(ctx, "could not validate spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data)
return
}
spec, err := m.agentRepo.UpdateSpec(
ctx,
datastore.AgentID(agentID),
string(updateSpecReq.SpecName()),
updateSpecReq.SpecRevision(),
updateSpecReq.SpecData(),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if errors.Is(err, datastore.ErrUnexpectedRevision) {
api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not update spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Spec *datastore.Spec `json:"spec"`
}{
Spec: spec,
})
}

View File

@ -10,12 +10,12 @@ import (
"strings"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/auth/agent"
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty"
"forge.cadoles.com/Cadoles/emissary/internal/config"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"forge.cadoles.com/Cadoles/emissary/internal/server/api"
"github.com/antonmedv/expr"
"github.com/antonmedv/expr/vm"
"github.com/go-chi/chi/v5"
@ -101,28 +101,13 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
}
router.Route("/api/v1", func(r chi.Router) {
r.Post("/register", s.registerAgent)
apiMount := api.NewMount(
s.agentRepo,
thirdPartyAuth,
agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew),
)
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(
thirdPartyAuth,
agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew),
))
r.Route("/agents", func(r chi.Router) {
r.Post("/claim", s.claimAgent)
r.With(assertGlobalReadAccess).Get("/", s.queryAgents)
r.With(assertAgentReadAccess).Get("/{agentID}", s.getAgent)
r.With(assertAgentWriteAccess).Put("/{agentID}", s.updateAgent)
r.With(assertAgentWriteAccess).Delete("/{agentID}", s.deleteAgent)
r.With(assertAgentReadAccess).Get("/{agentID}/specs", s.getAgentSpecs)
r.With(assertAgentWriteAccess).Post("/{agentID}/specs", s.updateSpec)
r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", s.deleteSpec)
})
})
apiMount.Mount(r)
})
logger.Info(ctx, "http server listening")

View File

@ -1,179 +0,0 @@
package server
import (
"net/http"
"strconv"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const (
ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision"
)
type updateSpecRequest struct {
spec.RawSpec
}
func (s *Server) updateSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
updateSpecReq := &updateSpecRequest{}
if ok := api.Bind(w, r, updateSpecReq); !ok {
return
}
if err := spec.Validate(ctx, updateSpecReq); err != nil {
data := struct {
Message string `json:"message"`
}{}
var validationErr *spec.ValidationError
if errors.As(err, &validationErr) {
data.Message = validationErr.Error()
}
err = errors.WithStack(err)
logger.Error(ctx, "could not validate spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data)
return
}
spec, err := s.agentRepo.UpdateSpec(
ctx,
datastore.AgentID(agentID),
string(updateSpecReq.SpecName()),
updateSpecReq.SpecRevision(),
updateSpecReq.SpecData(),
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
if errors.Is(err, datastore.ErrUnexpectedRevision) {
api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not update spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Spec *datastore.Spec `json:"spec"`
}{
Spec: spec,
})
}
func (s *Server) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specs, err := s.agentRepo.GetSpecs(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not list specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}
type deleteSpecRequest struct {
Name string `json:"name"`
}
func (s *Server) deleteSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
deleteSpecReq := &deleteSpecRequest{}
if ok := api.Bind(w, r, deleteSpecReq); !ok {
return
}
ctx := r.Context()
err := s.agentRepo.DeleteSpec(
ctx,
agentID,
deleteSpecReq.Name,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not delete spec", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Name string `json:"name"`
}{
Name: deleteSpecReq.Name,
})
}
func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) {
rawSpecID := chi.URLParam(r, "")
specID, err := strconv.ParseInt(rawSpecID, 10, 64)
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.SpecID(specID), true
}