From 801981bb81a9086d70460ccbc7f5bc001e8ce717 Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Mon, 1 Jul 2024 18:03:05 +0800 Subject: [PATCH 1/2] chore: refactor query construction Avoid redundant whitespace. --- internal/lagoondb/client.go | 52 ++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/lagoondb/client.go b/internal/lagoondb/client.go index b4f6f3cd..9e4a3128 100644 --- a/internal/lagoondb/client.go +++ b/internal/lagoondb/client.go @@ -61,7 +61,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 +71,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 +102,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 +128,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 +149,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 From 7a899968ab229c764f2829b0d6c805c23015935b Mon Sep 17 00:00:00 2001 From: Scott Leggett Date: Mon, 1 Jul 2024 18:04:27 +0800 Subject: [PATCH 2/2] feat: update last_used timestamp for SSH keys Update the last_used timestamp to the current time whenever an SSH key is used to either generate a token or query exec permissions on a Lagoon environment. The timestamp is updated every time the key is used, regardless of whether or not the permission check or token generation succeeds. Timestamps are converted and stored in UTC. --- go.mod | 1 + go.sum | 3 ++ internal/lagoondb/client.go | 27 +++++++++++++ internal/lagoondb/client_test.go | 61 ++++++++++++++++++++++++++++++ internal/lagoondb/helper_test.go | 11 ++++++ internal/sshportalapi/server.go | 2 + internal/sshportalapi/sshportal.go | 7 ++++ internal/sshtoken/authhandler.go | 7 ++++ internal/sshtoken/serve.go | 1 + 9 files changed, 120 insertions(+) create mode 100644 internal/lagoondb/client_test.go create mode 100644 internal/lagoondb/helper_test.go 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 9e4a3128..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" @@ -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