Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 cmd/kosli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func newCreateCmd(out io.Writer) *cobra.Command {
newCreatePolicyCmd(out),
newCreateAttestationTypeCmd(out),
newCreateApiKeyCmd(out),
newCreateServiceAccountCmd(out),
)
return cmd
}
89 changes: 89 additions & 0 deletions cmd/kosli/createServiceAccount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package main

import (
"io"
"net/http"
"net/url"

"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const createServiceAccountShortDesc = `Create a service account.`

const createServiceAccountLongDesc = createServiceAccountShortDesc + `

A service account is a non-human identity in your organization. API keys are
created separately for it with ^kosli create api-key^.`

const createServiceAccountExample = `
# create a service account:
kosli create service-account yourServiceAccountName \
--privilege member \
--description "CI service account" \
--api-token yourAPIToken \
--org yourOrgName
`

type createServiceAccountOptions struct {
payload createServiceAccountPayload
}

type createServiceAccountPayload struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Privilege string `json:"privilege"`
}

func newCreateServiceAccountCmd(out io.Writer) *cobra.Command {
o := new(createServiceAccountOptions)
cmd := &cobra.Command{
Use: "service-account SERVICE-ACCOUNT-NAME",
Aliases: []string{"sa"},
Short: createServiceAccountShortDesc,
Long: createServiceAccountLongDesc,
Example: createServiceAccountExample,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(args)
},
}

cmd.Flags().StringVarP(&o.payload.Description, "description", "d", "", serviceAccountDescriptionFlag)
cmd.Flags().StringVar(&o.payload.Privilege, "privilege", "", serviceAccountPrivilegeFlag)
addDryRunFlag(cmd)

err := RequireFlags(cmd, []string{"privilege"})
Comment thread
mbevc1 marked this conversation as resolved.
if err != nil {
logger.Error("failed to configure required flags: %v", err)
}

return cmd
}

func (o *createServiceAccountOptions) run(args []string) error {
o.payload.Name = args[0]
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org)
if err != nil {
return err
}

reqParams := &requests.RequestParams{
Method: http.MethodPost,
URL: url,
Payload: o.payload,
DryRun: global.DryRun,
Token: global.ApiToken,
}
_, err = kosliClient.Do(reqParams)
if err == nil && !global.DryRun {
logger.Info("service account %s was created", o.payload.Name)
}
return err
}
1 change: 1 addition & 0 deletions cmd/kosli/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func newDeleteCmd(out io.Writer) *cobra.Command {
// Add subcommands
cmd.AddCommand(
newDeleteApiKeyCmd(out),
newDeleteServiceAccountCmd(out),
)

return cmd
Expand Down
146 changes: 146 additions & 0 deletions cmd/kosli/deleteServiceAccount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package main

import (
"bufio"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const deleteServiceAccountShortDesc = `Delete one or more service accounts.`

const deleteServiceAccountLongDesc = deleteServiceAccountShortDesc + `

This permanently removes the service account(s) identified by SERVICE-ACCOUNT-NAME
from the organization, along with their API keys. Deletion is immediate and
cannot be undone. You are asked to confirm before deletion; use
^--assume-yes^/^--yes^ to skip the confirmation prompt.`

const deleteServiceAccountExample = `
# delete a service account (asks for confirmation):
kosli delete service-account yourServiceAccountName \
--api-token yourAPIToken \
--org yourOrgName

# delete multiple service accounts at once:
kosli delete service-account sa1 sa2 \
--api-token yourAPIToken \
--org yourOrgName

# delete a service account without confirmation:
kosli delete service-account yourServiceAccountName \
--assume-yes \
--api-token yourAPIToken \
--org yourOrgName
`

type deleteServiceAccountOptions struct {
assumeYes bool
}

func newDeleteServiceAccountCmd(out io.Writer) *cobra.Command {
o := new(deleteServiceAccountOptions)
cmd := &cobra.Command{
Use: "service-account SERVICE-ACCOUNT-NAME [SERVICE-ACCOUNT-NAME...]",
Aliases: []string{"sa"},
Short: deleteServiceAccountShortDesc,
Long: deleteServiceAccountLongDesc,
Example: deleteServiceAccountExample,
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(cmd.InOrStdin(), args)
},
}

cmd.Flags().BoolVarP(&o.assumeYes, "assume-yes", "y", false, serviceAccountAssumeYesFlag)
// keep --yes as a hidden alias for --assume-yes (bound to the same option)
cmd.Flags().BoolVar(&o.assumeYes, "yes", false, serviceAccountAssumeYesFlag)
if f := cmd.Flags().Lookup("yes"); f != nil {
f.Hidden = true
}
addDryRunFlag(cmd)

return cmd
}

func (o *deleteServiceAccountOptions) run(in io.Reader, args []string) error {
if !o.assumeYes && !global.DryRun {
confirmed, err := confirmServiceAccountDeletion(args, in)
if err != nil {
return err
}
if !confirmed {
logger.Info("Deletion of service account(s) %s was cancelled.", strings.Join(styleServiceAccountNames(args), ", "))
return nil
}
}

// deletion is destructive and one-way: on any failure mid-batch, make clear
// which service accounts were already deleted before it.
reportAlreadyDeleted := func(i int) {
if i > 0 {
logger.Info("Service accounts already deleted before this failure: %s", strings.Join(styleServiceAccountNames(args[:i]), ", "))
}
}

for i, name := range args {
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, name)
if err != nil {
reportAlreadyDeleted(i)
return err
}

reqParams := &requests.RequestParams{
Method: http.MethodDelete,
URL: url,
DryRun: global.DryRun,
Token: global.ApiToken,
}
if _, err := kosliClient.Do(reqParams); err != nil {
reportAlreadyDeleted(i)
return fmt.Errorf("failed to delete service account: %w", err)
}
if !global.DryRun {
logger.Info("service account %s was deleted!", style(logger.Out, name, ansiBold, ansiCyan))
}
}
return nil
}

