Skip to content

Commit

Permalink
feat: add sshkeys manager (#1519)
Browse files Browse the repository at this point in the history
* feat: add sshkey manager

* chore: wire up ssh key manager

* chore: add missing godoc for PublicKey
  • Loading branch information
kian99 authored Jan 14, 2025
1 parent 253320e commit 7a29162
Show file tree
Hide file tree
Showing 7 changed files with 314 additions and 0 deletions.
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ linters-settings:
goheader:
template: |-
Copyright {{MOD-YEAR}} Canonical.
importas:
no-unaliased: false
no-extra-aliases: false
Expand Down
22 changes: 22 additions & 0 deletions internal/jimm/jimm.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/canonical/jimm/v3/internal/jimm/permissions"
"github.com/canonical/jimm/v3/internal/jimm/role"
"github.com/canonical/jimm/v3/internal/jimm/serviceaccount"
"github.com/canonical/jimm/v3/internal/jimm/sshkeys"
"github.com/canonical/jimm/v3/internal/jimmjwx"
"github.com/canonical/jimm/v3/internal/openfga"
ofganames "github.com/canonical/jimm/v3/internal/openfga/names"
Expand Down Expand Up @@ -242,6 +243,18 @@ type ServiceAccountManager interface {
CopyServiceAccountCredential(ctx context.Context, u *openfga.User, svcAcc *openfga.User, cred names.CloudCredentialTag) (names.CloudCredentialTag, []jujuparams.UpdateCredentialModelResult, error)
}

// SSHKeyManager provides a means to manage SSH keys within JIMM.
type SSHKeyManager interface {
// AddUserPublicKey saves a user's public key.
AddUserPublicKey(ctx context.Context, user *openfga.User, publicKey sshkeys.PublicKey) error
// ListUserPublicKeys lists a user's public keys.
ListUserPublicKeys(ctx context.Context, user *openfga.User) ([]sshkeys.PublicKey, error)
// RemoveUserKeyByComment removes a user's public key(s) by the key comment.
RemoveUserKeyByComment(ctx context.Context, user *openfga.User, comment string) error
// RemoveUserKeyByFingerprint removes a user's public key(s) by the key fingerprint.
RemoveUserKeyByFingerprint(ctx context.Context, user *openfga.User, fingerprint string) error
}

// Parameters holds the services and static fields passed to the jimm.New() constructor.
// You can provide mock implementations of certain services where necessary for dependency injection.
type Parameters struct {
Expand Down Expand Up @@ -383,6 +396,12 @@ func New(p Parameters) (*JIMM, error) {
}
j.serviceAccountManager = svcAccManager

sshKeyManager, err := sshkeys.NewSSHKeyManager(j.Database)
if err != nil {
return nil, err
}
j.sshKeyManager = sshKeyManager

return j, nil
}

Expand Down Expand Up @@ -414,6 +433,9 @@ type JIMM struct {

// serviceAccountManager provides a means to manage service accounts within JIMM.
serviceAccountManager ServiceAccountManager

// sshKeyManager provides a means to manage SSH keys within JIMM.
sshKeyManager SSHKeyManager
}

// ResourceTag returns JIMM's controller tag stating its UUID.
Expand Down
6 changes: 6 additions & 0 deletions internal/jimm/sshkeys/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright 2025 Canonical.

package sshkeys

// SSHKeyManager is a type alias to export sshKeyManager for use in tests.
type SSHKeyManager = sshKeyManager
88 changes: 88 additions & 0 deletions internal/jimm/sshkeys/sshkeys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2025 Canonical.

package sshkeys

import (
"context"

gossh "golang.org/x/crypto/ssh"

"github.com/canonical/jimm/v3/internal/db"
"github.com/canonical/jimm/v3/internal/dbmodel"
"github.com/canonical/jimm/v3/internal/errors"
"github.com/canonical/jimm/v3/internal/openfga"
)

type sshKeyManager struct {
store *db.Database
}

// NewSSHKeyManager returns a new sshKeyManager that handles ssh keys.
func NewSSHKeyManager(store *db.Database) (*sshKeyManager, error) {
if store == nil {
return nil, errors.E("role store cannot be nil")
}
return &sshKeyManager{store}, nil
}

// AddUserPublicKey saves a user's public key.
func (rm *sshKeyManager) AddUserPublicKey(ctx context.Context, user *openfga.User, publicKey PublicKey) error {
const op = errors.Op("sshkeys.AddUserPublicKey")

if ok, reason := publicKey.valid(); !ok {
return errors.E(op, errors.CodeBadRequest, reason)
}

k := dbmodel.SSHKey{
IdentityName: user.Name,
PublicKey: publicKey.Marshal(),
MD5Fingerprint: gossh.FingerprintLegacyMD5(publicKey),
KeyComment: publicKey.Comment,
}
err := rm.store.AddSSHKey(ctx, &k)
if err != nil {
return errors.E(op, err)
}
return nil
}

// ListUserPublicKeys lists a user's public keys.
func (rm *sshKeyManager) ListUserPublicKeys(ctx context.Context, user *openfga.User) ([]PublicKey, error) {
const op = errors.Op("sshkeys.ListUserPublicKeys")

dbKeys, err := rm.store.ListSSHKeysForUser(ctx, user.Name)
if err != nil {
return nil, errors.E(op, err)
}
var pubKeys []PublicKey
for _, key := range dbKeys {
k, err := gossh.ParsePublicKey(key.PublicKey)
if err != nil {
return nil, errors.E(op, err)
}
pubKeys = append(pubKeys, PublicKey{PublicKey: k, Comment: key.KeyComment})
}
return pubKeys, nil
}

// RemoveUserKeyByComment removes a user's public key(s) by the key comment.
func (rm *sshKeyManager) RemoveUserKeyByComment(ctx context.Context, user *openfga.User, comment string) error {
const op = errors.Op("sshkeys.RemoveUserKeyByComment")

err := rm.store.RemoveSSHKeyByComment(ctx, user.Name, comment)
if err != nil {
return errors.E(op, err)
}
return nil
}

// RemoveUserKeyByFingerprint removes a user's public key by the key fingerprint.
func (rm *sshKeyManager) RemoveUserKeyByFingerprint(ctx context.Context, user *openfga.User, fingerprint string) error {
const op = errors.Op("sshkeys.RemoveUserKeyByFingerprint")

err := rm.store.RemoveSSHKeyByFingerprint(ctx, user.Name, fingerprint)
if err != nil {
return errors.E(op, err)
}
return nil
}
130 changes: 130 additions & 0 deletions internal/jimm/sshkeys/sshkeys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright 2025 Canonical.

package sshkeys_test

import (
"context"
"crypto/rand"
"crypto/rsa"
"testing"
"time"

qt "github.com/frankban/quicktest"
"github.com/frankban/quicktest/qtsuite"
gossh "golang.org/x/crypto/ssh"
"gorm.io/gorm"

"github.com/canonical/jimm/v3/internal/db"
"github.com/canonical/jimm/v3/internal/dbmodel"
"github.com/canonical/jimm/v3/internal/jimm/sshkeys"
"github.com/canonical/jimm/v3/internal/openfga"
"github.com/canonical/jimm/v3/internal/testutils/jimmtest"
)

type sshKeysManagerSuite struct {
manager *sshkeys.SSHKeyManager
user *openfga.User
db *db.Database
ofgaClient *openfga.OFGAClient
pubKey sshkeys.PublicKey
}

func (s *sshKeysManagerSuite) Init(c *qt.C) {
// Setup DB
db := &db.Database{
DB: jimmtest.PostgresDB(c, time.Now),
}
err := db.Migrate(context.Background())
c.Assert(err, qt.IsNil)

s.db = db

// Setup OFGA
ofgaClient, _, _, err := jimmtest.SetupTestOFGAClient(c.Name())
c.Assert(err, qt.IsNil)

s.ofgaClient = ofgaClient

s.manager, err = sshkeys.NewSSHKeyManager(db)
c.Assert(err, qt.IsNil)

// Create test identity
i, err := dbmodel.NewIdentity("alice")
c.Assert(err, qt.IsNil)
c.Assert(db.DB.Create(i).Error, qt.IsNil)
s.user = openfga.NewUser(i, ofgaClient)

key, err := rsa.GenerateKey(rand.Reader, 2048)
c.Assert(err, qt.IsNil)

pubKey, err := gossh.NewPublicKey(&key.PublicKey)
c.Assert(err, qt.IsNil)
s.pubKey = sshkeys.PublicKey{PublicKey: pubKey, Comment: "myComment"}
}

func (s *sshKeysManagerSuite) TestAddUserPublicKey(c *qt.C) {
c.Parallel()
ctx := context.Background()

err := s.manager.AddUserPublicKey(ctx, s.user, s.pubKey)
c.Assert(err, qt.IsNil)

var dbKey dbmodel.SSHKey
c.Assert(s.db.DB.First(&dbKey).Error, qt.IsNil)
c.Assert(dbKey.ID, qt.Not(qt.Equals), 0)
c.Assert(dbKey.IdentityName, qt.Equals, "alice")
c.Assert(dbKey.PublicKey, qt.DeepEquals, s.pubKey.Marshal())
c.Assert(dbKey.MD5Fingerprint, qt.Equals, gossh.FingerprintLegacyMD5(s.pubKey))
c.Assert(dbKey.KeyComment, qt.Equals, s.pubKey.Comment)
}

func (s *sshKeysManagerSuite) TestListUserPublicKeys(c *qt.C) {
c.Parallel()
ctx := context.Background()

err := s.manager.AddUserPublicKey(ctx, s.user, s.pubKey)
c.Assert(err, qt.IsNil)

keys, err := s.manager.ListUserPublicKeys(ctx, s.user)
c.Assert(err, qt.IsNil)

c.Assert(keys, qt.HasLen, 1)
c.Assert(keys[0].Comment, qt.Equals, s.pubKey.Comment)
c.Assert(keys[0].Marshal(), qt.DeepEquals, s.pubKey.Marshal())
}

func (s *sshKeysManagerSuite) TestRemoveUserKeyByComment(c *qt.C) {
c.Parallel()
ctx := context.Background()

err := s.manager.AddUserPublicKey(ctx, s.user, s.pubKey)
c.Assert(err, qt.IsNil)

var key dbmodel.SSHKey
c.Assert(s.db.DB.First(&dbmodel.SSHKey{}).First(&key).Error, qt.IsNil)

err = s.manager.RemoveUserKeyByComment(ctx, s.user, s.pubKey.Comment)
c.Assert(err, qt.IsNil)

c.Assert(s.db.DB.First(&dbmodel.SSHKey{}).Error, qt.Equals, gorm.ErrRecordNotFound)
}

func (s *sshKeysManagerSuite) TestRemoveUserKeyByFingerprint(c *qt.C) {
c.Parallel()
ctx := context.Background()

err := s.manager.AddUserPublicKey(ctx, s.user, s.pubKey)
c.Assert(err, qt.IsNil)

var key dbmodel.SSHKey
c.Assert(s.db.DB.First(&dbmodel.SSHKey{}).First(&key).Error, qt.IsNil)

err = s.manager.RemoveUserKeyByFingerprint(ctx, s.user, gossh.FingerprintLegacyMD5(s.pubKey))
c.Assert(err, qt.IsNil)

c.Assert(s.db.DB.First(&dbmodel.SSHKey{}).Error, qt.Equals, gorm.ErrRecordNotFound)
}

func TestSSHKeyManager(t *testing.T) {
qtsuite.Run(qt.New(t), &sshKeysManagerSuite{})
}
25 changes: 25 additions & 0 deletions internal/jimm/sshkeys/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2025 Canonical.

package sshkeys

import (
gossh "golang.org/x/crypto/ssh"
)

// PublicKey holds a public key and key comment.
// The public key is any key that is supported by
// the crypto/ssh PublicKey interface.
type PublicKey struct {
gossh.PublicKey
Comment string
}

func (pk PublicKey) valid() (ok bool, reason string) {
if pk.PublicKey == nil {
return false, "public key is nil"
}
if len(pk.Comment) > 255 {
return false, "comment is too long (max 255 characters)"
}
return true, ""
}
42 changes: 42 additions & 0 deletions internal/testutils/jimmtest/mocks/jimm_sshkeys_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2025 Canonical.
package mocks

import (
"context"

"github.com/canonical/jimm/v3/internal/errors"
"github.com/canonical/jimm/v3/internal/jimm/sshkeys"
"github.com/canonical/jimm/v3/internal/openfga"
)

type SSHKeyManager struct {
AddUserPublicKey_ func(ctx context.Context, user *openfga.User, publicKey sshkeys.PublicKey) error
ListUserPublicKeys_ func(ctx context.Context, user *openfga.User) ([]sshkeys.PublicKey, error)
RemoveUserKeyByComment_ func(ctx context.Context, user *openfga.User, comment string) error
RemoveUserKeyByFingerprint_ func(ctx context.Context, user *openfga.User, fingerprint string) error
}

func (j *SSHKeyManager) AddUserPublicKey(ctx context.Context, user *openfga.User, publicKey sshkeys.PublicKey) error {
if j.AddUserPublicKey_ == nil {
return errors.E(errors.CodeNotImplemented)
}
return j.AddUserPublicKey_(ctx, user, publicKey)
}
func (j *SSHKeyManager) ListUserPublicKeys(ctx context.Context, user *openfga.User) ([]sshkeys.PublicKey, error) {
if j.ListUserPublicKeys_ == nil {
return nil, errors.E(errors.CodeNotImplemented)
}
return j.ListUserPublicKeys_(ctx, user)
}
func (j *SSHKeyManager) RemoveUserKeyByComment(ctx context.Context, user *openfga.User, comment string) error {
if j.RemoveUserKeyByComment_ == nil {
return errors.E(errors.CodeNotImplemented)
}
return j.RemoveUserKeyByComment_(ctx, user, comment)
}
func (j *SSHKeyManager) RemoveUserKeyByFingerprint(ctx context.Context, user *openfga.User, fingerprint string) error {
if j.RemoveUserKeyByFingerprint_ == nil {
return errors.E(errors.CodeNotImplemented)
}
return j.RemoveUserKeyByFingerprint_(ctx, user, fingerprint)
}

0 comments on commit 7a29162

Please sign in to comment.