Introduce service accounts and bootstrap tokens (#22)

This commit is contained in:
Nikolay Edigaryev
2023-02-21 11:34:12 -05:00
committed by GitHub
parent edb9b3d693
commit 8df31f7c2d
37 changed files with 1245 additions and 40 deletions
+87
View File
@@ -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 {
+134
View File
@@ -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)
})
}
+21 -1
View File
@@ -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") != "" {
+21 -1
View File
@@ -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 {
+34 -8
View File
@@ -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
+6
View File
@@ -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
}
+5
View File
@@ -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)
}