diff --git a/cmd/ssh-portal-api/main.go b/cmd/ssh-portal-api/main.go index 94c716fa..031c29f5 100644 --- a/cmd/ssh-portal-api/main.go +++ b/cmd/ssh-portal-api/main.go @@ -1,3 +1,4 @@ +// Package main is the executable ssh-portal-api service. package main import ( diff --git a/cmd/ssh-portal-api/serve.go b/cmd/ssh-portal-api/serve.go index 3936cab4..c2ad7f6b 100644 --- a/cmd/ssh-portal-api/serve.go +++ b/cmd/ssh-portal-api/serve.go @@ -10,6 +10,7 @@ import ( "github.com/uselagoon/ssh-portal/internal/keycloak" "github.com/uselagoon/ssh-portal/internal/lagoondb" "github.com/uselagoon/ssh-portal/internal/metrics" + "github.com/uselagoon/ssh-portal/internal/rbac" "github.com/uselagoon/ssh-portal/internal/sshportalapi" "go.uber.org/zap" ) @@ -20,6 +21,7 @@ type ServeCmd struct { APIDBDatabase string `kong:"default='infrastructure',env='API_DB_DATABASE',help='Lagoon API DB Database Name'"` APIDBPassword string `kong:"required,env='API_DB_PASSWORD',help='Lagoon API DB Password'"` APIDBUsername string `kong:"default='api',env='API_DB_USERNAME',help='Lagoon API DB Username'"` + BlockDeveloperSSH bool `kong:"env='BLOCK_DEVELOPER_SSH',help='Disallow Developer SSH access'"` KeycloakBaseURL string `kong:"required,env='KEYCLOAK_BASE_URL',help='Keycloak Base URL'"` KeycloakClientID string `kong:"default='service-api',env='KEYCLOAK_SERVICE_API_CLIENT_ID',help='Keycloak OAuth2 Client ID'"` KeycloakClientSecret string `kong:"required,env='KEYCLOAK_SERVICE_API_CLIENT_SECRET',help='Keycloak OAuth2 Client Secret'"` @@ -35,6 +37,13 @@ func (cmd *ServeCmd) Run(log *zap.Logger) error { // get main process context, which cancels on SIGTERM ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() + // init RBAC permission engine + var p *rbac.Permission + if cmd.BlockDeveloperSSH { + p = rbac.NewPermission(rbac.BlockDeveloperSSH()) + } else { + p = rbac.NewPermission() + } // init lagoon DB client dbConf := mysql.NewConfig() dbConf.Addr = cmd.APIDBAddress @@ -53,5 +62,5 @@ func (cmd *ServeCmd) Run(log *zap.Logger) error { return fmt.Errorf("couldn't init keycloak Client: %v", err) } // start serving NATS requests - return sshportalapi.ServeNATS(ctx, stop, log, l, k, cmd.NATSURL) + return sshportalapi.ServeNATS(ctx, stop, log, p, l, k, cmd.NATSURL) } diff --git a/cmd/ssh-token/serve.go b/cmd/ssh-token/serve.go index 2ab287c5..e4fa4fd4 100644 --- a/cmd/ssh-token/serve.go +++ b/cmd/ssh-token/serve.go @@ -11,6 +11,7 @@ import ( "github.com/uselagoon/ssh-portal/internal/keycloak" "github.com/uselagoon/ssh-portal/internal/lagoondb" "github.com/uselagoon/ssh-portal/internal/metrics" + "github.com/uselagoon/ssh-portal/internal/rbac" "github.com/uselagoon/ssh-portal/internal/sshtoken" "go.uber.org/zap" ) @@ -21,15 +22,16 @@ type ServeCmd struct { APIDBDatabase string `kong:"default='infrastructure',env='API_DB_DATABASE',help='Lagoon API DB Database Name'"` APIDBPassword string `kong:"required,env='API_DB_PASSWORD',help='Lagoon API DB Password'"` APIDBUsername string `kong:"default='api',env='API_DB_USERNAME',help='Lagoon API DB Username'"` + BlockDeveloperSSH bool `kong:"env='BLOCK_DEVELOPER_SSH',help='Disallow Developer SSH access'"` + HostKeyECDSA string `kong:"env='HOST_KEY_ECDSA',help='PEM encoded ECDSA host key'"` + HostKeyED25519 string `kong:"env='HOST_KEY_ED25519',help='PEM encoded Ed25519 host key'"` + HostKeyRSA string `kong:"env='HOST_KEY_RSA',help='PEM encoded RSA host key'"` KeycloakBaseURL string `kong:"required,env='KEYCLOAK_BASE_URL',help='Keycloak Base URL'"` - KeycloakTokenClientID string `kong:"default='auth-server',env='KEYCLOAK_AUTH_SERVER_CLIENT_ID',help='Keycloak auth-server OAuth2 Client ID'"` - KeycloakTokenClientSecret string `kong:"required,env='KEYCLOAK_AUTH_SERVER_CLIENT_SECRET',help='Keycloak auth-server OAuth2 Client Secret'"` KeycloakPermissionClientID string `kong:"default='service-api',env='KEYCLOAK_SERVICE_API_CLIENT_ID',help='Keycloak service-api OAuth2 Client ID'"` KeycloakPermissionClientSecret string `kong:"env='KEYCLOAK_SERVICE_API_CLIENT_SECRET',help='Keycloak service-api OAuth2 Client Secret'"` + KeycloakTokenClientID string `kong:"default='auth-server',env='KEYCLOAK_AUTH_SERVER_CLIENT_ID',help='Keycloak auth-server OAuth2 Client ID'"` + KeycloakTokenClientSecret string `kong:"required,env='KEYCLOAK_AUTH_SERVER_CLIENT_SECRET',help='Keycloak auth-server OAuth2 Client Secret'"` SSHServerPort uint `kong:"default='2222',env='SSH_SERVER_PORT',help='Port the SSH server will listen on for SSH client connections'"` - HostKeyECDSA string `kong:"env='HOST_KEY_ECDSA',help='PEM encoded ECDSA host key'"` - HostKeyED25519 string `kong:"env='HOST_KEY_ED25519',help='PEM encoded Ed25519 host key'"` - HostKeyRSA string `kong:"env='HOST_KEY_RSA',help='PEM encoded RSA host key'"` } // Run the serve command to ssh-portal API requests. @@ -41,6 +43,13 @@ func (cmd *ServeCmd) Run(log *zap.Logger) error { // get main process context, which cancels on SIGTERM ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() + // init RBAC permission engine + var p *rbac.Permission + if cmd.BlockDeveloperSSH { + p = rbac.NewPermission(rbac.BlockDeveloperSSH()) + } else { + p = rbac.NewPermission() + } // init lagoon DB client dbConf := mysql.NewConfig() dbConf.Addr = cmd.APIDBAddress @@ -78,6 +87,6 @@ func (cmd *ServeCmd) Run(log *zap.Logger) error { } } // start serving SSH token requests - return sshtoken.Serve(ctx, log, l, ldb, keycloakToken, keycloakPermission, + return sshtoken.Serve(ctx, log, l, p, ldb, keycloakToken, keycloakPermission, hostkeys) } diff --git a/internal/permission/usercansshtoenvironment_test.go b/internal/permission/usercansshtoenvironment_test.go deleted file mode 100644 index 7f860bc8..00000000 --- a/internal/permission/usercansshtoenvironment_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package permission_test - -import ( - "context" - "testing" - - "github.com/uselagoon/ssh-portal/internal/lagoon" - "github.com/uselagoon/ssh-portal/internal/lagoondb" - "github.com/uselagoon/ssh-portal/internal/permission" -) - -type args struct { - env *lagoondb.Environment - realmRoles []string - userGroups []string - groupProjectIDs map[string][]int -} - -func TestUserCanSSH(t *testing.T) { - var testCases = map[string]struct { - input *args - expect bool - }{ - "wrong project": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/project-foo/project-foo-maintainer", - }, - groupProjectIDs: map[string][]int{ - "project-foo": {3}, - }, - }, expect: false}, - "right project": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/project-bar/project-bar-maintainer", - }, - groupProjectIDs: map[string][]int{ - "project-bar": {4}, - }, - }, expect: true}, - "not group member": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/customer-a/customer-a-maintainer", - }, - groupProjectIDs: map[string][]int{ - "customer-b": {4}, - }, - }, expect: false}, - "group member": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/customer-b/customer-b-maintainer", - }, - groupProjectIDs: map[string][]int{ - "customer-b": {4}, - }, - }, expect: true}, - "platform-owner": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - "platform-owner", - }, - userGroups: []string{ - "/lagoonadmin", - }, - }, expect: true}, - "developer can't ssh to prod": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/customer-b/customer-b-developer", - }, - groupProjectIDs: map[string][]int{ - "customer-b": {4}, - }, - }, expect: false}, - "developer can ssh to dev": {input: &args{ - env: &lagoondb.Environment{ - Name: "pr-123", - NamespaceName: "project-bar-pr-123", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Development, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/customer-b/customer-b-developer", - }, - groupProjectIDs: map[string][]int{ - "customer-b": {4}, - }, - }, expect: true}, - "owner can ssh to prod": {input: &args{ - env: &lagoondb.Environment{ - Name: "production", - NamespaceName: "project-bar-production", - ProjectID: 4, - ProjectName: "project-bar", - Type: lagoon.Production, - }, - realmRoles: []string{ - "offline_access", - "uma_authorization", - }, - userGroups: []string{ - "/customer-b/customer-b-owner", - }, - groupProjectIDs: map[string][]int{ - "customer-b": {4}, - }, - }, expect: true}, - } - for name, tc := range testCases { - t.Run(name, func(tt *testing.T) { - response := permission.UserCanSSHToEnvironment(context.Background(), - tc.input.env, tc.input.realmRoles, tc.input.userGroups, - tc.input.groupProjectIDs) - if response != tc.expect { - tt.Fatalf("expected %v, got %v", tc.expect, response) - } - }) - } -} diff --git a/internal/rbac/permission.go b/internal/rbac/permission.go new file mode 100644 index 00000000..a5d0ec09 --- /dev/null +++ b/internal/rbac/permission.go @@ -0,0 +1,44 @@ +// Package rbac contains permission logic for Lagoon. +package rbac + +import "github.com/uselagoon/ssh-portal/internal/lagoon" + +// Permission encapsulates the permission logic for Lagoon. +// This object should not be constructed by itself, only via NewPermission(). +type Permission struct { + envTypeRoleCanSSH map[lagoon.EnvironmentType][]lagoon.UserRole +} + +// Option performs optional configuration on Permission objects during +// initialization, and is passed to NewPermission(). +type Option func(*Permission) + +// BlockDeveloperSSH configures the Permission object returned by +// NewPermission() to disallow Developer SSH access to Lagoon environments. +// Instead, only Maintainers and Owners can SSH to either Development or +// Production environments. +func BlockDeveloperSSH() Option { + return func(p *Permission) { + p.envTypeRoleCanSSH = map[lagoon.EnvironmentType][]lagoon.UserRole{ + lagoon.Development: { + lagoon.Maintainer, + lagoon.Owner, + }, + lagoon.Production: { + lagoon.Maintainer, + lagoon.Owner, + }, + } + } +} + +// NewPermission applies the given Options and returns a new Permission object. +func NewPermission(opts ...Option) *Permission { + p := Permission{ + envTypeRoleCanSSH: defaultEnvTypeRoleCanSSH, + } + for _, opt := range opts { + opt(&p) + } + return &p +} diff --git a/internal/permission/usercansshtoenvironment.go b/internal/rbac/usercansshtoenvironment.go similarity index 72% rename from internal/permission/usercansshtoenvironment.go rename to internal/rbac/usercansshtoenvironment.go index 713f619c..7a0d3a64 100644 --- a/internal/permission/usercansshtoenvironment.go +++ b/internal/rbac/usercansshtoenvironment.go @@ -1,4 +1,4 @@ -package permission +package rbac import ( "context" @@ -9,10 +9,20 @@ import ( "go.opentelemetry.io/otel" ) -const pkgName = "github.com/uselagoon/ssh-portal/internal/permission" +const pkgName = "github.com/uselagoon/ssh-portal/internal/rbac" -// map environment type to role which can SSH -var envTypeRoleCanSSH = map[lagoon.EnvironmentType][]lagoon.UserRole{ +// Default permission map of environment type to roles which can SSH. +// +// By default: +// - Developer and higher can SSH to development environments. +// - Maintainer and higher can SSH to production environments. +// +// See https://docs.lagoon.sh/administering-lagoon/rbac/#group-roles for more +// information. +// +// Note that this does not affect the platform-owner role, which can always SSH +// to any environment. +var defaultEnvTypeRoleCanSSH = map[lagoon.EnvironmentType][]lagoon.UserRole{ lagoon.Development: { lagoon.Developer, lagoon.Maintainer, @@ -27,7 +37,7 @@ var envTypeRoleCanSSH = map[lagoon.EnvironmentType][]lagoon.UserRole{ // UserCanSSHToEnvironment returns true if the given environment can be // connected to via SSH by the user with the given realm roles and user groups, // and false otherwise. -func UserCanSSHToEnvironment(ctx context.Context, env *lagoondb.Environment, +func (p *Permission) UserCanSSHToEnvironment(ctx context.Context, env *lagoondb.Environment, realmRoles, userGroups []string, groupProjectIDs map[string][]int) bool { // set up tracing _, span := otel.Tracer(pkgName).Start(ctx, "UserCanSSHToEnvironment") @@ -38,7 +48,7 @@ func UserCanSSHToEnvironment(ctx context.Context, env *lagoondb.Environment, return true } } - validRoles := envTypeRoleCanSSH[env.Type] + validRoles := p.envTypeRoleCanSSH[env.Type] // check if the user is directly a member of the project group and has the // required role var validProjectGroups []string diff --git a/internal/rbac/usercansshtoenvironment_test.go b/internal/rbac/usercansshtoenvironment_test.go new file mode 100644 index 00000000..171ce2b0 --- /dev/null +++ b/internal/rbac/usercansshtoenvironment_test.go @@ -0,0 +1,355 @@ +package rbac_test + +import ( + "context" + "testing" + + "github.com/uselagoon/ssh-portal/internal/lagoon" + "github.com/uselagoon/ssh-portal/internal/lagoondb" + "github.com/uselagoon/ssh-portal/internal/rbac" +) + +type args struct { + env *lagoondb.Environment + realmRoles []string + userGroups []string + groupProjectIDs map[string][]int +} + +func TestUserCanSSHDefaultRBAC(t *testing.T) { + var testCases = map[string]struct { + input *args + expect bool + }{ + "wrong project": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/project-foo/project-foo-maintainer", + }, + groupProjectIDs: map[string][]int{ + "project-foo": {3}, + }, + }, expect: false}, + "right project": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/project-bar/project-bar-maintainer", + }, + groupProjectIDs: map[string][]int{ + "project-bar": {4}, + }, + }, expect: true}, + "not group member": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-a/customer-a-maintainer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: false}, + "group member": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-maintainer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: true}, + "platform-owner": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + "platform-owner", + }, + userGroups: []string{ + "/lagoonadmin", + }, + }, expect: true}, + "developer can't ssh to prod": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-developer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: false}, + "developer can ssh to dev": {input: &args{ + env: &lagoondb.Environment{ + Name: "pr-123", + NamespaceName: "project-bar-pr-123", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Development, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-developer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: true}, + "owner can ssh to prod": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-owner", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: true}, + } + p := rbac.NewPermission() + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + response := p.UserCanSSHToEnvironment(context.Background(), + tc.input.env, tc.input.realmRoles, tc.input.userGroups, + tc.input.groupProjectIDs) + if response != tc.expect { + tt.Fatalf("expected %v, got %v", tc.expect, response) + } + }) + } +} + +func TestUserCanSSHCustomRBAC(t *testing.T) { + var testCases = map[string]struct { + input *args + expect bool + }{ + "wrong project": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/project-foo/project-foo-maintainer", + }, + groupProjectIDs: map[string][]int{ + "project-foo": {3}, + }, + }, expect: false}, + "right project": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/project-bar/project-bar-maintainer", + }, + groupProjectIDs: map[string][]int{ + "project-bar": {4}, + }, + }, expect: true}, + "not group member": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-a/customer-a-maintainer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: false}, + "group member": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-maintainer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: true}, + "platform-owner": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + "platform-owner", + }, + userGroups: []string{ + "/lagoonadmin", + }, + }, expect: true}, + "developer can't ssh to prod": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-developer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: false}, + "developer can NOT ssh to dev": {input: &args{ + env: &lagoondb.Environment{ + Name: "pr-123", + NamespaceName: "project-bar-pr-123", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Development, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-developer", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: false}, + "owner can ssh to prod": {input: &args{ + env: &lagoondb.Environment{ + Name: "production", + NamespaceName: "project-bar-production", + ProjectID: 4, + ProjectName: "project-bar", + Type: lagoon.Production, + }, + realmRoles: []string{ + "offline_access", + "uma_authorization", + }, + userGroups: []string{ + "/customer-b/customer-b-owner", + }, + groupProjectIDs: map[string][]int{ + "customer-b": {4}, + }, + }, expect: true}, + } + p := rbac.NewPermission(rbac.BlockDeveloperSSH()) + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + response := p.UserCanSSHToEnvironment(context.Background(), + tc.input.env, tc.input.realmRoles, tc.input.userGroups, + tc.input.groupProjectIDs) + if response != tc.expect { + tt.Fatalf("expected %v, got %v", tc.expect, response) + } + }) + } +} diff --git a/internal/sshportalapi/server.go b/internal/sshportalapi/server.go index ae0fd5f3..444f9201 100644 --- a/internal/sshportalapi/server.go +++ b/internal/sshportalapi/server.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/nats-io/nats.go" "github.com/uselagoon/ssh-portal/internal/lagoondb" + "github.com/uselagoon/ssh-portal/internal/rbac" "go.uber.org/zap" ) @@ -31,7 +32,7 @@ type KeycloakService interface { // ServeNATS sshportalapi NATS requests. func ServeNATS(ctx context.Context, stop context.CancelFunc, log *zap.Logger, - l LagoonDBService, k KeycloakService, natsURL string) error { + p *rbac.Permission, l LagoonDBService, k KeycloakService, natsURL string) error { // setup synchronisation wg := sync.WaitGroup{} wg.Add(1) @@ -60,7 +61,7 @@ func ServeNATS(ctx context.Context, stop context.CancelFunc, log *zap.Logger, defer nc.Close() // set up request/response callback for sshportal _, err = nc.QueueSubscribe(SubjectSSHAccessQuery, queue, - sshportal(ctx, log, nc, l, k)) + sshportal(ctx, log, nc, p, l, k)) if err != nil { return fmt.Errorf("couldn't subscribe to queue: %v", err) } diff --git a/internal/sshportalapi/sshportal.go b/internal/sshportalapi/sshportal.go index e0fad7be..61418640 100644 --- a/internal/sshportalapi/sshportal.go +++ b/internal/sshportalapi/sshportal.go @@ -8,7 +8,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/uselagoon/ssh-portal/internal/lagoondb" - "github.com/uselagoon/ssh-portal/internal/permission" + "github.com/uselagoon/ssh-portal/internal/rbac" "go.opentelemetry.io/otel" "go.uber.org/zap" ) @@ -35,7 +35,7 @@ var ( ) func sshportal(ctx context.Context, log *zap.Logger, c *nats.EncodedConn, - l LagoonDBService, k KeycloakService) nats.Handler { + p *rbac.Permission, l LagoonDBService, k KeycloakService) nats.Handler { return func(_, replySubject string, query *SSHAccessQuery) { var realmRoles, userGroups []string var groupProjectIDs map[string][]int @@ -111,8 +111,8 @@ func sshportal(ctx context.Context, log *zap.Logger, c *nats.EncodedConn, zap.Error(err)) return } - // calculate permission - ok := permission.UserCanSSHToEnvironment(ctx, env, realmRoles, userGroups, + // check permission + ok := p.UserCanSSHToEnvironment(ctx, env, realmRoles, userGroups, groupProjectIDs) if ok { log.Info("validated SSH access", diff --git a/internal/sshtoken/serve.go b/internal/sshtoken/serve.go index 1d023359..4923dc02 100644 --- a/internal/sshtoken/serve.go +++ b/internal/sshtoken/serve.go @@ -11,6 +11,7 @@ import ( "github.com/gliderlabs/ssh" "github.com/uselagoon/ssh-portal/internal/keycloak" "github.com/uselagoon/ssh-portal/internal/lagoondb" + "github.com/uselagoon/ssh-portal/internal/rbac" "go.uber.org/zap" ) @@ -26,10 +27,11 @@ type LagoonDBService interface { // Serve contains the main ssh session logic func Serve(ctx context.Context, log *zap.Logger, l net.Listener, - ldb *lagoondb.Client, keycloakToken, keycloakPermission *keycloak.Client, + p *rbac.Permission, ldb *lagoondb.Client, + keycloakToken, keycloakPermission *keycloak.Client, hostKeys [][]byte) error { srv := ssh.Server{ - Handler: sessionHandler(log, keycloakToken, keycloakPermission, ldb), + Handler: sessionHandler(log, p, keycloakToken, keycloakPermission, ldb), PublicKeyHandler: pubKeyAuth(log, ldb), } for _, hk := range hostKeys { diff --git a/internal/sshtoken/sessionhandler.go b/internal/sshtoken/sessionhandler.go index ea6b5b3f..033d5185 100644 --- a/internal/sshtoken/sessionhandler.go +++ b/internal/sshtoken/sessionhandler.go @@ -10,7 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/uselagoon/ssh-portal/internal/lagoondb" - "github.com/uselagoon/ssh-portal/internal/permission" + "github.com/uselagoon/ssh-portal/internal/rbac" "go.uber.org/zap" ) @@ -21,9 +21,9 @@ type KeycloakTokenService interface { UserAccessToken(context.Context, *uuid.UUID) (string, error) } -// KeycloakPermissionService provides methods for querying the Keycloak API for +// KeycloakUserInfoService provides methods for querying the Keycloak API for // permission information contained in service-api user tokens. -type KeycloakPermissionService interface { +type KeycloakUserInfoService interface { UserRolesAndGroups(context.Context, *uuid.UUID) ([]string, []string, map[string][]int, error) } @@ -60,7 +60,8 @@ func tokenSession(s ssh.Session, log *zap.Logger, zap.String("sessionID", sid), zap.String("userUUID", uid.String())) _, err := fmt.Fprintf(s.Stderr(), - "invalid command: only \"grant\" and \"token\" are supported. SID: %s\n", sid) + "invalid command: only \"grant\" and \"token\" are supported. SID: %s\r\n", + sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -81,7 +82,7 @@ func tokenSession(s ssh.Session, log *zap.Logger, zap.String("userUUID", uid.String()), zap.Error(err)) _, err = fmt.Fprintf(s.Stderr(), - "internal error. SID: %s\n", sid) + "internal error. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -98,7 +99,7 @@ func tokenSession(s ssh.Session, log *zap.Logger, zap.String("userUUID", uid.String()), zap.Error(err)) _, err = fmt.Fprintf(s.Stderr(), - "internal error. SID: %s\n", sid) + "internal error. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -113,7 +114,8 @@ func tokenSession(s ssh.Session, log *zap.Logger, zap.String("sessionID", sid), zap.String("userUUID", uid.String())) _, err := fmt.Fprintf(s.Stderr(), - "invalid command: only \"grant\" and \"token\" are supported. SID: %s\n", sid) + "invalid command: only \"grant\" and \"token\" are supported. SID: %s\r\n", + sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -123,7 +125,7 @@ func tokenSession(s ssh.Session, log *zap.Logger, return } // send response - _, err = fmt.Fprintf(s, "%s\n", response) + _, err = fmt.Fprintf(s, "%s\r\n", response) if err != nil { log.Debug("couldn't write response to session stream", zap.String("sessionID", sid), @@ -142,19 +144,19 @@ func tokenSession(s ssh.Session, log *zap.Logger, // endpoint to use for ssh shell access. If the user doesn't have access to the // environment a generic error message is returned. func redirectSession(s ssh.Session, log *zap.Logger, - keycloakPermission KeycloakPermissionService, ldb LagoonDBService, - uid *uuid.UUID) { + p *rbac.Permission, keycloakUserInfo KeycloakUserInfoService, + ldb LagoonDBService, uid *uuid.UUID) { sid := s.Context().SessionID() // get the user roles and groups realmRoles, userGroups, groupProjectIDs, err := - keycloakPermission.UserRolesAndGroups(s.Context(), uid) + keycloakUserInfo.UserRolesAndGroups(s.Context(), uid) if err != nil { log.Error("couldn't query user roles and groups", zap.String("sessionID", sid), zap.String("userUUID", uid.String()), zap.Error(err)) _, err = fmt.Fprintf(s.Stderr(), - "This SSH server does not provide shell access. SID: %s\n", sid) + "This SSH server does not provide shell access. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -179,7 +181,7 @@ func redirectSession(s ssh.Session, log *zap.Logger, zap.Error(err)) } _, err = fmt.Fprintf(s.Stderr(), - "This SSH server does not provide shell access. SID: %s\n", sid) + "This SSH server does not provide shell access. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -188,8 +190,8 @@ func redirectSession(s ssh.Session, log *zap.Logger, } return } - // calculate permission - ok := permission.UserCanSSHToEnvironment(s.Context(), env, realmRoles, + // check permission + ok := p.UserCanSSHToEnvironment(s.Context(), env, realmRoles, userGroups, groupProjectIDs) if !ok { log.Info("user cannot SSH to environment", @@ -206,7 +208,7 @@ func redirectSession(s ssh.Session, log *zap.Logger, zap.Strings("userGroups", userGroups), zap.Any("groupProjectIDs", groupProjectIDs)) _, err = fmt.Fprintf(s.Stderr(), - "This SSH server does not provide shell access. SID: %s\n", sid) + "This SSH server does not provide shell access. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -246,7 +248,7 @@ func redirectSession(s ssh.Session, log *zap.Logger, zap.Error(err)) } _, err = fmt.Fprintf(s.Stderr(), - "This SSH server does not provide shell access. SID: %s\n", sid) + "This SSH server does not provide shell access. SID: %s\r\n", sid) if err != nil { log.Debug("couldn't write error message to session stream", zap.String("sessionID", sid), @@ -256,16 +258,16 @@ func redirectSession(s ssh.Session, log *zap.Logger, return } preamble := - "This SSH server does not provide shell access to your environment.\n" + - "To SSH into your environment use this endpoint:\n\n" + "This SSH server does not provide shell access to your environment.\r\n" + + "To SSH into your environment use this endpoint:\r\n\n" // send response if sshPort == "22" { _, err = fmt.Fprintf(s.Stderr(), - preamble+"\tssh %s@%s\n\nSID: %s\n", + preamble+"\tssh %s@%s\r\n\nSID: %s\r\n", s.User(), sshHost, sid) } else { _, err = fmt.Fprintf(s.Stderr(), - preamble+"\tssh -p %s %s@%s\n\nSID: %s\n", + preamble+"\tssh -p %s %s@%s\r\n\nSID: %s\r\n", sshPort, s.User(), sshHost, sid) } if err != nil { @@ -286,8 +288,9 @@ func redirectSession(s ssh.Session, log *zap.Logger, // sessionHandler returns a ssh.Handler which writes a Lagoon access token to // the session stream and then closes the connection. -func sessionHandler(log *zap.Logger, keycloakToken KeycloakTokenService, - keycloakPermission KeycloakPermissionService, +func sessionHandler(log *zap.Logger, p *rbac.Permission, + keycloakToken KeycloakTokenService, + keycloakPermission KeycloakUserInfoService, ldb LagoonDBService) ssh.Handler { return func(s ssh.Session) { sessionTotal.Inc() @@ -296,7 +299,7 @@ func sessionHandler(log *zap.Logger, keycloakToken KeycloakTokenService, if !ok { log.Warn("couldn't get user UUID from context", zap.String("sessionID", s.Context().SessionID())) - _, err := fmt.Fprintf(s.Stderr(), "internal error. SID: %s\n", + _, err := fmt.Fprintf(s.Stderr(), "internal error. SID: %s\r\n", s.Context().SessionID()) if err != nil { log.Debug("couldn't write error message to session stream", @@ -308,7 +311,7 @@ func sessionHandler(log *zap.Logger, keycloakToken KeycloakTokenService, if s.User() == "lagoon" { tokenSession(s, log, keycloakToken, uid) } else { - redirectSession(s, log, keycloakPermission, ldb, uid) + redirectSession(s, log, p, keycloakPermission, ldb, uid) } } }