diff --git a/.golangci.yaml b/.golangci.yaml index d640b4e53..bc8a13a8b 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -71,6 +71,7 @@ linters-settings: goheader: template: |- Copyright {{MOD-YEAR}} Canonical. + importas: no-unaliased: false no-extra-aliases: false diff --git a/internal/jimm/jimm.go b/internal/jimm/jimm.go index 784c6f5e1..c7e2225cc 100644 --- a/internal/jimm/jimm.go +++ b/internal/jimm/jimm.go @@ -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" @@ -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 { @@ -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 } @@ -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. diff --git a/internal/jimm/sshkeys/export_test.go b/internal/jimm/sshkeys/export_test.go new file mode 100644 index 000000000..99000adaf --- /dev/null +++ b/internal/jimm/sshkeys/export_test.go @@ -0,0 +1,6 @@ +// Copyright 2025 Canonical. + +package sshkeys + +// SSHKeyManager is a type alias to export sshKeyManager for use in tests. +type SSHKeyManager = sshKeyManager diff --git a/internal/jimm/sshkeys/sshkeys.go b/internal/jimm/sshkeys/sshkeys.go new file mode 100644 index 000000000..0a1abcc71 --- /dev/null +++ b/internal/jimm/sshkeys/sshkeys.go @@ -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 +} diff --git a/internal/jimm/sshkeys/sshkeys_test.go b/internal/jimm/sshkeys/sshkeys_test.go new file mode 100644 index 000000000..e028f39c5 --- /dev/null +++ b/internal/jimm/sshkeys/sshkeys_test.go @@ -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{}) +} diff --git a/internal/jimm/sshkeys/types.go b/internal/jimm/sshkeys/types.go new file mode 100644 index 000000000..7c0244043 --- /dev/null +++ b/internal/jimm/sshkeys/types.go @@ -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, "" +} diff --git a/internal/testutils/jimmtest/mocks/jimm_sshkeys_mock.go b/internal/testutils/jimmtest/mocks/jimm_sshkeys_mock.go new file mode 100644 index 000000000..f20dfbe27 --- /dev/null +++ b/internal/testutils/jimmtest/mocks/jimm_sshkeys_mock.go @@ -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) +}