Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sshkeys manager #1519

Merged
merged 4 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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{})
}
22 changes: 22 additions & 0 deletions internal/jimm/sshkeys/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2025 Canonical.

package sshkeys

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

type PublicKey struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

godoc, please

gossh.PublicKey
alesstimec marked this conversation as resolved.
Show resolved Hide resolved
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)
}
Loading