mirror of
https://github.com/cirruslabs/orchard.git
synced 2026-10-05 14:31:27 +02:00
Introduce service accounts and bootstrap tokens (#22)
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
storepkg "github.com/cirruslabs/orchard/internal/controller/store"
|
||||
"github.com/cirruslabs/orchard/internal/responder"
|
||||
v1pkg "github.com/cirruslabs/orchard/pkg/resource/v1"
|
||||
"github.com/deckarep/golang-set/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const ctxServiceAccountKey = "service-account"
|
||||
|
||||
func (controller *Controller) initAPI() *gin.Engine {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
ginEngine := gin.Default()
|
||||
@@ -14,11 +19,31 @@ func (controller *Controller) initAPI() *gin.Engine {
|
||||
// v1 API
|
||||
v1 := ginEngine.Group("/v1")
|
||||
|
||||
// Auth
|
||||
v1.Use(controller.authenticateMiddleware)
|
||||
|
||||
// A way to for the clients to check that the API is working
|
||||
v1.GET("/", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
// Service accounts
|
||||
v1.POST("/service-accounts", func(c *gin.Context) {
|
||||
controller.createServiceAccount(c).Respond(c)
|
||||
})
|
||||
v1.PUT("/service-accounts/:name", func(c *gin.Context) {
|
||||
controller.updateServiceAccount(c).Respond(c)
|
||||
})
|
||||
v1.GET("/service-accounts/:name", func(c *gin.Context) {
|
||||
controller.getServiceAccount(c).Respond(c)
|
||||
})
|
||||
v1.GET("/service-accounts", func(c *gin.Context) {
|
||||
controller.listServiceAccounts(c).Respond(c)
|
||||
})
|
||||
v1.DELETE("/service-accounts/:name", func(c *gin.Context) {
|
||||
controller.deleteServiceAccount(c).Respond(c)
|
||||
})
|
||||
|
||||
// Workers
|
||||
v1.POST("/workers", func(c *gin.Context) {
|
||||
controller.createWorker(c).Respond(c)
|
||||
@@ -56,6 +81,68 @@ func (controller *Controller) initAPI() *gin.Engine {
|
||||
return ginEngine
|
||||
}
|
||||
|
||||
func (controller *Controller) authenticateMiddleware(c *gin.Context) {
|
||||
// Retrieve presented credentials (if any)
|
||||
user, password, ok := c.Request.BasicAuth()
|
||||
if !ok {
|
||||
c.Next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
var serviceAccount *v1pkg.ServiceAccount
|
||||
var err error
|
||||
|
||||
err = controller.store.View(func(txn storepkg.Transaction) error {
|
||||
serviceAccount, err = txn.GetServiceAccount(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
responder.Error(err).Respond(c)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// No such service account found
|
||||
if serviceAccount == nil {
|
||||
responder.Code(http.StatusUnauthorized).Respond(c)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Service account's token provided is not valid
|
||||
if subtle.ConstantTimeCompare([]byte(serviceAccount.Token), []byte(password)) == 0 {
|
||||
responder.Code(http.StatusUnauthorized).Respond(c)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Remember service account for further authorize() calls
|
||||
c.Set(ctxServiceAccountKey, serviceAccount)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (controller *Controller) authorize(ctx *gin.Context, scopes ...v1pkg.ServiceAccountRole) bool {
|
||||
if controller.insecureAuthDisabled {
|
||||
return true
|
||||
}
|
||||
|
||||
serviceAccountUntyped, ok := ctx.Get(ctxServiceAccountKey)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
serviceAccount := serviceAccountUntyped.(*v1pkg.ServiceAccount)
|
||||
|
||||
return mapset.NewSet[v1pkg.ServiceAccountRole](serviceAccount.Roles...).Contains(scopes...)
|
||||
}
|
||||
|
||||
type storeTransactionFunc func(operation func(txn storepkg.Transaction) error) error
|
||||
|
||||
func (controller *Controller) storeView(view func(txn storepkg.Transaction) responder.Responder) responder.Responder {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
storepkg "github.com/cirruslabs/orchard/internal/controller/store"
|
||||
"github.com/cirruslabs/orchard/internal/responder"
|
||||
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (controller *Controller) createServiceAccount(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleAdminWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var serviceAccount v1.ServiceAccount
|
||||
|
||||
if err := ctx.ShouldBindJSON(&serviceAccount); err != nil {
|
||||
return responder.Code(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if serviceAccount.Name == "" {
|
||||
return responder.Code(http.StatusPreconditionFailed)
|
||||
}
|
||||
|
||||
if serviceAccount.Token == "" {
|
||||
serviceAccount.Token = uuid.New().String()
|
||||
}
|
||||
|
||||
serviceAccount.CreatedAt = time.Now()
|
||||
serviceAccount.DeletedAt = time.Time{}
|
||||
serviceAccount.UID = uuid.New().String()
|
||||
serviceAccount.Generation = 0
|
||||
|
||||
return controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder {
|
||||
// Does the Service Account resource with this name already exists?
|
||||
_, err := txn.GetServiceAccount(serviceAccount.Name)
|
||||
if !errors.Is(err, storepkg.ErrNotFound) {
|
||||
return responder.Code(http.StatusConflict)
|
||||
}
|
||||
|
||||
if err := txn.SetServiceAccount(&serviceAccount); err != nil {
|
||||
return responder.Code(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return responder.JSON(http.StatusOK, &serviceAccount)
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) updateServiceAccount(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleAdminWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var userServiceAccount v1.ServiceAccount
|
||||
|
||||
if err := ctx.ShouldBindJSON(&userServiceAccount); err != nil {
|
||||
return responder.Code(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if userServiceAccount.Name == "" {
|
||||
return responder.Code(http.StatusPreconditionFailed)
|
||||
}
|
||||
|
||||
if userServiceAccount.Token == "" {
|
||||
return responder.Code(http.StatusPreconditionFailed)
|
||||
}
|
||||
|
||||
return controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder {
|
||||
dbServiceAccount, err := txn.GetServiceAccount(userServiceAccount.Name)
|
||||
if err != nil {
|
||||
return responder.Error(err)
|
||||
}
|
||||
|
||||
dbServiceAccount.Generation++
|
||||
|
||||
if err := txn.SetServiceAccount(dbServiceAccount); err != nil {
|
||||
return responder.Code(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return responder.JSON(http.StatusOK, &dbServiceAccount)
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) getServiceAccount(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleAdminRead) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
serviceAccount, err := txn.GetServiceAccount(name)
|
||||
if err != nil {
|
||||
return responder.Error(err)
|
||||
}
|
||||
|
||||
return responder.JSON(http.StatusOK, &serviceAccount)
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) listServiceAccounts(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleAdminRead) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
serviceAccounts, err := txn.ListServiceAccounts()
|
||||
if err != nil {
|
||||
return responder.Error(err)
|
||||
}
|
||||
|
||||
return responder.JSON(http.StatusOK, &serviceAccounts)
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) deleteServiceAccount(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleAdminWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
return controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder {
|
||||
if err := txn.DeleteServiceAccount(name); err != nil {
|
||||
return responder.Error(err)
|
||||
}
|
||||
|
||||
return responder.Code(http.StatusOK)
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
)
|
||||
|
||||
func (controller *Controller) createVM(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var vm v1.VM
|
||||
|
||||
if err := ctx.ShouldBindJSON(&vm); err != nil {
|
||||
@@ -44,6 +48,10 @@ func (controller *Controller) createVM(ctx *gin.Context) responder.Responder {
|
||||
}
|
||||
|
||||
func (controller *Controller) updateVM(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var userVM v1.VM
|
||||
|
||||
if err := ctx.ShouldBindJSON(&userVM); err != nil {
|
||||
@@ -72,6 +80,10 @@ func (controller *Controller) updateVM(ctx *gin.Context) responder.Responder {
|
||||
}
|
||||
|
||||
func (controller *Controller) getVM(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeRead) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
@@ -84,7 +96,11 @@ func (controller *Controller) getVM(ctx *gin.Context) responder.Responder {
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) listVMs(_ *gin.Context) responder.Responder {
|
||||
func (controller *Controller) listVMs(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeRead) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
vms, err := txn.ListVMs()
|
||||
if err != nil {
|
||||
@@ -96,6 +112,10 @@ func (controller *Controller) listVMs(_ *gin.Context) responder.Responder {
|
||||
}
|
||||
|
||||
func (controller *Controller) deleteVM(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
if ctx.Query("force") != "" {
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
)
|
||||
|
||||
func (controller *Controller) createWorker(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite, v1.ServiceAccountRoleWorker) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var worker v1.Worker
|
||||
|
||||
if err := ctx.ShouldBindJSON(&worker); err != nil {
|
||||
@@ -47,6 +51,10 @@ func (controller *Controller) createWorker(ctx *gin.Context) responder.Responder
|
||||
}
|
||||
|
||||
func (controller *Controller) updateWorker(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite, v1.ServiceAccountRoleWorker) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var userWorker v1.Worker
|
||||
|
||||
if err := ctx.ShouldBindJSON(&userWorker); err != nil {
|
||||
@@ -71,6 +79,10 @@ func (controller *Controller) updateWorker(ctx *gin.Context) responder.Responder
|
||||
}
|
||||
|
||||
func (controller *Controller) getWorker(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeRead, v1.ServiceAccountRoleWorker) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
@@ -83,7 +95,11 @@ func (controller *Controller) getWorker(ctx *gin.Context) responder.Responder {
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) listWorkers(_ *gin.Context) responder.Responder {
|
||||
func (controller *Controller) listWorkers(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeRead, v1.ServiceAccountRoleWorker) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
|
||||
workers, err := txn.ListWorkers()
|
||||
if err != nil {
|
||||
@@ -95,6 +111,10 @@ func (controller *Controller) listWorkers(_ *gin.Context) responder.Responder {
|
||||
}
|
||||
|
||||
func (controller *Controller) deleteWorker(ctx *gin.Context) responder.Responder {
|
||||
if !controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite, v1.ServiceAccountRoleWorker) {
|
||||
return responder.Code(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := ctx.Param("name")
|
||||
|
||||
return controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
storepkg "github.com/cirruslabs/orchard/internal/controller/store"
|
||||
"github.com/cirruslabs/orchard/internal/controller/store/badger"
|
||||
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -18,16 +20,20 @@ const (
|
||||
DefaultServerName = "orchard-controller"
|
||||
)
|
||||
|
||||
var ErrInitFailed = errors.New("controller initialization failed")
|
||||
var (
|
||||
ErrInitFailed = errors.New("controller initialization failed")
|
||||
ErrAdminTaskFailed = errors.New("controller administrative task failed")
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
dataDir *DataDir
|
||||
listenAddr string
|
||||
tlsConfig *tls.Config
|
||||
listener net.Listener
|
||||
httpServer *http.Server
|
||||
store storepkg.Store
|
||||
logger *zap.SugaredLogger
|
||||
dataDir *DataDir
|
||||
listenAddr string
|
||||
tlsConfig *tls.Config
|
||||
listener net.Listener
|
||||
httpServer *http.Server
|
||||
insecureAuthDisabled bool
|
||||
store storepkg.Store
|
||||
logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
func New(opts ...Option) (*Controller, error) {
|
||||
@@ -75,6 +81,26 @@ func New(opts ...Option) (*Controller, error) {
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (controller *Controller) EnsureServiceAccount(serviceAccount *v1.ServiceAccount) error {
|
||||
if serviceAccount.Name == "" {
|
||||
return fmt.Errorf("%w: attempted to create a service account with an empty name",
|
||||
ErrAdminTaskFailed)
|
||||
}
|
||||
|
||||
if serviceAccount.Token == "" {
|
||||
serviceAccount.Token = uuid.New().String()
|
||||
}
|
||||
|
||||
serviceAccount.CreatedAt = time.Now()
|
||||
serviceAccount.DeletedAt = time.Time{}
|
||||
serviceAccount.UID = uuid.New().String()
|
||||
serviceAccount.Generation = 0
|
||||
|
||||
return controller.store.Update(func(txn storepkg.Transaction) error {
|
||||
return txn.SetServiceAccount(serviceAccount)
|
||||
})
|
||||
}
|
||||
|
||||
func (controller *Controller) Run(ctx context.Context) error {
|
||||
// Run the scheduler so that each VM will eventually
|
||||
// be assigned to a specific Worker
|
||||
|
||||
@@ -25,6 +25,12 @@ func WithTLSConfig(tlsConfig *tls.Config) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithInsecureAuthDisabled() Option {
|
||||
return func(controller *Controller) {
|
||||
controller.insecureAuthDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger *zap.Logger) Option {
|
||||
return func(controller *Controller) {
|
||||
controller.logger = logger.Sugar()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//nolint:dupl // maybe we'll figure out how to make DB resource accessors generic in the future
|
||||
package badger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/cirruslabs/orchard/pkg/resource/v1"
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"path"
|
||||
)
|
||||
|
||||
const SpaceServiceAccounts = "/service-accounts"
|
||||
|
||||
func ServiceAccountKey(name string) []byte {
|
||||
return []byte(path.Join(SpaceServiceAccounts, name))
|
||||
}
|
||||
|
||||
func (txn *Transaction) GetServiceAccount(name string) (result *v1.ServiceAccount, err error) {
|
||||
defer func() {
|
||||
err = mapErr(err)
|
||||
}()
|
||||
|
||||
key := ServiceAccountKey(name)
|
||||
|
||||
item, err := txn.badgerTxn.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valueBytes, err := item.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var serviceAccount v1.ServiceAccount
|
||||
|
||||
err = json.Unmarshal(valueBytes, &serviceAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &serviceAccount, nil
|
||||
}
|
||||
|
||||
func (txn *Transaction) SetServiceAccount(serviceAccount *v1.ServiceAccount) (err error) {
|
||||
defer func() {
|
||||
err = mapErr(err)
|
||||
}()
|
||||
|
||||
key := ServiceAccountKey(serviceAccount.Name)
|
||||
|
||||
valueBytes, err := json.Marshal(serviceAccount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txn.badgerTxn.Set(key, valueBytes)
|
||||
}
|
||||
|
||||
func (txn *Transaction) DeleteServiceAccount(name string) (err error) {
|
||||
defer func() {
|
||||
err = mapErr(err)
|
||||
}()
|
||||
|
||||
key := ServiceAccountKey(name)
|
||||
|
||||
return txn.badgerTxn.Delete(key)
|
||||
}
|
||||
|
||||
func (txn *Transaction) ListServiceAccounts() (result []*v1.ServiceAccount, err error) {
|
||||
defer func() {
|
||||
err = mapErr(err)
|
||||
}()
|
||||
|
||||
it := txn.badgerTxn.NewIterator(badger.IteratorOptions{
|
||||
Prefix: []byte(SpaceServiceAccounts),
|
||||
})
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
item := it.Item()
|
||||
|
||||
serviceAccountBytes, err := item.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var serviceAccount v1.ServiceAccount
|
||||
|
||||
if err := json.Unmarshal(serviceAccountBytes, &serviceAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, &serviceAccount)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -17,4 +17,9 @@ type Transaction interface {
|
||||
SetWorker(worker *v1.Worker) (err error)
|
||||
DeleteWorker(name string) (err error)
|
||||
ListWorkers() (result []*v1.Worker, err error)
|
||||
|
||||
GetServiceAccount(name string) (result *v1.ServiceAccount, err error)
|
||||
SetServiceAccount(serviceAccount *v1.ServiceAccount) (err error)
|
||||
DeleteServiceAccount(name string) (err error)
|
||||
ListServiceAccounts() (result []*v1.ServiceAccount, err error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user