diff --git a/go.mod b/go.mod index 750a9afb..ad68b414 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/uselagoon/ssh-portal go 1.22.2 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/alecthomas/assert/v2 v2.10.0 github.com/alecthomas/kong v0.9.0 diff --git a/go.sum b/go.sum index 566caf33..f4355058 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= @@ -79,6 +81,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= diff --git a/internal/lagoondb/client.go b/internal/lagoondb/client.go index b4f6f3cd..fa8255cc 100644 --- a/internal/lagoondb/client.go +++ b/internal/lagoondb/client.go @@ -5,6 +5,7 @@ import ( "context" "database/sql" "errors" + "fmt" "time" "github.com/google/uuid" @@ -61,7 +62,7 @@ func NewClient(ctx context.Context, dsn string) (*Client, error) { } // EnvironmentByNamespaceName returns the Environment associated with the given -// Namespace name (on Openshift this is the project name). +// Namespace name. func (c *Client) EnvironmentByNamespaceName( ctx context.Context, name string, @@ -71,18 +72,17 @@ func (c *Client) EnvironmentByNamespaceName( defer span.End() // run query env := Environment{} - err := c.db.GetContext(ctx, &env, ` - SELECT - environment.environment_type AS type, - environment.id AS id, - environment.name AS name, - environment.openshift_project_name AS namespace_name, - project.id AS project_id, - project.name AS project_name - FROM environment JOIN project ON environment.project = project.id - WHERE environment.openshift_project_name = ? - AND environment.deleted = '0000-00-00 00:00:00' - LIMIT 1`, name) + err := c.db.GetContext(ctx, &env, + `SELECT environment.environment_type AS type, `+ + `environment.id AS id, `+ + `environment.name AS name, `+ + `environment.openshift_project_name AS namespace_name, `+ + `project.id AS project_id, `+ + `project.name AS project_name `+ + `FROM environment JOIN project ON environment.project = project.id `+ + `WHERE environment.openshift_project_name = ? `+ + `AND environment.deleted = '0000-00-00 00:00:00' `+ + `LIMIT 1`, name) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoResult @@ -103,10 +103,11 @@ func (c *Client) UserBySSHFingerprint( defer span.End() // run query user := User{} - err := c.db.GetContext(ctx, &user, ` - SELECT user_ssh_key.usid AS uuid - FROM user_ssh_key JOIN ssh_key ON user_ssh_key.skid = ssh_key.id - WHERE ssh_key.key_fingerprint = ?`, fingerprint) + err := c.db.GetContext(ctx, &user, + `SELECT user_ssh_key.usid AS uuid `+ + `FROM user_ssh_key JOIN ssh_key ON user_ssh_key.skid = ssh_key.id `+ + `WHERE ssh_key.key_fingerprint = ?`, + fingerprint) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoResult @@ -128,12 +129,12 @@ func (c *Client) SSHEndpointByEnvironmentID(ctx context.Context, Host string `db:"ssh_host"` Port string `db:"ssh_port"` }{} - err := c.db.GetContext(ctx, &ssh, ` - SELECT - openshift.ssh_host AS ssh_host, - openshift.ssh_port AS ssh_port - FROM environment JOIN openshift ON environment.openshift = openshift.id - WHERE environment.id = ?`, envID) + err := c.db.GetContext(ctx, &ssh, + `SELECT openshift.ssh_host AS ssh_host, `+ + `openshift.ssh_port AS ssh_port `+ + `FROM environment JOIN openshift ON environment.openshift = openshift.id `+ + `WHERE environment.id = ?`, + envID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNoResult @@ -149,9 +150,9 @@ func (c *Client) GroupIDProjectIDsMap( ctx context.Context, ) (map[string][]int, error) { var gpms []groupProjectMapping - err := c.db.SelectContext(ctx, &gpms, ` - SELECT group_id, project_id - FROM kc_group_projects`) + err := c.db.SelectContext(ctx, &gpms, + `SELECT group_id, project_id `+ + `FROM kc_group_projects`) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNoResult @@ -167,3 +168,29 @@ func (c *Client) GroupIDProjectIDsMap( } return groupIDProjectIDsMap, nil } + +// SSHKeyUsed sets the last_used attribute of the ssh key identified by the +// given fingerprint to used. +// +// The value of used is converted to UTC before being stored in a DATETIME +// column in the MySQL database. +func (c *Client) SSHKeyUsed( + ctx context.Context, + fingerprint string, + used time.Time, +) error { + // set up tracing + ctx, span := otel.Tracer(pkgName).Start(ctx, "SSHKeyUsed") + defer span.End() + _, err := c.db.ExecContext(ctx, + `UPDATE ssh_key `+ + `SET last_used = ? `+ + `WHERE key_fingerprint = ?`, + used.UTC().Format(time.DateTime), + fingerprint) + if err != nil { + return fmt.Errorf("couldn't update last_used for key_fingerprint=%s: %v", + fingerprint, err) + } + return nil +} diff --git a/internal/lagoondb/client_test.go b/internal/lagoondb/client_test.go new file mode 100644 index 00000000..1b74cf93 --- /dev/null +++ b/internal/lagoondb/client_test.go @@ -0,0 +1,61 @@ +package lagoondb_test + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alecthomas/assert/v2" + "github.com/uselagoon/ssh-portal/internal/lagoondb" +) + +func TestLastUsed(t *testing.T) { + var testCases = map[string]struct { + fingerprint string + used time.Time + usedString string + expectError bool + }{ + "right time": { + fingerprint: "SHA256:yARVMVDnP2B2QzTvE8eSs5ZZlkZEoMFEIKjtYv1adfU", + used: time.Unix(1719825567, 0), + usedString: "2024-07-01 09:19:27", + expectError: false, + }, + "wrong time": { + fingerprint: "SHA256:yARVMVDnP2B2QzTvE8eSs5ZZlkZEoMFEIKjtYv1adfU", + used: time.Unix(1719825567, 0), + usedString: "2024-07-01 17:19:27", + expectError: true, + }, + } + for name, tc := range testCases { + t.Run(name, func(tt *testing.T) { + // set up mocks + mockDB, mock, err := sqlmock.New() + assert.NoError(tt, err, name) + mock.ExpectExec( + `UPDATE ssh_key `+ + `SET last_used = (.+) `+ + `WHERE key_fingerprint = (.+)`). + WithArgs(tc.usedString, tc.fingerprint). + WillReturnResult(sqlmock.NewErrorResult(nil)) + // execute expected database operations + db := lagoondb.NewClientFromDB(mockDB) + err = db.SSHKeyUsed(context.Background(), tc.fingerprint, tc.used) + if tc.expectError { + assert.Error(tt, err, name) + } else { + assert.NoError(tt, err, name) + } + // check expectations + err = mock.ExpectationsWereMet() + if tc.expectError { + assert.Error(tt, err, name) + } else { + assert.NoError(tt, err, name) + } + }) + } +} diff --git a/internal/lagoondb/helper_test.go b/internal/lagoondb/helper_test.go new file mode 100644 index 00000000..072aa880 --- /dev/null +++ b/internal/lagoondb/helper_test.go @@ -0,0 +1,11 @@ +package lagoondb + +import ( + "database/sql" + + "github.com/jmoiron/sqlx" +) + +func NewClientFromDB(db *sql.DB) *Client { + return &Client{db: sqlx.NewDb(db, "mysql")} +} diff --git a/internal/sshportalapi/server.go b/internal/sshportalapi/server.go index d3f60b89..bdbd78cd 100644 --- a/internal/sshportalapi/server.go +++ b/internal/sshportalapi/server.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "sync" + "time" "github.com/google/uuid" "github.com/nats-io/nats.go" @@ -25,6 +26,7 @@ type LagoonDBService interface { lagoon.DBService EnvironmentByNamespaceName(context.Context, string) (*lagoondb.Environment, error) UserBySSHFingerprint(context.Context, string) (*lagoondb.User, error) + SSHKeyUsed(context.Context, string, time.Time) error } // KeycloakService provides methods for querying the Keycloak API. diff --git a/internal/sshportalapi/sshportal.go b/internal/sshportalapi/sshportal.go index 7059b54d..aaf7fc11 100644 --- a/internal/sshportalapi/sshportal.go +++ b/internal/sshportalapi/sshportal.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "time" "github.com/nats-io/nats.go" "github.com/prometheus/client_golang/prometheus" @@ -106,6 +107,12 @@ func sshportal( log.Error("couldn't query user by ssh fingerprint", slog.Any("error", err)) return } + // update last_used + if err := l.SSHKeyUsed(ctx, query.SSHFingerprint, time.Now()); err != nil { + log.Error("couldn't update ssh key last used: %v", + slog.Any("error", err)) + return + } // get the user roles and groups realmRoles, userGroups, err = k.UserRolesAndGroups(ctx, user.UUID) if err != nil { diff --git a/internal/sshtoken/authhandler.go b/internal/sshtoken/authhandler.go index c32ec40c..a5273bd9 100644 --- a/internal/sshtoken/authhandler.go +++ b/internal/sshtoken/authhandler.go @@ -3,6 +3,7 @@ package sshtoken import ( "errors" "log/slog" + "time" "github.com/gliderlabs/ssh" "github.com/prometheus/client_golang/prometheus" @@ -53,6 +54,12 @@ func pubKeyAuth(log *slog.Logger, ldb LagoonDBService) ssh.PublicKeyHandler { } return false } + // update last_used + if err := ldb.SSHKeyUsed(ctx, fingerprint, time.Now()); err != nil { + log.Error("couldn't update ssh key last used: %v", + slog.Any("error", err)) + return false + } // The SSH key fingerprint was in the database so "authentication" was // successful. Inject the user UUID into the context so it can be used in // the session handler. diff --git a/internal/sshtoken/serve.go b/internal/sshtoken/serve.go index 03328bcd..06c52dce 100644 --- a/internal/sshtoken/serve.go +++ b/internal/sshtoken/serve.go @@ -25,6 +25,7 @@ type LagoonDBService interface { EnvironmentByNamespaceName(context.Context, string) (*lagoondb.Environment, error) UserBySSHFingerprint(context.Context, string) (*lagoondb.User, error) SSHEndpointByEnvironmentID(context.Context, int) (string, string, error) + SSHKeyUsed(context.Context, string, time.Time) error } // Serve contains the main ssh session logic