// styleServiceAccountNames styles service account names for user-facing
// messages printed via logger (bold cyan when styling is enabled).
func styleServiceAccountNames(names []string) []string {
styled := make([]string, len(names))
for i, name := range names {
styled[i] = style(logger.Out, name, ansiBold, ansiCyan)
}
return styled
}

// confirmServiceAccountDeletion prompts the user to confirm deletion and
// returns true only when the answer is an affirmative "y"/"yes"
// (case-insensitive). The prompt has no trailing newline so the answer is
// typed on the same line.
func confirmServiceAccountDeletion(names []string, in io.Reader) (bool, error) {
logger.Print("Are you sure you want to delete service account(s) %s? [y/N] ",
strings.Join(styleServiceAccountNames(names), ", "))

answer, err := bufio.NewReader(in).ReadString('\n')
if err != nil && err != io.EOF {
return false, err
}

answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes", nil
}
1 change: 1 addition & 0 deletions cmd/kosli/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func newGetCmd(out io.Writer) *cobra.Command {
newGetAttestationTypeCmd(out),
newGetAttestationCmd(out),
newGetRepoCmd(out),
newGetServiceAccountCmd(out),
)
return cmd
}
78 changes: 78 additions & 0 deletions cmd/kosli/getServiceAccount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"io"
"net/http"
"net/url"

"github.com/kosli-dev/cli/internal/output"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
)

const getServiceAccountShortDesc = `Get a service account's metadata.`

const getServiceAccountLongDesc = getServiceAccountShortDesc + `

The metadata includes the name, description, privilege, and creation time. The
secret values of the account's API keys are never returned. Use ^--output json^
to get the raw response for scripting.`

const getServiceAccountExample = `
# get the metadata of a service account:
kosli get service-account yourServiceAccountName \
--api-token yourAPIToken \
--org yourOrgName
`

type getServiceAccountOptions struct {
output string
}

func newGetServiceAccountCmd(out io.Writer) *cobra.Command {
o := new(getServiceAccountOptions)
cmd := &cobra.Command{
Use: "service-account SERVICE-ACCOUNT-NAME",
Aliases: []string{"sa"},
Short: getServiceAccountShortDesc,
Long: getServiceAccountLongDesc,
Example: getServiceAccountExample,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}); err != nil {
return ErrorBeforePrintingUsage(cmd, err.Error())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return o.run(out, args)
},
}

cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag)

return cmd
}

func (o *getServiceAccountOptions) run(out io.Writer, args []string) error {
url, err := url.JoinPath(global.Host, "api/v2/service-accounts", global.Org, args[0])
if err != nil {
return err
}

reqParams := &requests.RequestParams{
Method: http.MethodGet,
URL: url,
Token: global.ApiToken,
}
response, err := kosliClient.Do(reqParams)
if err != nil {
return err
}

return output.FormattedPrint(response.Body, o.output, out, 0,
map[string]output.FormatOutputFunc{
"table": printServiceAccountAsTable,
"json": output.PrintJson,
})
}
1 change: 1 addition & 0 deletions cmd/kosli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func newListCmd(out io.Writer) *cobra.Command {
newListAttestationTypesCmd(out),
newListReposCmd(out),
newListApiKeysCmd(out),
newListServiceAccountsCmd(out),
)

return cmd
Expand Down
Loading
Loading