diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 2307a920a6..1be58985c3 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -25,7 +25,7 @@ func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncrypted } if response.IsError() { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response: [response=%s]", response) + return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) } return result, nil @@ -339,3 +339,23 @@ func CallGetSingleSecretByNameV3(httpClient *resty.Client, request CreateSecretV return nil } + +func CallCreateServiceToken(httpClient *resty.Client, request CreateServiceTokenRequest) (CreateServiceTokenResponse, error) { + var createServiceTokenResponse CreateServiceTokenResponse + response, err := httpClient. + R(). + SetResult(&createServiceTokenResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/service-token/", config.INFISICAL_URL)) + + if err != nil { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + } + + return createServiceTokenResponse, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 027243ef58..1531dce360 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -387,3 +387,37 @@ type GetSingleSecretByNameSecretResponse struct { UpdatedAt time.Time `json:"updatedAt"` } `json:"secrets"` } + +type ScopePermission struct { + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` +} + +type CreateServiceTokenRequest struct { + Name string `json:"name"` + WorkspaceId string `json:"workspaceId"` + Scopes []ScopePermission `json:"scopes"` + ExpiresIn int `json:"expiresIn"` + EncryptedKey string `json:"encryptedKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + RandomBytes string `json:"randomBytes"` + Permissions []string `json:"permissions"` +} + +type ServiceTokenData struct { + ID string `json:"_id"` + Name string `json:"name"` + Workspace string `json:"workspace"` + Scopes []interface{} `json:"scopes"` + User string `json:"user"` + LastUsed time.Time `json:"lastUsed"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateServiceTokenResponse struct { + ServiceToken string `json:"serviceToken"` + ServiceTokenData ServiceTokenData `json:"serviceTokenData"` +} diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go new file mode 100644 index 0000000000..9f0c8da6b0 --- /dev/null +++ b/cli/packages/cmd/tokens.go @@ -0,0 +1,181 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/crypto" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" +) + +var tokensCmd = &cobra.Command{ + Use: "service-token", + Short: "Manage service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +var tokensCreateCmd = &cobra.Command{ + Use: "create", + Short: "Used to create service tokens", + DisableFlagsInUseLine: true, + Example: "infisical service-token create", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + // get plain text workspace key + loggedInUserDetails, _ := util.GetCurrentLoggedInUserDetails() + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + tokenOnly, err := cmd.Flags().GetBool("token-only") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + workspaceId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if workspaceId == "" { + configFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + workspaceId = configFile.WorkspaceId + } + + serviceTokenName, err := cmd.Flags().GetString("name") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + scopes, err := cmd.Flags().GetStringSlice("scope") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if len(scopes) == 0 { + util.PrintErrorMessageAndExit("You must define the environments and paths your service token should have access to via the --scope flag") + } + + permissions := []api.ScopePermission{} + + for _, scope := range scopes { + parts := strings.Split(scope, ":") + + if len(parts) != 2 { + fmt.Println("--scope flag is malformed. Each scope flag should be in the following format: :") + return + } + + permissions = append(permissions, api.ScopePermission{Environment: parts[0], SecretPath: parts[1]}) + } + + accessLevels, err := cmd.Flags().GetStringSlice("access-level") + if err != nil { + util.HandleError(err, "Unable to parse flag accessLevels") + } + + if len(accessLevels) == 0 { + util.PrintErrorMessageAndExit("You must define whether your service token can be used to read and or write via the --access-level flag") + } + + for _, accessLevel := range accessLevels { + if accessLevel != "read" && accessLevel != "write" { + util.PrintErrorMessageAndExit("--access-level can only be of values read and write") + } + } + + workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) + if err != nil { + fmt.Println(err) + } + + newWorkspaceEncryptionKey := make([]byte, 16) + _, err = rand.Read(newWorkspaceEncryptionKey) + if err != nil { + fmt.Println("Error generating random bytes:", err) + return + } + + newWorkspaceEncryptionKeyHexFormat := hex.EncodeToString(newWorkspaceEncryptionKey) + + // encrypt the workspace key symmetrically + encryptedDetails, err := crypto.EncryptSymmetric(workspaceKey, newWorkspaceEncryptionKey) + if err != nil { + fmt.Println(err) + } + + // make a call to the api to save the encrypted symmetric key details + httpClient := resty.New() + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json") + + createServiceTokenResponse, err := api.CallCreateServiceToken(httpClient, api.CreateServiceTokenRequest{ + Name: serviceTokenName, + WorkspaceId: workspaceId, + Scopes: permissions, + ExpiresIn: 0, + EncryptedKey: string(workspaceKey), + Iv: base64.StdEncoding.EncodeToString(encryptedDetails.Nonce), + Tag: base64.StdEncoding.EncodeToString(encryptedDetails.AuthTag), + RandomBytes: newWorkspaceEncryptionKeyHexFormat, + Permissions: accessLevels, + }) + + if err != nil { + fmt.Println(err) + } + + serviceToken := createServiceTokenResponse.ServiceToken + "." + newWorkspaceEncryptionKeyHexFormat + + if tokenOnly { + fmt.Println(serviceToken) + } else { + printablePermission := []string{} + for _, permission := range permissions { + printablePermission = append(printablePermission, fmt.Sprintf("([environment: %v] [path: %v])", permission.Environment, permission.SecretPath)) + } + + fmt.Printf("New service token created\n") + fmt.Printf("Name: %v\n", serviceTokenName) + fmt.Printf("Project ID: %v\n", workspaceId) + fmt.Printf("Access type: [%v]\n", strings.Join(accessLevels, ", ")) + fmt.Printf("Permission(s): %v\n", strings.Join(printablePermission, ", ")) + fmt.Printf("Service Token: %v\n", serviceToken) + } + }, +} + +func init() { + tokensCreateCmd.Flags().String("projectId", "", "The project ID you'd like to create the service token for. Default: will use linked Infisical project in .infisical.json") + tokensCreateCmd.Flags().StringSliceP("scope", "s", []string{}, "Environment and secret path. Example format: :") + tokensCreateCmd.Flags().StringP("name", "n", "Service token generated via CLI", "Service token name") + tokensCreateCmd.Flags().StringSliceP("access-level", "a", []string{}, "The type of access the service token should have. Can be 'read' and or 'write'") + tokensCreateCmd.Flags().Bool("token-only", false, "When true, only the service token will be printed") + + tokensCmd.AddCommand(tokensCreateCmd) + + rootCmd.AddCommand(tokensCmd) +} diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 0d8f1b214d..96b03a0bb9 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -14,7 +14,7 @@ import ( var userCmd = &cobra.Command{ Use: "user", - Short: "Used to manage user credentials", + Short: "Used to manage local user credentials", DisableFlagsInUseLine: true, Example: "infisical user", Args: cobra.ExactArgs(0), diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index a8d248a3bc..73be433197 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -684,3 +684,44 @@ func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) st return "" } } + +func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey string, workspaceId string) ([]byte, error) { + httpClient := resty.New() + httpClient.SetAuthToken(authenticationToken). + SetHeader("Accept", "application/json") + + request := api.GetEncryptedWorkspaceKeyRequest{ + WorkspaceId: workspaceId, + } + + workspaceKeyResponse, err := api.CallGetEncryptedWorkspaceKey(httpClient, request) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: unable to retrieve your encrypted workspace key. [err=%v]", err) + } + + encryptedWorkspaceKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.EncryptedKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKey [err=%v]", err) + } + + encryptedWorkspaceKeySenderPublicKey, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Sender.PublicKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeySenderPublicKey [err=%v]", err) + } + + encryptedWorkspaceKeyNonce, err := base64.StdEncoding.DecodeString(workspaceKeyResponse.Nonce) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for encryptedWorkspaceKeyNonce [err=%v]", err) + } + + currentUsersPrivateKey, err := base64.StdEncoding.DecodeString(receiverPrivateKey) + if err != nil { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Unable to get bytes represented by the base64 for currentUsersPrivateKey [err=%v]", err) + } + + if len(currentUsersPrivateKey) == 0 || len(encryptedWorkspaceKeySenderPublicKey) == 0 { + return nil, fmt.Errorf("GetPlainTextWorkspaceKey: Missing credentials for generating plainTextEncryptionKey") + } + + return crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey), nil +} diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx new file mode 100644 index 0000000000..04ce5ce986 --- /dev/null +++ b/docs/cli/commands/service-token.mdx @@ -0,0 +1,73 @@ +--- +title: "infisical service-token" +description: "Manage Infisical service tokens" +--- + +```bash +infisical service-token create --scope=dev:/global --scope=dev:/backend --access-level=read --access-level=write +``` + +## Description +The Infisical `service-token` command allows you to manage service tokens for a given Infisical project. +With this command can create, view and delete service tokens. + + + Use this command to create a service token + + ```bash + $ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write + ``` + + ### Flags + + ```bash + infisical service-token create --scope=dev:/global --scope=dev:/backend/** --access-level=read + ``` + + Use the scope flag to define which environments and paths your service token should be authorized to access. + + The value of your scope flag should be in the following `:`. + Here, `environment slug` refers to the slug name of the environment, and `path` indicates the folder path where your secrets are stored. + + For specifying multiple scopes, you can use multiple --scope flags. + + + The `path` can be a Glob pattern + + + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --projectId=63cefb15c8d3175601cfa989 + ``` + + The project ID you'd like to create the service token for. + By default, the CLI will attempt to use the linked Infisical project in `.infisical.json` generated by `infisical init` command. + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --name service-token-name + ``` + + Service token name + + Default: `Service token generated via CLI` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write + ``` + + The type of access the service token should have. Can be `read` and or `write` + + + ```bash + infisical service-token create --scope=dev:/global --access-level=read --access-level=write --token-only + ``` + + When true, only the service token will be printed + + Default: `false` + + + \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 260b174b00..08588eded8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -169,6 +169,7 @@ "cli/commands/run", "cli/commands/secrets", "cli/commands/export", + "cli/commands/service-token", "cli/commands/vault", "cli/commands/user", "cli/commands/reset",