-
Notifications
You must be signed in to change notification settings - Fork 10
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(account): support API token operations #363
base: main
Are you sure you want to change the base?
Changes from all commits
7b08d72
25a87f9
e211673
b97d067
b06b573
12f9f87
38b2f5c
08f11e5
1c18808
4e16e7a
e06fc89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
package tokens | ||
|
||
import ( | ||
"fmt" | ||
"time" | ||
|
||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/ui" | ||
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" | ||
"github.com/spf13/pflag" | ||
) | ||
|
||
// CreateCommand creates the "token create" command | ||
func CreateCommand() commands.Command { | ||
return &createCommand{ | ||
BaseCommand: commands.New( | ||
"create", | ||
"Create an API token", | ||
`upctl token create --name test --expires-in 1h`, | ||
`upctl token create --name test --expires-in 1h --allowed-ip-ranges "0.0.0.0/0" --allowed-ip-ranges "::/0"`, | ||
), | ||
} | ||
} | ||
|
||
var defaultCreateParams = &createParams{ | ||
CreateTokenRequest: request.CreateTokenRequest{}, | ||
name: "", | ||
expiresIn: 0, | ||
allowedIPRanges: []string{}, // TODO: should we default to empty or "0.0.0.0/0", "::/0"? | ||
canCreateTokens: false, | ||
} | ||
|
||
func newCreateParams() createParams { | ||
return createParams{ | ||
CreateTokenRequest: request.CreateTokenRequest{}, | ||
} | ||
} | ||
|
||
type createParams struct { | ||
request.CreateTokenRequest | ||
name string | ||
expiresIn time.Duration | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could use support for d unit, e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Definitely should use as hours is not convenient. I suppose one could do it with some custom parser, but the current approach uses flags.DurationVarP which uses time.ParseDuration and only supports up to hours. |
||
expiresAt string | ||
canCreateTokens bool | ||
allowedIPRanges []string | ||
} | ||
|
||
func (s *createParams) processParams() error { | ||
if s.expiresIn == 0 && s.expiresAt == "" { | ||
return fmt.Errorf("either expires-in or expires-at must be set") | ||
} | ||
if s.expiresAt != "" { | ||
var err error | ||
s.ExpiresAt, err = time.Parse(time.RFC3339, s.expiresAt) | ||
if err != nil { | ||
return fmt.Errorf("invalid expires-at: %w", err) | ||
} | ||
} else { | ||
s.ExpiresAt = time.Now().Add(s.expiresIn) | ||
} | ||
s.Name = s.name | ||
s.CanCreateSubTokens = s.canCreateTokens | ||
s.AllowedIPRanges = s.allowedIPRanges | ||
return nil | ||
} | ||
|
||
type createCommand struct { | ||
*commands.BaseCommand | ||
params createParams | ||
flagSet *pflag.FlagSet | ||
} | ||
|
||
func applyCreateFlags(fs *pflag.FlagSet, dst, def *createParams) { | ||
fs.StringVar(&dst.name, "name", def.name, "Name for the token.") | ||
fs.StringVar(&dst.expiresAt, "expires-at", def.expiresAt, "Exact time when the token expires in RFC3339 format. e.g. 2025-01-01T00:00:00Z") | ||
fs.DurationVar(&dst.expiresIn, "expires-in", def.expiresIn, "Duration until the token expires. e.g. 24h") | ||
fs.BoolVar(&dst.canCreateTokens, "can-create-tokens", def.canCreateTokens, "Allow token to be used to create further tokens.") | ||
fs.StringArrayVar(&dst.allowedIPRanges, "allowed-ip-ranges", def.allowedIPRanges, "Allowed IP ranges for the token.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs more documentation as token with empty list is invalid(?). Maybe this could be made required as well 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is technically not invalid, it just can't be used from anywhere to access API. We deliberately allow creating such tokens if one would like to use them as canary tokens. We could give a warning that the token will not be able to access any API though. |
||
|
||
commands.Must(fs.SetAnnotation("name", commands.FlagAnnotationNoFileCompletions, nil)) | ||
commands.Must(fs.SetAnnotation("expires-at", commands.FlagAnnotationNoFileCompletions, nil)) | ||
commands.Must(fs.SetAnnotation("expires-in", commands.FlagAnnotationNoFileCompletions, nil)) | ||
commands.Must(fs.SetAnnotation("allowed-ip-ranges", commands.FlagAnnotationNoFileCompletions, nil)) | ||
} | ||
|
||
// InitCommand implements Command.InitCommand | ||
func (s *createCommand) InitCommand() { | ||
s.flagSet = &pflag.FlagSet{} | ||
s.params = newCreateParams() | ||
applyCreateFlags(s.flagSet, &s.params, defaultCreateParams) | ||
|
||
s.AddFlags(s.flagSet) | ||
commands.Must(s.Cobra().MarkFlagRequired("name")) | ||
} | ||
|
||
// ExecuteWithoutArguments implements commands.NoArgumentCommand | ||
func (s *createCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { | ||
svc := exec.Token() | ||
|
||
if err := s.params.processParams(); err != nil { | ||
return nil, err | ||
} | ||
|
||
msg := fmt.Sprintf("Creating token %s", s.params.Name) | ||
exec.PushProgressStarted(msg) | ||
|
||
res, err := svc.CreateToken(exec.Context(), &s.params.CreateTokenRequest) | ||
if err != nil { | ||
return commands.HandleError(exec, msg, err) | ||
} | ||
|
||
exec.PushProgressSuccess(msg) | ||
|
||
return output.MarshaledWithHumanDetails{Value: res, Details: []output.DetailRow{ | ||
{Title: "API Token", Value: res.APIToken, Colour: ui.DefaultNoteColours}, | ||
{Title: "Name", Value: res.Name}, | ||
{Title: "ID", Value: res.ID, Colour: ui.DefaultUUUIDColours}, | ||
{Title: "Type", Value: res.Type}, | ||
{Title: "Created At", Value: res.CreatedAt.Format(time.RFC3339)}, | ||
{Title: "Expires At", Value: res.ExpiresAt.Format(time.RFC3339)}, | ||
{Title: "Allowed IP Ranges", Value: res.AllowedIPRanges}, | ||
{Title: "Can Create Sub Tokens", Value: res.CanCreateSubTokens}, | ||
}}, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
package tokens | ||
|
||
import ( | ||
"testing" | ||
"time" | ||
|
||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config" | ||
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/mockexecute" | ||
|
||
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" | ||
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/mock" | ||
) | ||
|
||
func TestCreateToken(t *testing.T) { | ||
created := time.Now() | ||
|
||
for _, test := range []struct { | ||
name string | ||
resp *upcloud.Token | ||
args []string | ||
req request.CreateTokenRequest | ||
errFn assert.ErrorAssertionFunc | ||
}{ | ||
{ | ||
name: "defaults", | ||
args: []string{ | ||
"--name", "test", | ||
"--expires-in", "1h", | ||
}, | ||
req: request.CreateTokenRequest{ | ||
Name: "test", | ||
ExpiresAt: created.Add(1 * time.Hour), | ||
CanCreateSubTokens: false, | ||
AllowedIPRanges: nil, | ||
}, | ||
resp: &upcloud.Token{ | ||
APIToken: "ucat_01JH5D3ZZJVZS6JC713FA11CB8", | ||
ID: "0cd8eab4-ecb7-445b-a457-6019b0a00496", | ||
Name: "test", | ||
Type: "workspace", | ||
CreatedAt: created, | ||
ExpiresAt: created.Add(1 * time.Hour), | ||
LastUsed: nil, | ||
CanCreateSubTokens: false, | ||
AllowedIPRanges: []string{"0.0.0.0/0", "::/0"}, | ||
}, | ||
errFn: assert.NoError, | ||
}, | ||
{ | ||
name: "missing name", | ||
args: []string{ | ||
"--expires-in", "1h", | ||
}, | ||
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { | ||
return assert.ErrorContains(t, err, `required flag(s) "name" not set`) | ||
}, | ||
}, | ||
{ | ||
name: "invalid expires-in", | ||
args: []string{ | ||
"--name", "test", | ||
"--expires-in", "seppo", | ||
}, | ||
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { | ||
return assert.ErrorContains(t, err, `invalid argument "seppo" for "--expires-in"`) | ||
}, | ||
}, | ||
{ | ||
name: "invalid expires-at", | ||
args: []string{ | ||
"--name", "test", | ||
"--expires-at", "seppo", | ||
}, | ||
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { | ||
return assert.ErrorContains(t, err, `invalid expires-at: `) | ||
}, | ||
}, | ||
{ | ||
name: "missing expiry", | ||
args: []string{ | ||
"--name", "test", | ||
}, | ||
errFn: func(t assert.TestingT, err error, _ ...interface{}) bool { | ||
return assert.ErrorContains(t, err, `either expires-in or expires-at must be set`) | ||
}, | ||
}, | ||
} { | ||
t.Run(test.name, func(t *testing.T) { | ||
conf := config.New() | ||
testCmd := CreateCommand() | ||
mService := new(smock.Service) | ||
|
||
if test.resp != nil { | ||
mService.On("CreateToken", mock.MatchedBy(func(req *request.CreateTokenRequest) bool { | ||
// service uses time.Now() with "expires-in" added to it to set ExpiresAt, so we can't set a mock to any | ||
// static value. Instead, we'll just check that the request has the correct name and that the ExpiresAt | ||
// is within 1 second of "now". | ||
return assert.Equal(t, test.req.Name, req.Name) && assert.InDelta(t, test.req.ExpiresAt.UnixMilli(), req.ExpiresAt.UnixMilli(), 1000) | ||
})).Once().Return(test.resp, nil) | ||
} | ||
|
||
c := commands.BuildCommand(testCmd, nil, conf) | ||
c.Cobra().SetArgs(test.args) | ||
_, err := mockexecute.MockExecute(c, mService, conf) | ||
|
||
if test.errFn(t, err) { | ||
mService.AssertExpectations(t) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
package tokens | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/completion" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/resolver" | ||
|
||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output" | ||
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" | ||
) | ||
|
||
// DeleteCommand creates the "token delete" command | ||
func DeleteCommand() commands.Command { | ||
return &deleteCommand{ | ||
BaseCommand: commands.New( | ||
"delete", | ||
"Delete an API token", | ||
"upctl token delete 0c0e2abf-cd89-490b-abdb-d06db6e8d816", | ||
), | ||
} | ||
} | ||
|
||
type deleteCommand struct { | ||
*commands.BaseCommand | ||
resolver.CachingToken | ||
completion.Token | ||
} | ||
|
||
// Execute implements commands.MultipleArgumentCommand | ||
func (c *deleteCommand) Execute(exec commands.Executor, arg string) (output.Output, error) { | ||
svc := exec.Token() | ||
msg := fmt.Sprintf("Deleting API token %v", arg) | ||
exec.PushProgressStarted(msg) | ||
|
||
err := svc.DeleteToken(exec.Context(), &request.DeleteTokenRequest{ | ||
ID: arg, | ||
}) | ||
if err != nil { | ||
return commands.HandleError(exec, msg, err) | ||
} | ||
|
||
exec.PushProgressSuccess(msg) | ||
|
||
return output.None{}, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package tokens | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" | ||
"github.com/UpCloudLtd/upcloud-cli/v3/internal/config" | ||
smock "github.com/UpCloudLtd/upcloud-cli/v3/internal/mock" | ||
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request" | ||
"github.com/gemalto/flume" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestDeleteCommand(t *testing.T) { | ||
svc := &smock.Service{} | ||
conf := config.New() | ||
dr := &request.DeleteTokenRequest{ID: "0cdabbf9-090b-4fc5-a6ae-3f76801ed171"} | ||
|
||
svc.On("DeleteToken", dr).Once().Return(nil) | ||
|
||
command := commands.BuildCommand(DeleteCommand(), nil, conf) | ||
_, err := command.(commands.MultipleArgumentCommand).Execute(commands.NewExecutor(conf, svc, flume.New("test")), dr.ID) | ||
assert.NoError(t, err) | ||
|
||
svc.AssertExpectations(t) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These could be under account.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was on the verge with this, so up to you. If we want to have the tokens hierarchically under accounts like they are in the API (makes sense), we should have tokens under account as a subcommand, i.e.
upctl account token create ...
.On the other hand, it just feels better as a top level command/resource, i.e.
upctl token create ...
.In either case, we should follow the same hierarchy in code (style, packaging...).