Skip to content

Commit

Permalink
Merge pull request juju#17500 from tlm/juju-5342-model-config-facades…
Browse files Browse the repository at this point in the history
…-authkeys

juju#17500

This commit introduces the services layer for authorised keys. The logic implemented in the services layer is taken from what we already have in the key manager facade.

As an implementation detail we still expect authorised keys to live in model config for the forseeable future.

## Checklist

- [x] Code style: imports ordered, good names, simple structure, etc
- [x] Comments saying why design decisions were made
- [x] Go unit tests, with comments saying what you're testing
- ~[ ] [Integration tests](https://github.com/juju/juju/tree/main/tests), with comments saying what you're testing~
- ~[ ] [doc.go](https://discourse.charmhub.io/t/readme-in-packages/451) added or updated in changed packages~

## QA steps

There is no QA to be performed as part of this PR but I would appreciate a sanity check of the current logic from https://github.com/juju/juju/blob/main/apiserver/facades/client/keymanager/keymanager.go

## Documentation changes

Yes we will have to update https://juju.is/docs/juju/juju-remove-ssh-key

Previously in Juju versions less then 4.0 we only worked with fingerprints calculated using MD5 sums. This Pr introduces the change to use SHA256 sums. We should document this expectation to the user.

@tmihoc will need to work out how we coordinate this with the release.

## Links

**Jira card:** JUJU-5342
  • Loading branch information
jujubot committed Jun 20, 2024
2 parents ba2f118 + 55a8f6f commit eaad3fe
Show file tree
Hide file tree
Showing 17 changed files with 1,148 additions and 23 deletions.
6 changes: 3 additions & 3 deletions cmd/juju/sshkeys/remove_sshkeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ var usageRemoveSSHKeyDetails = `
Juju maintains a per-model cache of public SSH keys which it copies to
each unit. This command will remove a specified key (or space separated
list of keys) from the model cache and all current units deployed in that
model. The keys to be removed may be specified by the key's fingerprint,
or by the text label associated with them. Invalid keys in the model cache
can be removed by specifying the key verbatim.
model. The keys to be removed may be specified by the key's fingerprint using a
sah256 sum, or by the text label associated with them. Invalid keys in the model
cache can be removed by specifying the key verbatim.
`[1:]

Expand Down
3 changes: 2 additions & 1 deletion core/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func NewUUID() (UUID, error) {
return UUID(uuid.String()), nil
}

// Validate returns an error if the UUID is invalid.
// Validate returns an error if the UUID is invalid. The error returned
// satisfies [errors.NotValid].
func (u UUID) Validate() error {
if u == "" {
return fmt.Errorf("empty uuid%w", errors.Hide(errors.NotValid))
Expand Down
10 changes: 10 additions & 0 deletions domain/keymanager/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

// Package keys provides the domain needed for configuring public keys on a
// model for a user.
//
// Public keys for a user are per model based and will not follow a user between
// models. Currently under the covers we do not model the public keys and their
// user (owner) as an old legacy implementation details of Juju 3.x.
package keymanager
31 changes: 31 additions & 0 deletions domain/keymanager/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package errors

import (
"github.com/juju/errors"
)

const (
// PublicKeyAlreadyExists indicates that the authorised key already
// exists for the specified user.
PublicKeyAlreadyExists = errors.ConstError("public key already exists")

// ImportSubjectNotFound indicates that when importing public keys for a
// subject the source of the public keys has told us that this subject
// does not exist.
ImportSubjectNotFound = errors.ConstError("import subject not found")

// InvalidPublicKey indicates a problem with a public key where it
// was unable to be understood.
InvalidPublicKey = errors.ConstError("invalid public key")

// ReservedCommentViolation indicates that a key contains a comment that is
// reserved within the Juju system and cannot be used.
ReservedCommentViolation = errors.ConstError("key contains a reserved comment")

// UnknownImportSource indicates that an import operation cannot occur
// because the source of the information is unknown.
UnknownImportSource = errors.ConstError("unknown import source")
)
16 changes: 16 additions & 0 deletions domain/keymanager/service/package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package service

import (
"testing"

gc "gopkg.in/check.v1"
)

//go:generate go run go.uber.org/mock/mockgen -typed -package service -destination service_mock_test.go github.com/juju/juju/domain/keymanager/service PublicKeyImporter,State

func TestPackage(t *testing.T) {
gc.TestingT(t)
}
258 changes: 258 additions & 0 deletions domain/keymanager/service/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.

package service

import (
"context"
"fmt"
"net/url"

"github.com/juju/collections/set"
"github.com/juju/errors"

"github.com/juju/juju/core/user"
"github.com/juju/juju/domain/keymanager"
keyserrors "github.com/juju/juju/domain/keymanager/errors"
"github.com/juju/juju/environs/config"
"github.com/juju/juju/internal/ssh"
importererrors "github.com/juju/juju/internal/ssh/importer/errors"
)

// PublicKeyImporter describes a service that is capable of fetching and
// providing public keys for a subject from a set of well known sources that
// don't need to be understood by this service.
type PublicKeyImporter interface {
// FetchPublicKeysForSubject is responsible for gathering all of the
// public keys available for a specified subject.
// The following errors can be expected:
// - [importererrors.NoResolver] when there is import resolver the subject
// schema.
// - [importerrors.SubjectNotFound] when the resolver has reported that no
// subject exists.
FetchPublicKeysForSubject(context.Context, *url.URL) ([]string, error)
}

// Service provides the means for interacting with a users underlying
// public keys for a model.
type Service struct {
// keyImporter is the [PublicKeyImporter] to use for fetching a users
// public key's for subject.
keyImporter PublicKeyImporter

// st provides the state access layer to this service.
st State
}

// State provides the access layer the [Service] needs for persisting and
// retrieving a user's public keys on a model.
type State interface {
// AddPublicKeysForUser adds a set of public keys for a user on
// this model. If one or more of the public keys to add for the user already
// exists a [keyserrors.PublicKeyAlreadyExists] error will be returned.
AddPublicKeysForUser(context.Context, user.UUID, []keymanager.PublicKey) error

// AddPublicKeyForUserIfNotFound will attempt to add the given set of public
// keys to the user. If the user already contains the public key it will be
// skipped and no [keyserrors.PublicKeyAlreadyExists] error will be returned.
AddPublicKeyForUserIfNotFound(context.Context, user.UUID, []keymanager.PublicKey) error

// GetPublicKeysForUser is responsible for returning all of the
// public keys for the current user in this model.
GetPublicKeysForUser(context.Context, user.UUID) ([]string, error)

// DeletePublicKeysForUser is responsible for removing the keys from the
// users list of public keys where the string list represents one of
// the keys fingerprint, public key data or comment.
DeletePublicKeysForUser(context.Context, user.UUID, []string) error
}

var (
// reservedPublicKeyComments is the set of comments that can not be
// removed or added by a user.
reservedPublicKeyComments = set.NewStrings(
"juju-client-key",
config.JujuSystemKey,
)
)

// NewService constructs a new [Service] for interfacting with a users
// public keys.
func NewService(keyImporter PublicKeyImporter, state State) *Service {
return &Service{
keyImporter: keyImporter,
st: state,
}
}

// AddPublicKeysForUser is responsible for adding public keys for a user to a
// model. The following errors can be expected:
// - [errors.NotValid] when the user id is not valid
// - [github.com/juju/juju/domain/access/errors.UserNotFound] when the user does
// not exist.
// - [keyserrors.InvalidPublicKey] when a public key fails validation.
// - [keyserrors.ReservedCommentViolation] when a key being added contains a
// comment string that is reserved.
// - [keyserrors.PublicKeyAlreadyExists] when a public key being added
// for a user already exists.
func (s *Service) AddPublicKeysForUser(
ctx context.Context,
userID user.UUID,
keys ...string,
) error {
if err := userID.Validate(); err != nil {
return fmt.Errorf("validating user id %q when adding public keys: %w", userID, err)
}

if len(keys) == 0 {
return nil
}

toAdd := make([]keymanager.PublicKey, 0, len(keys))
for i, keyToAdd := range keys {
parsedKey, err := ssh.ParsePublicKey(keyToAdd)
if err != nil {
return fmt.Errorf(
"%w %q at index %d: %w",
keyserrors.InvalidPublicKey, keyToAdd, i, err,
)
}

if reservedPublicKeyComments.Contains(parsedKey.Comment) {
return fmt.Errorf(
"public key %q at index %d contains a reserved comment %q that cannot be used: %w",
keyToAdd,
i,
parsedKey.Comment,
errors.Hide(keyserrors.ReservedCommentViolation),
)
}

toAdd = append(toAdd, keymanager.PublicKey{
Comment: parsedKey.Comment,
Fingerprint: parsedKey.Fingerprint(),
Key: keyToAdd,
})
}

return s.st.AddPublicKeysForUser(ctx, userID, toAdd)
}

// DeletePublicKeysForUser removes the keys associated with targets from the
// user's list of public keys. Targets can be an arbitrary list of a
// public key fingerprint (sha256), comment or full key value to be
// removed. Where a match is found the key will be removed. If no key exists for
// a target this will result in no operation. The following errors can be
// expected:
// - [errors.NotValid] when the user id is not valid
// - [accesserrors.UserNotFound] when the provided user does not exist.
func (s *Service) DeleteKeysForUser(
ctx context.Context,
userID user.UUID,
targets ...string,
) error {
if err := userID.Validate(); err != nil {
return fmt.Errorf(
"validating user id %q when deleting public keys: %w",
userID, err,
)
}

return s.st.DeletePublicKeysForUser(ctx, userID, targets)
}

// ImportPublicKeysForUser will import all of the public keys available for a
// given subject and add them to the specified Juju user. If the user already
// has one or more of the public keys being imported they will safely be skipped
// with no errors being returned.
// The following errors can be expected:
// - [errors.NotValid] when the user id is not valid
// - [github.com/juju/juju/domain/access/errors.UserNotFound] when the user does
// not exist.
// - [keyserrors.InvalidPublicKey] when a key being imported fails validation.
// - [keyserrors.ReservedCommentViolation] when a key being added contains a
// comment string that is reserved.
// - [keyserrors.UnknownImportSource] when the source for the import operation
// is unknown to the service.
// - [keyserrors.ImportSubjectNotFound] when the source has indicated that the
// subject for the import operation does not exist.
func (s *Service) ImportPublicKeysForUser(
ctx context.Context,
userID user.UUID,
subject *url.URL,
) error {
if err := userID.Validate(); err != nil {
return fmt.Errorf(
"validating user id %q when importing public keys from %q: %w",
userID, subject.String(), err,
)
}

keys, err := s.keyImporter.FetchPublicKeysForSubject(ctx, subject)

switch {
case errors.Is(err, importererrors.NoResolver):
return fmt.Errorf(
"cannot import public keys for user %q, unknown public key source %q%w",
userID, subject.Scheme, errors.Hide(keyserrors.UnknownImportSource),
)
case errors.Is(err, importererrors.SubjectNotFound):
return fmt.Errorf(
"cannot import public keys for user %q, import subject %q not found%w",
userID, subject.String(), errors.Hide(keyserrors.ImportSubjectNotFound),
)
case err != nil:
return fmt.Errorf(
"cannot import public keys for user %q using subject %q: %w",
userID, subject.String(), err,
)
}

keysToAdd := make([]keymanager.PublicKey, 0, len(keys))
for i, key := range keys {
parsedKey, err := ssh.ParsePublicKey(key)
if err != nil {
return fmt.Errorf(
"cannot parse key %d for subject %q when importing keys for user %q: %w%w",
i, subject.String(), userID, err, errors.Hide(keyserrors.InvalidPublicKey),
)
}

if reservedPublicKeyComments.Contains(parsedKey.Comment) {
return fmt.Errorf(
"cannot import key %d for user %q with subject %q because the comment %q is reserved%w",
i,
userID,
subject.String(),
parsedKey.Comment,
errors.Hide(keyserrors.ReservedCommentViolation),
)
}

keysToAdd = append(keysToAdd, keymanager.PublicKey{
Comment: parsedKey.Comment,
Key: key,
Fingerprint: parsedKey.Fingerprint(),
})
}

return s.st.AddPublicKeyForUserIfNotFound(ctx, userID, keysToAdd)
}

// ListPublicKeysForUser is responsible for returning the public ssh keys for
// the specified user. The following errors can be expected:
// - [errors.NotValid] when the user id is not valid.
// - [usererrors.NotFound] when the given user does not exist.
func (s *Service) ListPublicKeysForUser(
ctx context.Context,
userID user.UUID,
) ([]string, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf(
"validating user id %q when listing public keys: %w",
userID, err,
)
}

return s.st.GetPublicKeysForUser(ctx, userID)
}

0 comments on commit eaad3fe

Please sign in to comment.