From 8df31f7c2daeae2cdf2d2a6449c823aa644ba79e Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Tue, 21 Feb 2023 20:34:12 +0400 Subject: [PATCH] Introduce service accounts and bootstrap tokens (#22) --- api/openapi.yaml | 77 ++++++++++ go.mod | 5 +- go.sum | 4 +- internal/bootstraptoken/bootstraptoken.go | 143 ++++++++++++++++++ .../bootstraptoken/bootstraptoken_test.go | 34 +++++ internal/command/context/create.go | 36 ++++- internal/command/context/list.go | 9 +- internal/command/controller/init.go | 31 +++- internal/command/create/create.go | 4 +- internal/command/create/service_account.go | 58 +++++++ internal/command/deletecmd/delete.go | 4 +- internal/command/deletecmd/service_account.go | 25 +++ internal/command/dev/dev.go | 3 +- internal/command/get/bootstrap_token.go | 53 +++++++ internal/command/get/get.go | 19 +++ internal/command/get/service_account.go | 70 +++++++++ internal/command/list/list.go | 4 +- internal/command/list/service_accounts.go | 57 +++++++ internal/command/list/vms.go | 5 +- internal/command/root.go | 2 + internal/config/context.go | 6 +- internal/controller/api.go | 87 +++++++++++ internal/controller/api_service_accounts.go | 134 ++++++++++++++++ internal/controller/api_vms.go | 22 ++- internal/controller/api_workers.go | 22 ++- internal/controller/controller.go | 42 ++++- internal/controller/option.go | 6 + .../store/badger/badger_service_account.go | 97 ++++++++++++ internal/controller/store/store.go | 5 + internal/structpath/structpath.go | 31 ++++ internal/structpath/structpath_test.go | 33 ++++ pkg/client/client.go | 23 ++- pkg/client/option.go | 7 + pkg/client/service_accounts.go | 73 +++++++++ pkg/client/vms.go | 1 + pkg/resource/v1/service_account.go | 8 + pkg/resource/v1/service_account_role.go | 45 ++++++ 37 files changed, 1245 insertions(+), 40 deletions(-) create mode 100644 internal/bootstraptoken/bootstraptoken.go create mode 100644 internal/bootstraptoken/bootstraptoken_test.go create mode 100644 internal/command/create/service_account.go create mode 100644 internal/command/deletecmd/service_account.go create mode 100644 internal/command/get/bootstrap_token.go create mode 100644 internal/command/get/get.go create mode 100644 internal/command/get/service_account.go create mode 100644 internal/command/list/service_accounts.go create mode 100644 internal/controller/api_service_accounts.go create mode 100644 internal/controller/store/badger/badger_service_account.go create mode 100644 internal/structpath/structpath.go create mode 100644 internal/structpath/structpath_test.go create mode 100644 pkg/client/service_accounts.go create mode 100644 pkg/resource/v1/service_account.go create mode 100644 pkg/resource/v1/service_account_role.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 0b31bee..143672f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4,6 +4,69 @@ info: description: Orchard orchestration API version: 0.1.0 paths: + /service-accounts: + post: + summary: "Create a Service Account" + tags: + - service-accounts + responses: + '200': + description: Service Account resource was successfully created + content: + application/json: + schema: + $ref: '#components/schemas/ServiceAccount' + '409': + description: Service Account resource with with the same name already exists + get: + summary: "List Service Accounts" + tags: + - service-accounts + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#components/schemas/ServiceAccount' + /service-accounts/{name}: + get: + summary: "Retrieve a Service Account" + tags: + - service-accounts + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#components/schemas/ServiceAccount' + '404': + description: Service Account resource with the given name doesn't exist + put: + summary: "Update a Service Account" + tags: + - service-accounts + responses: + '200': + description: Service Account object was successfully updated + content: + application/json: + schema: + $ref: '#components/schemas/ServiceAccount' + '404': + description: Service Account resource with the given name doesn't exist + delete: + summary: "Delete a Service Account" + tags: + - service-accounts + responses: + '200': + description: Service Account resource was successfully deleted + '404': + description: Service Account resource with the given name doesn't exist /workers: post: summary: "Create a Worker" @@ -146,3 +209,17 @@ components: name: type: string description: VM name + ServiceAccount: + title: Service Account + type: object + properties: + name: + type: string + description: Name + token: + type: string + description: Secret token used to access the API + roles: + type: array + items: + type: string diff --git a/go.mod b/go.mod index 8ad4ee3..f072804 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cirruslabs/orchard go 1.19 require ( + github.com/deckarep/golang-set/v2 v2.1.0 github.com/dgraph-io/badger/v3 v3.2103.5 github.com/dustin/go-humanize v1.0.0 github.com/gin-gonic/gin v1.8.2 @@ -11,8 +12,8 @@ require ( github.com/gosuri/uitable v0.0.4 github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.6.0 + github.com/stretchr/testify v1.8.1 go.uber.org/zap v1.24.0 - golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 gopkg.in/yaml.v3 v3.0.1 ) @@ -20,6 +21,7 @@ require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -45,6 +47,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/ugorji/go/codec v1.2.7 // indirect diff --git a/go.sum b/go.sum index 5aa862d..6c7de66 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.1.0 h1:g47V4Or+DUdzbs8FxCCmgb6VYd+ptPAngjM6dtGktsI= +github.com/deckarep/golang-set/v2 v2.1.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= @@ -180,8 +182,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 h1:frX3nT9RkKybPnjyI+yvZh6ZucTZatCCEm9D47sZ2zo= -golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= diff --git a/internal/bootstraptoken/bootstraptoken.go b/internal/bootstraptoken/bootstraptoken.go new file mode 100644 index 0000000..8ecc66c --- /dev/null +++ b/internal/bootstraptoken/bootstraptoken.go @@ -0,0 +1,143 @@ +package bootstraptoken + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "strings" +) + +var ( + ErrFailedToCreateBootstrapToken = errors.New("failed to create bootstrap token") + ErrInvalidBootstrapTokenFormat = errors.New("invalid bootstrap token format") + + encoding = base64.RawURLEncoding +) + +const ( + versionPrefix = "orchard-bootstrap-token-v" + version = 0 +) + +type BootstrapToken struct { + version int + certificate *x509.Certificate + rawCertificate []byte + serviceAccountName string + serviceAccountToken string +} + +func New(rawCertificate []byte, serviceAccountName string, serviceAccountToken string) (*BootstrapToken, error) { + if len(rawCertificate) == 0 { + return nil, fmt.Errorf("%w: empty certificate", ErrFailedToCreateBootstrapToken) + } + + if serviceAccountName == "" { + return nil, fmt.Errorf("%w: empty service account name", ErrFailedToCreateBootstrapToken) + } + + if serviceAccountToken == "" { + return nil, fmt.Errorf("%w: empty service account token", ErrFailedToCreateBootstrapToken) + } + + // Parse certificate + block, _ := pem.Decode(rawCertificate) + if block == nil { + return nil, fmt.Errorf("%w: failed to parse certificate: expected a PEM format", + ErrFailedToCreateBootstrapToken) + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse certificate: %v", + ErrFailedToCreateBootstrapToken, err) + } + + return &BootstrapToken{ + version: version, + certificate: certificate, + serviceAccountName: serviceAccountName, + serviceAccountToken: serviceAccountToken, + rawCertificate: rawCertificate, + }, nil +} + +func NewFromString(rawBootstrapToken string) (*BootstrapToken, error) { + splits := strings.Split(rawBootstrapToken, ".") + + currentVersionString := fmt.Sprintf("%s%d", versionPrefix, version) + + if splits[0] != currentVersionString { + return nil, fmt.Errorf("%w: invalid version string or unsupported version", + ErrInvalidBootstrapTokenFormat) + } + + if len(splits) < 3 { + return nil, fmt.Errorf("%w: missing service account credentials", ErrInvalidBootstrapTokenFormat) + } + + if len(splits) < 4 { + return nil, fmt.Errorf("%w: missing certificate", ErrInvalidBootstrapTokenFormat) + } + + if len(splits) > 4 { + return nil, fmt.Errorf("%w: extraneous data", ErrInvalidBootstrapTokenFormat) + } + + serviceAccountName, err := encoding.DecodeString(splits[1]) + if err != nil { + return nil, fmt.Errorf("%w: failed to decode service account name: %v", + ErrInvalidBootstrapTokenFormat, err) + } + serviceAccountToken, err := encoding.DecodeString(splits[2]) + if err != nil { + return nil, fmt.Errorf("%w: failed to decode service account token: %v", + ErrInvalidBootstrapTokenFormat, err) + } + rawCertificate, err := encoding.DecodeString(splits[3]) + if err != nil { + return nil, fmt.Errorf("%w: failed to decode certificate: %v", + ErrInvalidBootstrapTokenFormat, err) + } + + // Parse certificate + block, _ := pem.Decode(rawCertificate) + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: failed to parse certificate: %v", + ErrFailedToCreateBootstrapToken, err) + } + + return &BootstrapToken{ + version: version, + certificate: certificate, + rawCertificate: rawCertificate, + serviceAccountName: string(serviceAccountName), + serviceAccountToken: string(serviceAccountToken), + }, nil +} + +func (bt *BootstrapToken) String() string { + return fmt.Sprintf("%s%d.%s.%s.%s", + versionPrefix, + version, + encoding.EncodeToString([]byte(bt.serviceAccountName)), + encoding.EncodeToString([]byte(bt.serviceAccountToken)), + encoding.EncodeToString(bt.rawCertificate), + ) +} + +func (bt *BootstrapToken) ServiceAccountName() string { + return bt.serviceAccountName +} + +func (bt *BootstrapToken) ServiceAccountToken() string { + return bt.serviceAccountToken +} + +func (bt *BootstrapToken) Certificate() *x509.Certificate { + return bt.certificate +} diff --git a/internal/bootstraptoken/bootstraptoken_test.go b/internal/bootstraptoken/bootstraptoken_test.go new file mode 100644 index 0000000..021ff87 --- /dev/null +++ b/internal/bootstraptoken/bootstraptoken_test.go @@ -0,0 +1,34 @@ +package bootstraptoken_test + +import ( + "encoding/pem" + "github.com/cirruslabs/orchard/internal/bootstraptoken" + controllercmd "github.com/cirruslabs/orchard/internal/command/controller" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "testing" +) + +func TestBootstrapTokenTwoWay(t *testing.T) { + serviceAccountName := "admin" + serviceAccountToken := uuid.New().String() + + tlsCert, err := controllercmd.GenerateSelfSignedControllerCertificate() + require.NoError(t, err) + + block := &pem.Block{ + Type: "CERTIFICATE", + Bytes: tlsCert.Certificate[0], + } + certificatePEM := pem.EncodeToMemory(block) + + bootstrapTokenOld, err := bootstraptoken.New(certificatePEM, serviceAccountName, serviceAccountToken) + require.NoError(t, err) + + bootstrapTokenNew, err := bootstraptoken.NewFromString(bootstrapTokenOld.String()) + require.NoError(t, err) + + require.Equal(t, bootstrapTokenOld.ServiceAccountName(), bootstrapTokenNew.ServiceAccountName()) + require.Equal(t, bootstrapTokenOld.ServiceAccountToken(), bootstrapTokenNew.ServiceAccountToken()) + require.Equal(t, bootstrapTokenOld.Certificate(), bootstrapTokenNew.Certificate()) +} diff --git a/internal/command/context/create.go b/internal/command/context/create.go index 2d086f0..41eee57 100644 --- a/internal/command/context/create.go +++ b/internal/command/context/create.go @@ -7,6 +7,7 @@ import ( "encoding/pem" "errors" "fmt" + "github.com/cirruslabs/orchard/internal/bootstraptoken" "github.com/cirruslabs/orchard/internal/config" "github.com/cirruslabs/orchard/internal/controller" "github.com/cirruslabs/orchard/pkg/client" @@ -19,6 +20,9 @@ import ( var ErrCreateFailed = errors.New("failed to create context") +var bootstrapTokenRaw string +var serviceAccountName string +var serviceAccountToken string var force bool func newCreateCommand() *cobra.Command { @@ -31,6 +35,12 @@ func newCreateCommand() *cobra.Command { command.PersistentFlags().StringVar(&contextName, "name", "default", "context name to use") + command.PersistentFlags().StringVar(&bootstrapTokenRaw, "bootstrap-token", "", + "bootstrap token to use") + command.PersistentFlags().StringVar(&serviceAccountName, "service-account-name", "", + "service account name to use (alternative to --bootstrap-token)") + command.PersistentFlags().StringVar(&serviceAccountToken, "service-account-token", "", + "service account token to use (alternative to --bootstrap-token)") command.PersistentFlags().BoolVar(&force, "force", false, "create the context even if a context with the same name already exists") @@ -54,9 +64,22 @@ func runCreate(cmd *cobra.Command, args []string) error { } // Establish trust - trustedControllerCertificate, err := probeControllerCertificate(controllerURL) - if err != nil { - return err + var trustedControllerCertificate *x509.Certificate + + if bootstrapTokenRaw != "" { + bootstrapToken, err := bootstraptoken.NewFromString(bootstrapTokenRaw) + if err != nil { + return err + } + + serviceAccountName = bootstrapToken.ServiceAccountName() + serviceAccountToken = bootstrapToken.ServiceAccountToken() + trustedControllerCertificate = bootstrapToken.Certificate() + } else { + trustedControllerCertificate, err = probeControllerCertificate(controllerURL) + if err != nil { + return err + } } // Check that the API is accessible @@ -72,6 +95,7 @@ func runCreate(cmd *cobra.Command, args []string) error { client, err := client.New( client.WithAddress(controllerURL.String()), client.WithTLSConfig(tlsConfig), + client.WithCredentials(serviceAccountName, serviceAccountToken), ) if err != nil { return err @@ -92,8 +116,10 @@ func runCreate(cmd *cobra.Command, args []string) error { }) return configHandle.CreateContext(contextName, config.Context{ - URL: controllerURL.String(), - Certificate: certificatePEMBytes, + URL: controllerURL.String(), + Certificate: certificatePEMBytes, + ServiceAccountName: serviceAccountName, + ServiceAccountToken: serviceAccountToken, }, force) } diff --git a/internal/command/context/list.go b/internal/command/context/list.go index 76b769c..c7a6bd8 100644 --- a/internal/command/context/list.go +++ b/internal/command/context/list.go @@ -30,10 +30,15 @@ func runList(cmd *cobra.Command, args []string) error { table := uitable.New() - table.AddRow("Name", "URL") + table.AddRow("Name", "URL", "Default") for name, context := range config.Contexts { - table.AddRow(name, context.URL) + var defaultMark string + if name == config.DefaultContext { + defaultMark = "*" + } + + table.AddRow(name, context.URL, defaultMark) } fmt.Println(table) diff --git a/internal/command/controller/init.go b/internal/command/controller/init.go index af55ff2..5805cac 100644 --- a/internal/command/controller/init.go +++ b/internal/command/controller/init.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "github.com/cirruslabs/orchard/internal/controller" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" "github.com/spf13/cobra" "math/big" "time" @@ -20,6 +21,8 @@ var ErrInitFailed = errors.New("controller initialization failed") var controllerCertPath string var controllerKeyPath string +var serviceAccountName string +var serviceAccountToken string var force bool func newInitCommand() *cobra.Command { @@ -35,6 +38,10 @@ func newInitCommand() *cobra.Command { command.PersistentFlags().StringVar(&controllerKeyPath, "controller-key", "", "do not auto-generate the controller certificate key, import it from the specified path instead"+ " (requires --controller-cert)") + command.PersistentFlags().StringVar(&serviceAccountName, "service-account-name", "admin", + "name of the service account with maximum privileges to create") + command.PersistentFlags().StringVar(&serviceAccountToken, "service-account-token", "", + "token to use when creating the service account with maximum privileges") command.PersistentFlags().BoolVar(&force, "force", false, "force re-initialization if the controller is already initialized") @@ -42,7 +49,9 @@ func newInitCommand() *cobra.Command { } func runInit(cmd *cobra.Command, args []string) (err error) { - var controllerCert tls.Certificate + if serviceAccountToken == "" { + return fmt.Errorf("%w: --service-account-token is required", ErrInitFailed) + } dataDir, err := controller.NewDataDir(dataDirPath) if err != nil { @@ -59,6 +68,8 @@ func runInit(cmd *cobra.Command, args []string) (err error) { "please specify \"--force\" to re-initialize", ErrInitFailed) } + var controllerCert tls.Certificate + if controllerCertPath != "" || controllerKeyPath != "" { if err := checkBothCertAndKeyAreSpecified(); err != nil { return err @@ -69,7 +80,7 @@ func runInit(cmd *cobra.Command, args []string) (err error) { return err } } else { - controllerCert, err = generateSelfSignedControllerCertificate() + controllerCert, err = GenerateSelfSignedControllerCertificate() if err != nil { return err } @@ -79,7 +90,19 @@ func runInit(cmd *cobra.Command, args []string) (err error) { return err } - return nil + // Run the controller to create the service account with maximum privileges + controller, err := controller.New(controller.WithDataDir(dataDir)) + if err != nil { + return err + } + + return controller.EnsureServiceAccount(&v1.ServiceAccount{ + Meta: v1.Meta{ + Name: serviceAccountName, + }, + Token: serviceAccountToken, + Roles: v1.AllServiceAccountRoles(), + }) } func checkBothCertAndKeyAreSpecified() error { @@ -96,7 +119,7 @@ func checkBothCertAndKeyAreSpecified() error { return nil } -func generateSelfSignedControllerCertificate() (tls.Certificate, error) { +func GenerateSelfSignedControllerCertificate() (tls.Certificate, error) { privateKey, err := ecdsa.GenerateKey(elliptic.P384(), cryptorand.Reader) if err != nil { return tls.Certificate{}, err diff --git a/internal/command/create/create.go b/internal/command/create/create.go index b41c584..7f795bf 100644 --- a/internal/command/create/create.go +++ b/internal/command/create/create.go @@ -7,10 +7,10 @@ import ( func NewCommand() *cobra.Command { command := &cobra.Command{ Use: "create", - Short: "Create resources on the controller (VMs)", + Short: "Create resources on the controller", } - command.AddCommand(newCreateVMCommand()) + command.AddCommand(newCreateVMCommand(), newCreateServiceAccount()) return command } diff --git a/internal/command/create/service_account.go b/internal/command/create/service_account.go new file mode 100644 index 0000000..5259448 --- /dev/null +++ b/internal/command/create/service_account.go @@ -0,0 +1,58 @@ +package create + +import ( + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/spf13/cobra" + "strings" +) + +var token string +var roles []string + +func newCreateServiceAccount() *cobra.Command { + command := &cobra.Command{ + Use: "service-account", + RunE: runCreateServiceAccount, + Args: cobra.ExactArgs(1), + } + + command.PersistentFlags().StringVar(&token, "token", "", + "token to use for this service account (autogenerated by the API server if left empty)") + + var serviceAccountRoleList []string + for _, role := range v1.AllServiceAccountRoles() { + serviceAccountRoleList = append(serviceAccountRoleList, string(role)) + } + command.PersistentFlags().StringArrayVar(&roles, "roles", []string{}, + fmt.Sprintf("roles to grant to this service account (supported roles: %s)", + strings.Join(serviceAccountRoleList, ", "))) + + return command +} + +func runCreateServiceAccount(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + var serviceAccountRoles []v1.ServiceAccountRole + + for _, role := range roles { + // Don't bother checking if the role name is valid + // since this will be checked by the API server anyway + serviceAccountRoles = append(serviceAccountRoles, v1.ServiceAccountRole(role)) + } + + return client.ServiceAccounts().Create(cmd.Context(), &v1.ServiceAccount{ + Meta: v1.Meta{ + Name: name, + }, + Token: token, + Roles: serviceAccountRoles, + }) +} diff --git a/internal/command/deletecmd/delete.go b/internal/command/deletecmd/delete.go index 359b57b..f3d82cf 100644 --- a/internal/command/deletecmd/delete.go +++ b/internal/command/deletecmd/delete.go @@ -7,10 +7,10 @@ import ( func NewCommand() *cobra.Command { command := &cobra.Command{ Use: "delete", - Short: "Delete resources from the controller (VMs)", + Short: "Delete resources from the controller", } - command.AddCommand(newDeleteVMCommand()) + command.AddCommand(newDeleteVMCommand(), newDeleteServiceComandCommand()) return command } diff --git a/internal/command/deletecmd/service_account.go b/internal/command/deletecmd/service_account.go new file mode 100644 index 0000000..73ce4c6 --- /dev/null +++ b/internal/command/deletecmd/service_account.go @@ -0,0 +1,25 @@ +package deletecmd + +import ( + "github.com/cirruslabs/orchard/pkg/client" + "github.com/spf13/cobra" +) + +func newDeleteServiceComandCommand() *cobra.Command { + return &cobra.Command{ + Use: "service-account", + Args: cobra.ExactArgs(1), + RunE: runDeleteServiceAccountCommand, + } +} + +func runDeleteServiceAccountCommand(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + return client.ServiceAccounts().Delete(cmd.Context(), name, false) +} diff --git a/internal/command/dev/dev.go b/internal/command/dev/dev.go index c094d0a..bbeb057 100644 --- a/internal/command/dev/dev.go +++ b/internal/command/dev/dev.go @@ -40,7 +40,8 @@ func runDev(cmd *cobra.Command, args []string) error { return err } - controller, err := controller.New(controller.WithDataDir(dataDir), controller.WithLogger(logger)) + controller, err := controller.New(controller.WithDataDir(dataDir), + controller.WithInsecureAuthDisabled(), controller.WithLogger(logger)) if err != nil { return err } diff --git a/internal/command/get/bootstrap_token.go b/internal/command/get/bootstrap_token.go new file mode 100644 index 0000000..939b10b --- /dev/null +++ b/internal/command/get/bootstrap_token.go @@ -0,0 +1,53 @@ +package get + +import ( + "fmt" + "github.com/cirruslabs/orchard/internal/bootstraptoken" + "github.com/cirruslabs/orchard/internal/config" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/spf13/cobra" +) + +func newGetBootstrapTokenCommand() *cobra.Command { + command := &cobra.Command{ + Use: "bootstrap-token", + Short: "Retrieve a bootstrap token for the specified service account", + RunE: runGetBootstrapToken, + Args: cobra.ExactArgs(1), + } + + return command +} + +func runGetBootstrapToken(cmd *cobra.Command, args []string) error { + name := args[0] + + configHandle, err := config.NewHandle() + if err != nil { + return err + } + + defaultContext, err := configHandle.DefaultContext() + if err != nil { + return err + } + + client, err := client.New() + if err != nil { + return err + } + + serviceAccount, err := client.ServiceAccounts().Get(cmd.Context(), name) + if err != nil { + return err + } + + bootstrapToken, err := bootstraptoken.New(defaultContext.Certificate, serviceAccount.Name, serviceAccount.Token) + if err != nil { + return err + } + + fmt.Println(bootstrapToken) + + return nil +} diff --git a/internal/command/get/get.go b/internal/command/get/get.go new file mode 100644 index 0000000..e6e93a2 --- /dev/null +++ b/internal/command/get/get.go @@ -0,0 +1,19 @@ +package get + +import ( + "errors" + "github.com/spf13/cobra" +) + +var ErrGetFailed = errors.New("get command failed") + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "get", + Short: "Retrieve resources from the controller", + } + + command.AddCommand(newGetServiceAccountCommand(), newGetBootstrapTokenCommand()) + + return command +} diff --git a/internal/command/get/service_account.go b/internal/command/get/service_account.go new file mode 100644 index 0000000..74f3c76 --- /dev/null +++ b/internal/command/get/service_account.go @@ -0,0 +1,70 @@ +package get + +import ( + "fmt" + "github.com/cirruslabs/orchard/internal/structpath" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + "strings" +) + +func newGetServiceAccountCommand() *cobra.Command { + command := &cobra.Command{ + Use: "service-account", + Short: "Retrieve service account and it's fields", + RunE: runGetServiceAccount, + Args: cobra.ExactArgs(1), + } + + return command +} + +func runGetServiceAccount(cmd *cobra.Command, args []string) error { + name := args[0] + + client, err := client.New() + if err != nil { + return err + } + + // Ability to retrieve resource fields (e.g. "orchard get service-account workers/token") + splits := strings.Split(name, "/") + var path []string + if len(splits) > 1 { + name = splits[0] + path = splits[1:] + } + + serviceAccount, err := client.ServiceAccounts().Get(cmd.Context(), name) + if err != nil { + return err + } + + // Ability to retrieve resource fields (e.g. "orchard get service-account workers/token") + if len(path) != 0 { + result, ok := structpath.Lookup(*serviceAccount, path) + if !ok { + return fmt.Errorf("%w: failed to find the specified field \"%s\" or the field is not a string", + ErrGetFailed, strings.Join(path, "/")) + } + + fmt.Println(result) + + return nil + } + + table := uitable.New() + + table.AddRow("name", serviceAccount.Name) + + var scopeList []string + for _, scope := range serviceAccount.Roles { + scopeList = append(scopeList, string(scope)) + } + table.AddRow("roles", strings.Join(scopeList, ", ")) + + fmt.Println(table) + + return nil +} diff --git a/internal/command/list/list.go b/internal/command/list/list.go index 7624e33..da6c243 100644 --- a/internal/command/list/list.go +++ b/internal/command/list/list.go @@ -9,10 +9,10 @@ var quiet bool func NewCommand() *cobra.Command { command := &cobra.Command{ Use: "list", - Short: "List resources on the controller (workers, VMs)", + Short: "List resources on the controller", } - command.AddCommand(newListWorkersCommand(), newListVMsCommand()) + command.AddCommand(newListWorkersCommand(), newListVMsCommand(), newListServiceAccountsCommand()) command.PersistentFlags().BoolVarP(&quiet, "", "q", false, "only show resource names") diff --git a/internal/command/list/service_accounts.go b/internal/command/list/service_accounts.go new file mode 100644 index 0000000..1ca00b5 --- /dev/null +++ b/internal/command/list/service_accounts.go @@ -0,0 +1,57 @@ +package list + +import ( + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + "strings" +) + +func newListServiceAccountsCommand() *cobra.Command { + command := &cobra.Command{ + Use: "service-accounts", + Short: "List service accounts", + RunE: runListServiceAccounts, + } + + return command +} + +func runListServiceAccounts(cmd *cobra.Command, args []string) error { + client, err := client.New() + if err != nil { + return err + } + + serviceAccounts, err := client.ServiceAccounts().List(cmd.Context()) + if err != nil { + return err + } + + if quiet { + for _, serviceAccount := range serviceAccounts { + fmt.Println(serviceAccount.Name) + } + + return nil + } + + table := uitable.New() + + table.AddRow("Name", "Roles") + + for _, serviceAccount := range serviceAccounts { + var scopeList []string + + for _, scope := range serviceAccount.Roles { + scopeList = append(scopeList, string(scope)) + } + + table.AddRow(serviceAccount.Name, strings.Join(scopeList, ", ")) + } + + fmt.Println(table) + + return nil +} diff --git a/internal/command/list/vms.go b/internal/command/list/vms.go index 8692d32..4cd1364 100644 --- a/internal/command/list/vms.go +++ b/internal/command/list/vms.go @@ -9,8 +9,9 @@ import ( func newListVMsCommand() *cobra.Command { command := &cobra.Command{ - Use: "vms", - RunE: runListVMs, + Use: "vms", + Short: "List VMs", + RunE: runListVMs, } return command diff --git a/internal/command/root.go b/internal/command/root.go index e206d6f..5889bfc 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -6,6 +6,7 @@ import ( "github.com/cirruslabs/orchard/internal/command/create" deletepkg "github.com/cirruslabs/orchard/internal/command/deletecmd" "github.com/cirruslabs/orchard/internal/command/dev" + "github.com/cirruslabs/orchard/internal/command/get" "github.com/cirruslabs/orchard/internal/command/list" "github.com/cirruslabs/orchard/internal/command/worker" "github.com/spf13/cobra" @@ -20,6 +21,7 @@ func NewRootCmd() *cobra.Command { addGroupedCommands(command, "Working With Resources:", create.NewCommand(), + get.NewCommand(), list.NewCommand(), deletepkg.NewCommand(), ) diff --git a/internal/config/context.go b/internal/config/context.go index ea0d1e5..fa51a7c 100644 --- a/internal/config/context.go +++ b/internal/config/context.go @@ -8,8 +8,10 @@ import ( ) type Context struct { - URL string `yaml:"url,omitempty"` - Certificate Base64 `yaml:"certificate,omitempty"` + URL string `yaml:"url,omitempty"` + Certificate Base64 `yaml:"certificate,omitempty"` + ServiceAccountName string `yaml:"serviceAccountName,omitempty"` + ServiceAccountToken string `yaml:"serviceAccountToken,omitempty"` } func (context *Context) TLSConfig() (*tls.Config, error) { diff --git a/internal/controller/api.go b/internal/controller/api.go index a0d522f..c854509 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -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 { diff --git a/internal/controller/api_service_accounts.go b/internal/controller/api_service_accounts.go new file mode 100644 index 0000000..6135013 --- /dev/null +++ b/internal/controller/api_service_accounts.go @@ -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) + }) +} diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go index 3dd8c3a..9844bb1 100644 --- a/internal/controller/api_vms.go +++ b/internal/controller/api_vms.go @@ -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") != "" { diff --git a/internal/controller/api_workers.go b/internal/controller/api_workers.go index eb2a900..b8d1700 100644 --- a/internal/controller/api_workers.go +++ b/internal/controller/api_workers.go @@ -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 { diff --git a/internal/controller/controller.go b/internal/controller/controller.go index f0553a2..8cc892c 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -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 diff --git a/internal/controller/option.go b/internal/controller/option.go index 942a3b7..a79f1b8 100644 --- a/internal/controller/option.go +++ b/internal/controller/option.go @@ -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() diff --git a/internal/controller/store/badger/badger_service_account.go b/internal/controller/store/badger/badger_service_account.go new file mode 100644 index 0000000..80ed086 --- /dev/null +++ b/internal/controller/store/badger/badger_service_account.go @@ -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 +} diff --git a/internal/controller/store/store.go b/internal/controller/store/store.go index 78139eb..4c5fd45 100644 --- a/internal/controller/store/store.go +++ b/internal/controller/store/store.go @@ -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) } diff --git a/internal/structpath/structpath.go b/internal/structpath/structpath.go new file mode 100644 index 0000000..2defbea --- /dev/null +++ b/internal/structpath/structpath.go @@ -0,0 +1,31 @@ +package structpath + +import ( + "reflect" + "strings" +) + +func Lookup(target interface{}, path []string) (s string, ok bool) { + currentValue := reflect.ValueOf(target) + + for _, pathElement := range path { + pathElement := pathElement + + field := currentValue.FieldByNameFunc(func(fieldName string) bool { + return strings.EqualFold(fieldName, pathElement) + }) + + if !field.IsValid() { + return "", false + } + + currentValue = field + } + + result, ok := currentValue.Interface().(string) + if !ok { + return "", false + } + + return result, true +} diff --git a/internal/structpath/structpath_test.go b/internal/structpath/structpath_test.go new file mode 100644 index 0000000..681e5ef --- /dev/null +++ b/internal/structpath/structpath_test.go @@ -0,0 +1,33 @@ +package structpath_test + +import ( + "github.com/cirruslabs/orchard/internal/structpath" + "github.com/stretchr/testify/require" + "testing" +) + +func TestStructPath(t *testing.T) { + target := struct { + Name string + Aliases []string + }{ + Name: "Test", + Aliases: []string{"Check-up", "Evaluation"}, + } + + t.Run("normal scenario", func(t *testing.T) { + result, ok := structpath.Lookup(target, []string{"name"}) + require.True(t, ok) + require.Equal(t, "Test", result) + }) + + t.Run("non-existent field", func(t *testing.T) { + _, ok := structpath.Lookup(target, []string{"non-existent"}) + require.False(t, ok) + }) + + t.Run("non-string field", func(t *testing.T) { + _, ok := structpath.Lookup(target, []string{"aliases"}) + require.False(t, ok) + }) +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 79bbb46..5156008 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -23,6 +23,9 @@ type Client struct { httpClient *http.Client baseURL *url.URL + + serviceAccountName string + serviceAccountToken string } type Config struct { @@ -51,6 +54,8 @@ func New(opts ...Option) (*Client, error) { } client.address = defaultContext.URL + client.serviceAccountName = defaultContext.ServiceAccountName + client.serviceAccountToken = defaultContext.ServiceAccountToken tlsConfig, err := defaultContext.TLSConfig() if err != nil { @@ -60,7 +65,7 @@ func New(opts ...Option) (*Client, error) { } // Instantiate client - httpClient := &http.Client{ + client.httpClient = &http.Client{ Transport: &http.Transport{ TLSClientConfig: client.tlsConfig, }, @@ -70,11 +75,9 @@ func New(opts ...Option) (*Client, error) { if err != nil { return nil, err } + client.baseURL = url - return &Client{ - httpClient: httpClient, - baseURL: url, - }, nil + return client, nil } func (client *Client) request( @@ -120,6 +123,10 @@ func (client *Client) request( return fmt.Errorf("%w instantiate a request: %v", ErrFailed, err) } + if client.serviceAccountName != "" && client.serviceAccountToken != "" { + request.SetBasicAuth(client.serviceAccountName, client.serviceAccountToken) + } + response, err := client.httpClient.Do(request) if err != nil { return fmt.Errorf("%w to make a request: %v", ErrFailed, err) @@ -162,3 +169,9 @@ func (client *Client) VMs() *VMsService { client: client, } } + +func (client *Client) ServiceAccounts() *ServiceAccountsService { + return &ServiceAccountsService{ + client: client, + } +} diff --git a/pkg/client/option.go b/pkg/client/option.go index c50be81..6199dc6 100644 --- a/pkg/client/option.go +++ b/pkg/client/option.go @@ -15,3 +15,10 @@ func WithTLSConfig(tlsConfig *tls.Config) Option { client.tlsConfig = tlsConfig } } + +func WithCredentials(serviceAccountName string, serviceAccountToken string) Option { + return func(client *Client) { + client.serviceAccountName = serviceAccountName + client.serviceAccountToken = serviceAccountToken + } +} diff --git a/pkg/client/service_accounts.go b/pkg/client/service_accounts.go new file mode 100644 index 0000000..007a07f --- /dev/null +++ b/pkg/client/service_accounts.go @@ -0,0 +1,73 @@ +//nolint:dupl // maybe we'll figure out how to make client API accessors generic in the future +package client + +import ( + "context" + "fmt" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "net/http" +) + +type ServiceAccountsService struct { + client *Client +} + +func (service *ServiceAccountsService) Create(ctx context.Context, serviceAccount *v1.ServiceAccount) error { + err := service.client.request(ctx, http.MethodPost, "service-accounts", + serviceAccount, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (service *ServiceAccountsService) List(ctx context.Context) ([]v1.ServiceAccount, error) { + var serviceAccounts []v1.ServiceAccount + + err := service.client.request(ctx, http.MethodGet, "service-accounts", + nil, &serviceAccounts, nil) + if err != nil { + return nil, err + } + + return serviceAccounts, nil +} + +func (service *ServiceAccountsService) Get(ctx context.Context, name string) (*v1.ServiceAccount, error) { + var serviceAccount v1.ServiceAccount + + err := service.client.request(ctx, http.MethodGet, fmt.Sprintf("service-accounts/%s", name), + nil, &serviceAccount, nil) + if err != nil { + return nil, err + } + + return &serviceAccount, nil +} + +func (service *ServiceAccountsService) Update(ctx context.Context, serviceAccount *v1.ServiceAccount) error { + err := service.client.request(ctx, http.MethodPut, fmt.Sprintf("service-accounts/%s", serviceAccount.Name), + serviceAccount, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (service *ServiceAccountsService) Delete(ctx context.Context, name string, force bool) error { + params := map[string]string{} + + if force { + params["force"] = "true" + } + + err := service.client.request(ctx, http.MethodDelete, fmt.Sprintf("service-accounts/%s", name), + nil, nil, params) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/client/vms.go b/pkg/client/vms.go index 85ff122..634ce22 100644 --- a/pkg/client/vms.go +++ b/pkg/client/vms.go @@ -1,3 +1,4 @@ +//nolint:dupl // maybe we'll figure out how to make client API accessors generic in the future package client import ( diff --git a/pkg/resource/v1/service_account.go b/pkg/resource/v1/service_account.go new file mode 100644 index 0000000..7272c3f --- /dev/null +++ b/pkg/resource/v1/service_account.go @@ -0,0 +1,8 @@ +package v1 + +type ServiceAccount struct { + Token string + Roles []ServiceAccountRole + + Meta +} diff --git a/pkg/resource/v1/service_account_role.go b/pkg/resource/v1/service_account_role.go new file mode 100644 index 0000000..cf56537 --- /dev/null +++ b/pkg/resource/v1/service_account_role.go @@ -0,0 +1,45 @@ +package v1 + +import ( + "errors" + "fmt" +) + +var ErrUnsupportedServiceAccountRole = errors.New("unsupported service account role") + +type ServiceAccountRole string + +const ( + ServiceAccountRoleWorker ServiceAccountRole = "worker" + ServiceAccountRoleComputeRead ServiceAccountRole = "compute:read" + ServiceAccountRoleComputeWrite ServiceAccountRole = "compute:write" + ServiceAccountRoleAdminRead ServiceAccountRole = "admin:read" + ServiceAccountRoleAdminWrite ServiceAccountRole = "admin:write" +) + +func NewServiceAccountRole(name string) (ServiceAccountRole, error) { + switch name { + case string(ServiceAccountRoleWorker): + return ServiceAccountRoleWorker, nil + case string(ServiceAccountRoleComputeRead): + return ServiceAccountRoleComputeRead, nil + case string(ServiceAccountRoleComputeWrite): + return ServiceAccountRoleComputeWrite, nil + case string(ServiceAccountRoleAdminRead): + return ServiceAccountRoleAdminRead, nil + case string(ServiceAccountRoleAdminWrite): + return ServiceAccountRoleAdminWrite, nil + default: + return "", fmt.Errorf("%w: %s", ErrUnsupportedServiceAccountRole, name) + } +} + +func AllServiceAccountRoles() []ServiceAccountRole { + return []ServiceAccountRole{ + ServiceAccountRoleWorker, + ServiceAccountRoleComputeRead, + ServiceAccountRoleComputeWrite, + ServiceAccountRoleAdminRead, + ServiceAccountRoleAdminWrite, + } +}