From 5d64ff08b8f7365c304b478b9ca8f38ae4b7476a Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 29 Jan 2026 17:54:19 +0100 Subject: [PATCH] Introduce "compute:connect" role --- internal/controller/api.go | 50 +++++++++++++++++++--- internal/controller/api_vms_ip.go | 10 +++-- internal/controller/api_vms_portforward.go | 10 +++-- internal/controller/sshserver/sshserver.go | 16 ++++--- pkg/resource/v1/service_account_role.go | 12 ++++-- 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/internal/controller/api.go b/internal/controller/api.go index b8697ca..4cbf3d5 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/go-openapi/runtime/middleware" "github.com/penglongli/gin-metrics/ginmetrics" + "github.com/samber/lo" "go.uber.org/zap" "google.golang.org/grpc/metadata" ) @@ -239,9 +240,31 @@ func (controller *Controller) authenticateMiddleware(c *gin.Context) { c.Next() } +type AuthorizeMode int + +const ( + AuthorizeModeAll AuthorizeMode = iota + AuthorizeModeAny +) + func (controller *Controller) authorize( ctx *gin.Context, requiredRoles ...v1pkg.ServiceAccountRole, +) responder.Responder { + return controller.authorizeBase(ctx, AuthorizeModeAll, requiredRoles...) +} + +func (controller *Controller) authorizeAny( + ctx *gin.Context, + requiredRoles ...v1pkg.ServiceAccountRole, +) responder.Responder { + return controller.authorizeBase(ctx, AuthorizeModeAny, requiredRoles...) +} + +func (controller *Controller) authorizeBase( + ctx *gin.Context, + mode AuthorizeMode, + requiredRoles ...v1pkg.ServiceAccountRole, ) responder.Responder { if controller.insecureAuthDisabled { return nil @@ -254,21 +277,34 @@ func (controller *Controller) authorize( serviceAccount := serviceAccountUntyped.(*v1pkg.ServiceAccount) serviceAccountRolesSet := mapset.NewSet[v1pkg.ServiceAccountRole](serviceAccount.Roles...) - requiredRolesSet := mapset.NewSet[v1pkg.ServiceAccountRole](requiredRoles...) + var authorized bool - missingRoles := requiredRolesSet.Difference(serviceAccountRolesSet).ToSlice() - if len(missingRoles) == 0 { + switch mode { + case AuthorizeModeAll: + authorized = serviceAccountRolesSet.Contains(requiredRoles...) + case AuthorizeModeAny: + authorized = serviceAccountRolesSet.ContainsAny(requiredRoles...) + } + + if authorized { return nil } - var missingRolesStrings []string + var hint string - for _, missingRole := range missingRoles { - missingRolesStrings = append(missingRolesStrings, string(missingRole)) + switch mode { + case AuthorizeModeAll: + hint = "all of the following roles must be present" + case AuthorizeModeAny: + hint = "any of the following roles must be present" } + humanizedRoles := lo.Map(requiredRoles, func(role v1pkg.ServiceAccountRole, _ int) string { + return string(role) + }) + return responder.JSON(http.StatusUnauthorized, - NewErrorResponse("missing roles: %s", strings.Join(missingRolesStrings, ", "))) + NewErrorResponse("%s: %s", hint, strings.Join(humanizedRoles, ", "))) } func (controller *Controller) authorizeGRPC(ctx context.Context, scopes ...v1pkg.ServiceAccountRole) bool { diff --git a/internal/controller/api_vms_ip.go b/internal/controller/api_vms_ip.go index 477bb1a..8e1702d 100644 --- a/internal/controller/api_vms_ip.go +++ b/internal/controller/api_vms_ip.go @@ -3,18 +3,20 @@ package controller import ( "context" "fmt" + "net/http" + "strconv" + "time" + "github.com/cirruslabs/orchard/internal/responder" v1 "github.com/cirruslabs/orchard/pkg/resource/v1" "github.com/cirruslabs/orchard/rpc" "github.com/gin-gonic/gin" "github.com/google/uuid" - "net/http" - "strconv" - "time" ) func (controller *Controller) ip(ctx *gin.Context) responder.Responder { - if responder := controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite); responder != nil { + if responder := controller.authorizeAny(ctx, v1.ServiceAccountRoleComputeWrite, + v1.ServiceAccountRoleComputeConnect); responder != nil { return responder } diff --git a/internal/controller/api_vms_portforward.go b/internal/controller/api_vms_portforward.go index deeb2e6..60e208f 100644 --- a/internal/controller/api_vms_portforward.go +++ b/internal/controller/api_vms_portforward.go @@ -3,6 +3,10 @@ package controller import ( "context" "fmt" + "net/http" + "strconv" + "time" + storepkg "github.com/cirruslabs/orchard/internal/controller/store" "github.com/cirruslabs/orchard/internal/netconncancel" "github.com/cirruslabs/orchard/internal/proxy" @@ -15,13 +19,11 @@ import ( "github.com/pkg/errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "net/http" - "strconv" - "time" ) func (controller *Controller) portForwardVM(ctx *gin.Context) responder.Responder { - if responder := controller.authorize(ctx, v1.ServiceAccountRoleComputeWrite); responder != nil { + if responder := controller.authorizeAny(ctx, v1.ServiceAccountRoleComputeWrite, + v1.ServiceAccountRoleComputeConnect); responder != nil { return responder } diff --git a/internal/controller/sshserver/sshserver.go b/internal/controller/sshserver/sshserver.go index 7f5c84f..9e58ff9 100644 --- a/internal/controller/sshserver/sshserver.go +++ b/internal/controller/sshserver/sshserver.go @@ -5,6 +5,10 @@ import ( "crypto/subtle" "errors" "fmt" + "net" + "strings" + "time" + "github.com/cirruslabs/orchard/internal/controller/notifier" "github.com/cirruslabs/orchard/internal/controller/rendezvous" storepkg "github.com/cirruslabs/orchard/internal/controller/store" @@ -15,9 +19,6 @@ import ( "github.com/samber/lo" "go.uber.org/zap" "golang.org/x/crypto/ssh" - "net" - "strings" - "time" ) const ( @@ -110,9 +111,12 @@ func (server *SSHServer) passwordCallback(connMetadata ssh.ConnMetadata, passwor } // Authorize - if !lo.Contains(serviceAccount.Roles, v1.ServiceAccountRoleComputeWrite) { - return fmt.Errorf("authorization failed for user %q because it lacks %q role", - connMetadata.User(), v1.ServiceAccountRoleComputeWrite) + authorized := lo.Contains(serviceAccount.Roles, v1.ServiceAccountRoleComputeWrite) || + lo.Contains(serviceAccount.Roles, v1.ServiceAccountRoleComputeConnect) + + if !authorized { + return fmt.Errorf("authorization failed for user %q because it lacks %q or %q roles", + connMetadata.User(), v1.ServiceAccountRoleComputeWrite, v1.ServiceAccountRoleComputeConnect) } return nil diff --git a/pkg/resource/v1/service_account_role.go b/pkg/resource/v1/service_account_role.go index c5baeef..ecae894 100644 --- a/pkg/resource/v1/service_account_role.go +++ b/pkg/resource/v1/service_account_role.go @@ -10,10 +10,11 @@ var ErrUnsupportedServiceAccountRole = errors.New("unsupported service account r type ServiceAccountRole string const ( - ServiceAccountRoleComputeRead ServiceAccountRole = "compute:read" - ServiceAccountRoleComputeWrite ServiceAccountRole = "compute:write" - ServiceAccountRoleAdminRead ServiceAccountRole = "admin:read" - ServiceAccountRoleAdminWrite ServiceAccountRole = "admin:write" + ServiceAccountRoleComputeRead ServiceAccountRole = "compute:read" + ServiceAccountRoleComputeWrite ServiceAccountRole = "compute:write" + ServiceAccountRoleComputeConnect ServiceAccountRole = "compute:connect" + ServiceAccountRoleAdminRead ServiceAccountRole = "admin:read" + ServiceAccountRoleAdminWrite ServiceAccountRole = "admin:write" ) func NewServiceAccountRole(name string) (ServiceAccountRole, error) { @@ -22,6 +23,8 @@ func NewServiceAccountRole(name string) (ServiceAccountRole, error) { return ServiceAccountRoleComputeRead, nil case string(ServiceAccountRoleComputeWrite): return ServiceAccountRoleComputeWrite, nil + case string(ServiceAccountRoleComputeConnect): + return ServiceAccountRoleComputeConnect, nil case string(ServiceAccountRoleAdminRead): return ServiceAccountRoleAdminRead, nil case string(ServiceAccountRoleAdminWrite): @@ -35,6 +38,7 @@ func AllServiceAccountRoles() []ServiceAccountRole { return []ServiceAccountRole{ ServiceAccountRoleComputeRead, ServiceAccountRoleComputeWrite, + ServiceAccountRoleComputeConnect, ServiceAccountRoleAdminRead, ServiceAccountRoleAdminWrite, }