Skip to content

Commit

Permalink
CSS-9268 Add method to copy cloud-creds for service accounts (#1250)
Browse files Browse the repository at this point in the history
* Add a CLI command to copy a users cloud-credential to a service account

* Add facade method for adding service account creds

* Add facade tests

* Add CLI test

* Minor final updates

- Updated the list-svc-acc-credentials command to not be plural since it only updates 1 credential at a time.
- Updates the jaas snapcraft file to include the new command.

* Integrate add cred command into update cred command

* PR comments and improved cmd docstring

* update client tests

---------

Co-authored-by: Ales Stimec <[email protected]>
  • Loading branch information
kian99 and alesstimec authored Jul 3, 2024
1 parent 9ac2128 commit 02f4b3e
Show file tree
Hide file tree
Showing 14 changed files with 439 additions and 100 deletions.
7 changes: 7 additions & 0 deletions api/jimm.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ func (c *Client) AddServiceAccount(req *params.AddServiceAccountRequest) error {
return c.caller.APICall("JIMM", 4, "", "AddServiceAccount", req, nil)
}

// CopyServiceAccountCredential copies a user cloud-credential to a service account.
func (c *Client) CopyServiceAccountCredential(req *params.CopyServiceAccountCredentialRequest) (*jujuparams.UpdateCredentialResult, error) {
var response jujuparams.UpdateCredentialResult
err := c.caller.APICall("JIMM", 4, "", "CopyServiceAccountCredential", req, &response)
return &response, err
}

// ListServiceAccountCredentials lists the cloud credentials belonging to a service account.
func (c *Client) ListServiceAccountCredentials(req *params.ListServiceAccountCredentialsRequest) (*jujuparams.CredentialContentResults, error) {
var response jujuparams.CredentialContentResults
Expand Down
7 changes: 7 additions & 0 deletions api/params/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,13 @@ type AddServiceAccountRequest struct {
ClientID string `json:"client-id"`
}

// CopyServiceAccountCredentialRequest holds a request to copy a user cloud-credential to a service account.
type CopyServiceAccountCredentialRequest struct {
jujuparams.CloudCredentialArg
// ClientID holds the client id of the service account.
ClientID string `json:"client-id"`
}

// UpdateServiceAccountCredentialsRequest holds a request to update
// a service accounts cloud credentials.
type UpdateServiceAccountCredentialsRequest struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/jaas/cmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func NewListServiceAccountCredentialsCommandForTesting(store jujuclient.ClientSt
}

func NewUpdateCredentialsCommandForTesting(store jujuclient.ClientStore, lp jujuapi.LoginProvider) cmd.Command {
cmd := &updateCredentialsCommand{
cmd := &updateCredentialCommand{
store: store,
dialOpts: &jujuapi.DialOpts{
InsecureSkipVerify: true,
Expand Down
82 changes: 58 additions & 24 deletions cmd/jaas/cmd/updatecredentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/juju/juju/jujuclient"
"github.com/juju/names/v5"

"github.com/juju/juju/rpc/params"
jujuparams "github.com/juju/juju/rpc/params"

"github.com/canonical/jimm/api"
Expand All @@ -22,27 +23,33 @@ import (
)

var (
updateCredentialsCommandDoc = `
update-service-account-credentials command updates the credentials associated with a service account.
This will add the credentials to JAAS if they were not found.
updateCredentialCommandDoc = `
update-service-account-credential command updates the credentials associated with a service account.
Without any additional flags this command will search for the specified credentials on the controller
and create a copy that belongs to the service account.
If the --client option is provided, the command will search for the specified credential on your local
client store and upload a copy of the credential that will be owned by the service account.
`

updateCredentialsCommandExamples = `
juju update-service-account-credentials 00000000-0000-0000-0000-000000000000 aws credential-name
updateCredentialCommandExamples = `
juju update-service-account-credential <client-id> aws <credential-name>
juju update-service-account-credential --client <client-id> aws <credential-name>
`
)

// NewUpdateCredentialsCommand returns a command to update a service account's cloud credentials.
func NewUpdateCredentialsCommand() cmd.Command {
cmd := &updateCredentialsCommand{
// NewUpdateCredentialCommand returns a command to update a service account's cloud credentials.
func NewUpdateCredentialCommand() cmd.Command {
cmd := &updateCredentialCommand{
store: jujuclient.NewFileClientStore(),
}

return modelcmd.WrapBase(cmd)
}

// updateCredentialsCommand updates a service account's cloud credentials.
type updateCredentialsCommand struct {
// updateCredentialCommand updates a service account's cloud credentials.
type updateCredentialCommand struct {
modelcmd.ControllerCommandBase
out cmd.Output

Expand All @@ -52,30 +59,32 @@ type updateCredentialsCommand struct {
clientID string
cloud string
credentialName string
client bool
}

// Info implements Command.Info.
func (c *updateCredentialsCommand) Info() *cmd.Info {
func (c *updateCredentialCommand) Info() *cmd.Info {
return jujucmd.Info(&cmd.Info{
Name: "update-service-account-credentials",
Purpose: "Update service account cloud credentials",
Name: "update-service-account-credential",
Purpose: "Update service account cloud credential",
Args: "<client-id> <cloud> <credential-name>",
Doc: updateCredentialsCommandDoc,
Examples: updateCredentialsCommandExamples,
Doc: updateCredentialCommandDoc,
Examples: updateCredentialCommandExamples,
})
}

// SetFlags implements Command.SetFlags.
func (c *updateCredentialsCommand) SetFlags(f *gnuflag.FlagSet) {
func (c *updateCredentialCommand) SetFlags(f *gnuflag.FlagSet) {
c.CommandBase.SetFlags(f)
c.out.AddFlags(f, "yaml", map[string]cmd.Formatter{
"yaml": cmd.FormatYaml,
"json": cmd.FormatJson,
})
f.BoolVar(&c.client, "client", false, "Provide this option to use a credential from your local store instead")
}

// Init implements the cmd.Command interface.
func (c *updateCredentialsCommand) Init(args []string) error {
func (c *updateCredentialCommand) Init(args []string) error {
if len(args) < 1 {
return errors.E("client ID not specified")
}
Expand All @@ -95,7 +104,7 @@ func (c *updateCredentialsCommand) Init(args []string) error {
}

// Run implements Command.Run.
func (c *updateCredentialsCommand) Run(ctxt *cmd.Context) error {
func (c *updateCredentialCommand) Run(ctxt *cmd.Context) error {
currentController, err := c.store.CurrentController()
if err != nil {
return errors.E(err, "could not determine controller")
Expand All @@ -105,11 +114,28 @@ func (c *updateCredentialsCommand) Run(ctxt *cmd.Context) error {
if err != nil {
return errors.E(err, "failed to dial the controller")
}
var resp any
if c.client {
resp, err = c.updateFromLocalStore(apiCaller)
} else {
resp, err = c.updateFromControllerStore(apiCaller)
}
if err != nil {
return errors.E(err)
}

credential, err := findCredentialsInLocalCache(c.store, c.cloud, c.credentialName)
err = c.out.Write(ctxt, resp)
if err != nil {
return errors.E(err)
}
return nil
}

func (c *updateCredentialCommand) updateFromLocalStore(apiCaller jujuapi.Connection) (any, error) {
credential, err := findCredentialsInLocalCache(c.store, c.cloud, c.credentialName)
if err != nil {
return nil, errors.E(err)
}

// Note that ensuring a client ID comes with the correct domain (which is
// `@serviceaccount`) is not the responsibility of the CLI commands and is
Expand All @@ -118,7 +144,7 @@ func (c *updateCredentialsCommand) Run(ctxt *cmd.Context) error {
// internals, we have to make sure they're in the correct format.
clientIdWithDomain, err := jimmnames.EnsureValidServiceAccountId(c.clientID)
if err != nil {
return errors.E("invalid client ID")
return nil, errors.E("invalid client ID")
}

taggedCredential := jujuparams.TaggedCredential{
Expand All @@ -136,14 +162,22 @@ func (c *updateCredentialsCommand) Run(ctxt *cmd.Context) error {
client := api.NewClient(apiCaller)
resp, err := client.UpdateServiceAccountCredentials(&params)
if err != nil {
return errors.E(err)
return nil, errors.E(err)
}
return resp, nil
}

err = c.out.Write(ctxt, resp)
func (c *updateCredentialCommand) updateFromControllerStore(apiCaller jujuapi.Connection) (any, error) {
params := apiparams.CopyServiceAccountCredentialRequest{
ClientID: c.clientID,
CloudCredentialArg: params.CloudCredentialArg{CloudName: c.cloud, CredentialName: c.credentialName},
}
client := api.NewClient(apiCaller)
res, err := client.CopyServiceAccountCredential(&params)
if err != nil {
return errors.E(err)
return nil, errors.E(err)
}
return nil
return res, nil
}

func findCredentialsInLocalCache(store jujuclient.ClientStore, cloud, credentialName string) (*jujuparams.CloudCredential, error) {
Expand Down
128 changes: 56 additions & 72 deletions cmd/jaas/cmd/updatecredentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cmd_test

import (
"context"
"fmt"

"github.com/juju/cmd/v3/cmdtesting"
"github.com/juju/names/v5"
Expand All @@ -12,11 +13,13 @@ import (
"github.com/canonical/jimm/cmd/jaas/cmd"
"github.com/canonical/jimm/internal/cmdtest"
"github.com/canonical/jimm/internal/dbmodel"
"github.com/canonical/jimm/internal/jimm"
"github.com/canonical/jimm/internal/jimmtest"
"github.com/canonical/jimm/internal/openfga"
ofganames "github.com/canonical/jimm/internal/openfga/names"
jimmnames "github.com/canonical/jimm/pkg/names"
jujucloud "github.com/juju/juju/cloud"
"github.com/juju/juju/rpc/params"
)

type updateCredentialsSuite struct {
Expand All @@ -25,7 +28,7 @@ type updateCredentialsSuite struct {

var _ = gc.Suite(&updateCredentialsSuite{})

func (s *updateCredentialsSuite) TestUpdateCredentialsWithNewCredentials(c *gc.C) {
func (s *updateCredentialsSuite) TestUpdateCredentialsWithLocalCredentials(c *gc.C) {
ctx := context.Background()

clientID := "abda51b2-d735-4794-a8bd-49c506baa4af"
Expand Down Expand Up @@ -66,77 +69,7 @@ func (s *updateCredentialsSuite) TestUpdateCredentialsWithNewCredentials(c *gc.C
})
c.Assert(err, gc.IsNil)

cmdContext, err := cmdtesting.RunCommand(c, cmd.NewUpdateCredentialsCommandForTesting(clientStore, bClient), clientID, "test-cloud", "test-credentials")
c.Assert(err, gc.IsNil)
c.Assert(cmdtesting.Stdout(cmdContext), gc.Equals, `results:
- credentialtag: cloudcred-test-cloud_abda51b2-d735-4794-a8bd-49c506baa4af@serviceaccount_test-credentials
error: null
models: []
`)

ofgaUser := openfga.NewUser(sa, s.JIMM.AuthorizationClient())
cloudCredentialTag := names.NewCloudCredentialTag("test-cloud/" + clientIDWithDomain + "/test-credentials")
cloudCredential2, err := s.JIMM.GetCloudCredential(ctx, ofgaUser, cloudCredentialTag)
c.Assert(err, gc.IsNil)
attrs, _, err := s.JIMM.GetCloudCredentialAttributes(ctx, ofgaUser, cloudCredential2, true)
c.Assert(err, gc.IsNil)

c.Assert(attrs, gc.DeepEquals, map[string]string{
"foo": "bar",
})
}

func (s *updateCredentialsSuite) TestUpdateCredentialsWithExistingCredentials(c *gc.C) {
ctx := context.Background()

clientID := "abda51b2-d735-4794-a8bd-49c506baa4af"
clientIDWithDomain := clientID + "@serviceaccount"

// alice is superuser
bClient := jimmtest.NewUserSessionLogin(c, "alice")

sa, err := dbmodel.NewIdentity(clientIDWithDomain)
c.Assert(err, gc.IsNil)
err = s.JIMM.Database.GetIdentity(ctx, sa)
c.Assert(err, gc.IsNil)

// Make alice admin of the service account
tuple := openfga.Tuple{
Object: ofganames.ConvertTag(names.NewUserTag("[email protected]")),
Relation: ofganames.AdministratorRelation,
Target: ofganames.ConvertTag(jimmnames.NewServiceAccountTag(clientIDWithDomain)),
}
err = s.JIMM.OpenFGAClient.AddRelation(ctx, tuple)
c.Assert(err, gc.IsNil)

cloud := dbmodel.Cloud{
Name: "test-cloud",
Type: "kubernetes",
}
err = s.JIMM.Database.AddCloud(ctx, &cloud)
c.Assert(err, gc.IsNil)

cloudCredential := dbmodel.CloudCredential{
Name: "test-credentials",
CloudName: "test-cloud",
OwnerIdentityName: clientIDWithDomain,
AuthType: "empty",
}
err = s.JIMM.Database.SetCloudCredential(ctx, &cloudCredential)
c.Assert(err, gc.IsNil)

clientStore := s.ClientStore()

err = clientStore.UpdateCredential("test-cloud", jujucloud.CloudCredential{
AuthCredentials: map[string]jujucloud.Credential{
"test-credentials": jujucloud.NewCredential(jujucloud.EmptyAuthType, map[string]string{
"foo": "bar",
}),
},
})
c.Assert(err, gc.IsNil)

cmdContext, err := cmdtesting.RunCommand(c, cmd.NewUpdateCredentialsCommandForTesting(clientStore, bClient), clientID, "test-cloud", "test-credentials")
cmdContext, err := cmdtesting.RunCommand(c, cmd.NewUpdateCredentialsCommandForTesting(clientStore, bClient), clientID, "test-cloud", "test-credentials", "--client")
c.Assert(err, gc.IsNil)
c.Assert(cmdtesting.Stdout(cmdContext), gc.Equals, `results:
- credentialtag: cloudcred-test-cloud_abda51b2-d735-4794-a8bd-49c506baa4af@serviceaccount_test-credentials
Expand All @@ -162,6 +95,7 @@ func (s *updateCredentialsSuite) TestCloudNotInLocalStore(c *gc.C) {
"00000000-0000-0000-0000-000000000000",
"non-existing-cloud",
"foo",
"--client",
)
c.Assert(err, gc.ErrorMatches, "failed to fetch local credentials for cloud \"non-existing-cloud\"")
}
Expand All @@ -181,10 +115,60 @@ func (s *updateCredentialsSuite) TestCredentialNotInLocalStore(c *gc.C) {
"00000000-0000-0000-0000-000000000000",
"some-cloud",
"non-existing-credential-name",
"--client",
)
c.Assert(err, gc.ErrorMatches, "credential \"non-existing-credential-name\" not found on local client.*")
}

func (s *updateCredentialsSuite) TestUpdateServiceAccountCredentialFromController(c *gc.C) {
clientID := "abda51b2-d735-4794-a8bd-49c506baa4af"
clientIDWithDomain := clientID + "@serviceaccount"
// Create Alice Identity and Service Account Identity.
// alice is superuser
ctx := context.Background()
user, err := dbmodel.NewIdentity("[email protected]")
c.Assert(err, gc.IsNil)
u := openfga.NewUser(user, s.OFGAClient)
err = s.JIMM.AddServiceAccount(ctx, u, clientIDWithDomain)
c.Assert(err, gc.IsNil)

// Create cloud and cloud-credential for Alice.
err = s.JIMM.Database.AddCloud(context.Background(), &dbmodel.Cloud{
Name: "aws",
Regions: []dbmodel.CloudRegion{{Name: "default", CloudName: "test-cloud"}},
})
c.Assert(err, gc.IsNil)
updateArgs := jimm.UpdateCloudCredentialArgs{
CredentialTag: names.NewCloudCredentialTag(fmt.Sprintf("aws/%s/foo", user.Name)),
Credential: params.CloudCredential{Attributes: map[string]string{"key": "bar"}},
}
_, err = s.JIMM.UpdateCloudCredential(ctx, u, updateArgs)
c.Assert(err, gc.IsNil)
bClient := jimmtest.NewUserSessionLogin(c, "alice")
cmdContext, err := cmdtesting.RunCommand(c, cmd.NewUpdateCredentialsCommandForTesting(s.ClientStore(), bClient), clientID, "aws", "foo")
c.Assert(err, gc.IsNil)
c.Assert(cmdtesting.Stdout(cmdContext), gc.Equals, `credentialtag: cloudcred-aws_abda51b2-d735-4794-a8bd-49c506baa4af@serviceaccount_foo
error: null
models: []
`)
newCred := dbmodel.CloudCredential{
CloudName: "aws",
OwnerIdentityName: clientIDWithDomain,
Name: "foo",
}
err = s.JIMM.Database.GetCloudCredential(ctx, &newCred)
c.Assert(err, gc.IsNil)
// Verify the old credential's attribute map matches the new one.
svcAcc, err := dbmodel.NewIdentity(clientIDWithDomain)
c.Assert(err, gc.IsNil)
err = s.JIMM.Database.GetIdentity(ctx, svcAcc)
c.Assert(err, gc.IsNil)
svcAccIdentity := openfga.NewUser(svcAcc, s.OFGAClient)
attr, _, err := s.JIMM.GetCloudCredentialAttributes(ctx, svcAccIdentity, &newCred, true)
c.Assert(err, gc.IsNil)
c.Assert(attr, gc.DeepEquals, updateArgs.Credential.Attributes)
}

func (s *updateCredentialsSuite) TestMissingArgs(c *gc.C) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion cmd/jaas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func NewSuperCommand() *jujucmd.SuperCommand {
// Register commands here:
serviceAccountCmd.Register(cmd.NewAddServiceAccountCommand())
serviceAccountCmd.Register(cmd.NewListServiceAccountCredentialsCommand())
serviceAccountCmd.Register(cmd.NewUpdateCredentialsCommand())
serviceAccountCmd.Register(cmd.NewUpdateCredentialCommand())
serviceAccountCmd.Register(cmd.NewGrantCommand())
return serviceAccountCmd
}
Expand Down
Loading

0 comments on commit 02f4b3e

Please sign in to comment.