diff --git a/plugins/redis/plugin.go b/plugins/redis/plugin.go new file mode 100644 index 000000000..45393508d --- /dev/null +++ b/plugins/redis/plugin.go @@ -0,0 +1,22 @@ +package redis + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "redis", + Platform: schema.PlatformInfo{ + Name: "Redis", + Homepage: sdk.URL("https://redis.io/"), + }, + Credentials: []schema.CredentialType{ + UserCredentials(), + }, + Executables: []schema.Executable{ + RedisCLI(), + }, + } +} diff --git a/plugins/redis/provisioner.go b/plugins/redis/provisioner.go new file mode 100644 index 000000000..2788eb8f5 --- /dev/null +++ b/plugins/redis/provisioner.go @@ -0,0 +1,96 @@ +package redis + +import ( + "context" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +type redisArgsProvisioner struct { +} + +func redisProvisioner() sdk.Provisioner { + return redisArgsProvisioner{} +} + +// Redis CLI flags that, when already supplied by the user, signal that we +// should not provision the corresponding field from the 1Password item. +var ( + hostFlags = []string{"-h"} + portFlags = []string{"-p"} + userFlags = []string{"--user"} + passwordFlags = []string{"-a", "--pass"} +) + +func (p redisArgsProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + suppliedFlags := flagSet(out.CommandLine) + + // The password is passed via an environment variable so it never appears in + // the process's argument list. Skip it if the user already authenticated on + // the command line. + if value, ok := in.ItemFields[fieldname.Password]; ok && !containsAny(suppliedFlags, passwordFlags) { + out.AddEnvVar("REDISCLI_AUTH", value) + } + + // Collect the flags to inject first, then prepend them in a single pass. + // Mutating out.CommandLine while ranging over it risks index-out-of-range + // panics and stale reads, so we never modify it during inspection. + var injected []string + if value, ok := in.ItemFields[fieldname.Host]; ok && !containsAny(suppliedFlags, hostFlags) { + injected = append(injected, "-h", value) + } + if value, ok := in.ItemFields[fieldname.Port]; ok && !containsAny(suppliedFlags, portFlags) { + injected = append(injected, "-p", value) + } + if value, ok := in.ItemFields[fieldname.Username]; ok && !containsAny(suppliedFlags, userFlags) { + injected = append(injected, "--user", value) + } + + if len(injected) > 0 { + out.CommandLine = prependArgs(out.CommandLine, injected) + } +} + +func (p redisArgsProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // Nothing to do here: credentials get wiped automatically when the process exits. +} + +func (p redisArgsProvisioner) Description() string { + return "Provision redis secrets as command-line arguments and the password as an environment variable." +} + +// flagSet returns the set of arguments present on the command line, excluding +// the executable name at index 0. +func flagSet(commandLine []string) map[string]bool { + set := make(map[string]bool, len(commandLine)) + for i, arg := range commandLine { + if i == 0 { + continue + } + set[arg] = true + } + return set +} + +func containsAny(set map[string]bool, flags []string) bool { + for _, f := range flags { + if set[f] { + return true + } + } + return false +} + +// prependArgs inserts args immediately after the executable name (index 0), +// leaving the rest of the user-supplied command line intact. +func prependArgs(commandLine []string, args []string) []string { + if len(commandLine) == 0 { + return append([]string{}, args...) + } + result := make([]string, 0, len(commandLine)+len(args)) + result = append(result, commandLine[0]) + result = append(result, args...) + result = append(result, commandLine[1:]...) + return result +} diff --git a/plugins/redis/redis_cli.go b/plugins/redis/redis_cli.go new file mode 100644 index 000000000..486f1d21b --- /dev/null +++ b/plugins/redis/redis_cli.go @@ -0,0 +1,26 @@ +package redis + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +func RedisCLI() schema.Executable { + return schema.Executable{ + Name: "Redis CLI", + Runs: []string{"redis-cli"}, + DocsURL: sdk.URL("https://redis.io/docs/ui/cli"), + NeedsAuth: needsauth.IfAll( + needsauth.NotWhenContainsArgs("--help"), + needsauth.NotForVersion(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.UserCredentials, + Provisioner: redisProvisioner(), + }, + }, + } +} diff --git a/plugins/redis/user_credentials.go b/plugins/redis/user_credentials.go new file mode 100644 index 000000000..a882cf088 --- /dev/null +++ b/plugins/redis/user_credentials.go @@ -0,0 +1,75 @@ +package redis + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func UserCredentials() schema.CredentialType { + return schema.CredentialType{ + Name: credname.UserCredentials, + DocsURL: sdk.URL("https://redis.io/docs/ui/cli/#host-port-password-and-database"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Password, + MarkdownDescription: "Password used to authenticate to Redis server.", + Secret: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.Username, + MarkdownDescription: "Username used to authenticate to Redis server.", + Secret: false, + Optional: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.Host, + MarkdownDescription: "Host address for the Redis server.", + Secret: false, + Optional: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Lowercase: true, + Symbols: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.Port, + MarkdownDescription: "Port for the Redis server.", + Secret: false, + Optional: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Digits: true, + }, + }, + }, + }, + DefaultProvisioner: provision.EnvVars(defaultEnvVarMapping), + Importer: importer.TryAll( + importer.TryEnvVarPair(defaultEnvVarMapping), + )} +} + +var defaultEnvVarMapping = map[string]sdk.FieldName{ + "REDISCLI_AUTH": fieldname.Password, +} diff --git a/plugins/redis/user_credentials_test.go b/plugins/redis/user_credentials_test.go new file mode 100644 index 000000000..82783a544 --- /dev/null +++ b/plugins/redis/user_credentials_test.go @@ -0,0 +1,102 @@ +package redis + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestUserCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, redisProvisioner(), map[string]plugintest.ProvisionCase{ + "default": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + fieldname.Username: "example", + fieldname.Host: "127.0.0.1", + fieldname.Port: "6379", + }, + CommandLine: []string{"redis-cli"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "REDISCLI_AUTH": "pjtxpc2gaddifapjvalggspojexample", + }, + CommandLine: []string{"redis-cli", "-h", "127.0.0.1", "-p", "6379", "--user", "example"}, + }, + }, + // User supplies some flags themselves: we must not duplicate them, but + // should still provision the fields they left out (and keep their args). + "user-supplied flags are respected": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + fieldname.Username: "example", + fieldname.Host: "127.0.0.1", + fieldname.Port: "6379", + }, + CommandLine: []string{"redis-cli", "-h", "myhost", "PING"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "REDISCLI_AUTH": "pjtxpc2gaddifapjvalggspojexample", + }, + CommandLine: []string{"redis-cli", "-p", "6379", "--user", "example", "-h", "myhost", "PING"}, + }, + }, + // A recognized flag as the final token used to cause an index-out-of-range + // panic; ensure it is handled gracefully. + "recognized flag as last token": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + fieldname.Host: "127.0.0.1", + }, + CommandLine: []string{"redis-cli", "-h"}, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "REDISCLI_AUTH": "pjtxpc2gaddifapjvalggspojexample", + }, + CommandLine: []string{"redis-cli", "-h"}, + }, + }, + // User authenticates on the command line: don't also set the env var. + "password flag skips env var": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + }, + CommandLine: []string{"redis-cli", "-a", "mypassword"}, + ExpectedOutput: sdk.ProvisionOutput{ + CommandLine: []string{"redis-cli", "-a", "mypassword"}, + }, + }, + }) +} +func TestDefaultUserCredentialsProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, UserCredentials().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "REDISCLI_AUTH": "pjtxpc2gaddifapjvalggspojexample", + }, + }, + }, + }) +} + +func TestUserCredentialsImporter(t *testing.T) { + plugintest.TestImporter(t, UserCredentials().Importer, map[string]plugintest.ImportCase{ + "environment": { + Environment: map[string]string{ + "REDISCLI_AUTH": "pjtxpc2gaddifapjvalggspojexample", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Password: "pjtxpc2gaddifapjvalggspojexample", + }, + }, + }, + }, + }) +} diff --git a/plugins/rediscloud/api_key.go b/plugins/rediscloud/api_key.go new file mode 100644 index 000000000..015efdd29 --- /dev/null +++ b/plugins/rediscloud/api_key.go @@ -0,0 +1,52 @@ +package rediscloud + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func APIKey() schema.CredentialType { + return schema.CredentialType{ + Name: credname.APIKey, + DocsURL: sdk.URL("https://docs.redis.com/latest/rc/api/get-started/manage-api-keys/"), + ManagementURL: sdk.URL("https://app.redislabs.com/#/access-management/api-keys"), + Fields: []schema.CredentialField{ + { + Name: fieldname.AccountKey, + MarkdownDescription: "API Account key (also known as Access Key, or just API Key) to authenticate to Redis Enterprise Cloud.", + Secret: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + { + Name: fieldname.UserKey, + MarkdownDescription: "API user key (also known as Secret Key) to authenticate to Redis Enterprise Cloud.", + Secret: true, + Composition: &schema.ValueComposition{ + Charset: schema.Charset{ + Uppercase: true, + Lowercase: true, + Digits: true, + }, + }, + }, + }, + DefaultProvisioner: provision.EnvVars(envVarMappingForRedisCloud), + Importer: importer.TryAll( + importer.TryEnvVarPair(envVarMappingForRedisCloud), + )} +} + +var envVarMappingForRedisCloud = map[string]sdk.FieldName{ + "REDISCLOUD_ACCESS_KEY": fieldname.AccountKey, + "REDISCLOUD_SECRET_KEY": fieldname.UserKey, +} diff --git a/plugins/rediscloud/api_key_test.go b/plugins/rediscloud/api_key_test.go new file mode 100644 index 000000000..4a6e57e76 --- /dev/null +++ b/plugins/rediscloud/api_key_test.go @@ -0,0 +1,45 @@ +package rediscloud + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestRedisCloudAPIKeyProvisioner(t *testing.T) { + plugintest.TestProvisioner(t, APIKey().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "default": { + ItemFields: map[sdk.FieldName]string{ + fieldname.AccountKey: "5v0mPzRKNcvlwRMi4CjWISt15UfCRxjcNVMPCZfDOJTZEXAMPLE", + fieldname.UserKey: "I2mLL1tjTKcyb5p0vWUSAcuO7XTut2QPPSSMavKQbrCEXAMPLE", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "REDISCLOUD_ACCESS_KEY": "5v0mPzRKNcvlwRMi4CjWISt15UfCRxjcNVMPCZfDOJTZEXAMPLE", + "REDISCLOUD_SECRET_KEY": "I2mLL1tjTKcyb5p0vWUSAcuO7XTut2QPPSSMavKQbrCEXAMPLE", + }, + }, + }, + }) +} + +func TestRedisCloudAPIKeyImporter(t *testing.T) { + plugintest.TestImporter(t, APIKey().Importer, map[string]plugintest.ImportCase{ + "environment": { + Environment: map[string]string{ + "REDISCLOUD_ACCESS_KEY": "5v0mPzRKNcvlwRMi4CjWISt15UfCRxjcNVMPCZfDOJTZEXAMPLE", + "REDISCLOUD_SECRET_KEY": "I2mLL1tjTKcyb5p0vWUSAcuO7XTut2QPPSSMavKQbrCEXAMPLE", + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.AccountKey: "5v0mPzRKNcvlwRMi4CjWISt15UfCRxjcNVMPCZfDOJTZEXAMPLE", + fieldname.UserKey: "I2mLL1tjTKcyb5p0vWUSAcuO7XTut2QPPSSMavKQbrCEXAMPLE", + }, + }, + }, + }, + }) +} diff --git a/plugins/rediscloud/plugin.go b/plugins/rediscloud/plugin.go new file mode 100644 index 000000000..8db45d9ea --- /dev/null +++ b/plugins/rediscloud/plugin.go @@ -0,0 +1,19 @@ +package rediscloud + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "rediscloud", + Platform: schema.PlatformInfo{ + Name: "Redis Cloud", + Homepage: sdk.URL("https://redis.com/redis-enterprise-cloud/overview/"), + }, + Credentials: []schema.CredentialType{ + APIKey(), + }, + } +} diff --git a/sdk/schema/credname/names.go b/sdk/schema/credname/names.go index c230ed51b..1a2821673 100644 --- a/sdk/schema/credname/names.go +++ b/sdk/schema/credname/names.go @@ -18,11 +18,13 @@ const ( DatabaseCredentials = sdk.CredentialName("Database Credentials") DeployKey = sdk.CredentialName("Deploy Key") LoginDetails = sdk.CredentialName("Login Details") + Password = sdk.CredentialName("Password") PersonalAPIToken = sdk.CredentialName("Personal API Token") PersonalAccessToken = sdk.CredentialName("Personal Access Token") RegistryCredentials = sdk.CredentialName("Registry Credentials") SecretKey = sdk.CredentialName("Secret Key") UserLogin = sdk.CredentialName("User Login") + UserCredentials = sdk.CredentialName("User Credentials") ) func ListAll() []sdk.CredentialName { @@ -41,10 +43,12 @@ func ListAll() []sdk.CredentialName { DatabaseCredentials, DeployKey, LoginDetails, + Password, PersonalAPIToken, PersonalAccessToken, RegistryCredentials, SecretKey, UserLogin, + UserCredentials, } } diff --git a/sdk/schema/credname/names_test.go b/sdk/schema/credname/names_test.go index 1568cff35..4b33ba03a 100644 --- a/sdk/schema/credname/names_test.go +++ b/sdk/schema/credname/names_test.go @@ -22,10 +22,12 @@ func TestGettingCredentialIDsFromNames(t *testing.T) { Credentials, DatabaseCredentials, LoginDetails, + Password, PersonalAPIToken, PersonalAccessToken, RegistryCredentials, SecretKey, + UserCredentials, sdk.CredentialName(""), sdk.CredentialName("Database-specific Credentials"), sdk.CredentialName("Public/Private Key-Pair"), @@ -50,10 +52,12 @@ func TestGettingCredentialIDsFromNames(t *testing.T) { "credentials", "database_credentials", "login_details", + "password", "personal_api_token", "personal_access_token", "registry_credentials", "secret_key", + "user_credentials", "", "database_specific_credentials", "public_private_key_pair", diff --git a/sdk/schema/fieldname/names.go b/sdk/schema/fieldname/names.go index 1ffc34a6d..1cc0d992e 100644 --- a/sdk/schema/fieldname/names.go +++ b/sdk/schema/fieldname/names.go @@ -4,6 +4,7 @@ import "github.com/1Password/shell-plugins/sdk" // Credential field names. const ( + AccountKey = sdk.FieldName("Account Key") APIHost = sdk.FieldName("API Host") APIUrl = sdk.FieldName("API URL") APIKey = sdk.FieldName("API Key") @@ -56,6 +57,7 @@ const ( URL = sdk.FieldName("URL") User = sdk.FieldName("User") UserAccessToken = sdk.FieldName("User Access Token") + UserKey = sdk.FieldName("User Key") Username = sdk.FieldName("Username") Website = sdk.FieldName("Website") ) @@ -70,6 +72,7 @@ func ListAll() []sdk.FieldName { AccessToken, Account, AccountID, + AccountKey, AccountSID, Address, AppKey, @@ -109,6 +112,7 @@ func ListAll() []sdk.FieldName { Token, URL, User, + UserKey, Username, Website, }