diff --git a/plugins/gcloud/bq.go b/plugins/gcloud/bq.go new file mode 100644 index 000000000..458cca516 --- /dev/null +++ b/plugins/gcloud/bq.go @@ -0,0 +1,25 @@ +package gcloud + +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 BqCLI() schema.Executable { + return schema.Executable{ + Name: "BigQuery CLI", + Runs: []string{"bq"}, + DocsURL: sdk.URL("https://cloud.google.com/bigquery/docs/bq-command-line-tool"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.ServiceAccountKey, + }, + }, + } +} diff --git a/plugins/gcloud/gcloud.go b/plugins/gcloud/gcloud.go new file mode 100644 index 000000000..f56a5452f --- /dev/null +++ b/plugins/gcloud/gcloud.go @@ -0,0 +1,29 @@ +package gcloud + +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 GCloudCLI() schema.Executable { + return schema.Executable{ + Name: "Google Cloud CLI", + Runs: []string{"gcloud"}, + DocsURL: sdk.URL("https://cloud.google.com/sdk/gcloud"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + needsauth.NotWhenContainsArgs("auth"), + needsauth.NotWhenContainsArgs("config"), + needsauth.NotWhenContainsArgs("info"), + needsauth.NotWhenContainsArgs("components"), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.ServiceAccountKey, + }, + }, + } +} diff --git a/plugins/gcloud/gsutil.go b/plugins/gcloud/gsutil.go new file mode 100644 index 000000000..9d6cfe358 --- /dev/null +++ b/plugins/gcloud/gsutil.go @@ -0,0 +1,25 @@ +package gcloud + +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 GsutilCLI() schema.Executable { + return schema.Executable{ + Name: "Google Cloud Storage CLI", + Runs: []string{"gsutil"}, + DocsURL: sdk.URL("https://cloud.google.com/storage/docs/gsutil"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + { + Name: credname.ServiceAccountKey, + }, + }, + } +} diff --git a/plugins/gcloud/plugin.go b/plugins/gcloud/plugin.go new file mode 100644 index 000000000..f2224297a --- /dev/null +++ b/plugins/gcloud/plugin.go @@ -0,0 +1,24 @@ +package gcloud + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +func New() schema.Plugin { + return schema.Plugin{ + Name: "gcloud", + Platform: schema.PlatformInfo{ + Name: "Google Cloud Platform", + Homepage: sdk.URL("https://cloud.google.com"), + }, + Credentials: []schema.CredentialType{ + ServiceAccountKey(), + }, + Executables: []schema.Executable{ + GCloudCLI(), + GsutilCLI(), + BqCLI(), + }, + } +} diff --git a/plugins/gcloud/service_account_key.go b/plugins/gcloud/service_account_key.go new file mode 100644 index 000000000..c6d8971df --- /dev/null +++ b/plugins/gcloud/service_account_key.go @@ -0,0 +1,194 @@ +package gcloud + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func ServiceAccountKey() schema.CredentialType { + return schema.CredentialType{ + Name: credname.ServiceAccountKey, + DocsURL: sdk.URL("https://cloud.google.com/iam/docs/keys-create-delete"), + ManagementURL: sdk.URL("https://console.cloud.google.com/iam-admin/serviceaccounts"), + Fields: []schema.CredentialField{ + { + Name: fieldname.Credential, + MarkdownDescription: "The JSON credential content for a service account key or authorized user credential.", + Secret: true, + }, + { + Name: fieldname.ProjectID, + MarkdownDescription: "The default GCP project to use.", + Optional: true, + }, + { + Name: fieldname.Account, + MarkdownDescription: "The account email associated with the credential.", + Optional: true, + }, + }, + DefaultProvisioner: GCPProvisioner{}, + Importer: importer.TryAll( + TryGCloudApplicationDefaultCredentialsFile(), + TryGoogleApplicationCredentialsEnvVar(), + ), + } +} + +// GCPProvisioner writes the credential JSON to a temporary file and sets +// GOOGLE_APPLICATION_CREDENTIALS to point at it. This approach works with +// all GCP tools (gcloud, gsutil, bq, client libraries, Terraform). +type GCPProvisioner struct{} + +func (p GCPProvisioner) Description() string { + return "Provision GCP credential as a temporary JSON file and set GOOGLE_APPLICATION_CREDENTIALS" +} + +func (p GCPProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) { + credJSON := in.ItemFields[fieldname.Credential] + var cred gcpCredentialFile + if err := json.Unmarshal([]byte(credJSON), &cred); err != nil { + out.AddError(errInvalidJSON) + return + } + + outPath := filepath.Join(in.TempDir, "gcloud-credentials.json") + out.AddSecretFile(outPath, []byte(credJSON)) + out.AddEnvVar("GOOGLE_APPLICATION_CREDENTIALS", outPath) + // CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE tells the gcloud CLI to use + // this credential file directly, bypassing its own auth store. + // GOOGLE_APPLICATION_CREDENTIALS alone is only respected by client + // libraries, not by gcloud CLI commands. + out.AddEnvVar("CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", outPath) + + if projectID, ok := in.ItemFields[fieldname.ProjectID]; ok && projectID != "" { + out.AddEnvVar("CLOUDSDK_CORE_PROJECT", projectID) + } else if cred.ProjectID != "" { + out.AddEnvVar("CLOUDSDK_CORE_PROJECT", cred.ProjectID) + } + + if account, ok := in.ItemFields[fieldname.Account]; ok && account != "" { + out.AddEnvVar("CLOUDSDK_CORE_ACCOUNT", account) + } else if cred.ClientEmail != "" { + out.AddEnvVar("CLOUDSDK_CORE_ACCOUNT", cred.ClientEmail) + } +} + +func (p GCPProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) { + // Temp files are automatically cleaned up by the SDK. +} + +var errInvalidJSON = &jsonError{} + +type jsonError struct{} + +func (e *jsonError) Error() string { + return "credential field does not contain valid JSON" +} + +// TryGCloudApplicationDefaultCredentialsFile imports credentials from the +// gcloud application default credentials file at ~/.config/gcloud/application_default_credentials.json. +// +// This file may contain either a service_account key (long-lived, never expires +// unless explicitly deleted) or an authorized_user credential generated by +// "gcloud auth application-default login". Authorized user credentials contain +// a refresh token whose lifetime depends on the Google account type: +// +// - Personal Gmail accounts: refresh tokens are long-lived and only expire +// if revoked, unused for 6 months, or the password is changed. +// - Google Workspace / Cloud Identity accounts: refresh tokens are subject +// to session length policies configured by the org admin (typically 1–24 +// hours), after which reauthentication is required. +// +// Both types are imported. Users with managed org accounts should prefer +// service account keys for a more durable credential. +func TryGCloudApplicationDefaultCredentialsFile() sdk.Importer { + return importer.TryFile("~/.config/gcloud/application_default_credentials.json", func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) { + var cred gcpCredentialFile + if err := contents.ToJSON(&cred); err != nil { + out.AddError(err) + return + } + + if cred.Type == "" { + return + } + + candidate := sdk.ImportCandidate{ + Fields: map[sdk.FieldName]string{ + fieldname.Credential: contents.ToString(), + }, + } + + if cred.ProjectID != "" { + candidate.Fields[fieldname.ProjectID] = cred.ProjectID + } + + if cred.ClientEmail != "" { + candidate.NameHint = importer.SanitizeNameHint(cred.ClientEmail) + } + + out.AddCandidate(candidate) + }) +} + +// TryGoogleApplicationCredentialsEnvVar imports credentials from the file +// pointed to by the GOOGLE_APPLICATION_CREDENTIALS environment variable. +func TryGoogleApplicationCredentialsEnvVar() sdk.Importer { + return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) { + attempt := out.NewAttempt(importer.SourceEnvVars("GOOGLE_APPLICATION_CREDENTIALS")) + + filePath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if filePath == "" { + return + } + + contents, err := os.ReadFile(filePath) + if err != nil { + attempt.AddError(err) + return + } + + var cred gcpCredentialFile + if err := json.Unmarshal(contents, &cred); err != nil { + attempt.AddError(err) + return + } + + if cred.Type == "" { + return + } + + candidate := sdk.ImportCandidate{ + Fields: map[sdk.FieldName]string{ + fieldname.Credential: string(contents), + }, + } + + if cred.ProjectID != "" { + candidate.Fields[fieldname.ProjectID] = cred.ProjectID + } + + if cred.ClientEmail != "" { + candidate.NameHint = importer.SanitizeNameHint(cred.ClientEmail) + } + + attempt.AddCandidate(candidate) + } +} + +// gcpCredentialFile represents the minimal structure of a GCP credential JSON file, +// supporting both service_account and authorized_user types. +type gcpCredentialFile struct { + Type string `json:"type"` + ProjectID string `json:"project_id,omitempty"` + ClientEmail string `json:"client_email,omitempty"` +} diff --git a/plugins/gcloud/service_account_key_test.go b/plugins/gcloud/service_account_key_test.go new file mode 100644 index 000000000..ebf9a88c8 --- /dev/null +++ b/plugins/gcloud/service_account_key_test.go @@ -0,0 +1,206 @@ +package gcloud + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/plugintest" + "github.com/1Password/shell-plugins/sdk/schema/fieldname" +) + +func TestServiceAccountKeyProvisioner(t *testing.T) { + saKeyJSON := plugintest.LoadFixture(t, "service_account_key.json") + adcJSON := plugintest.LoadFixture(t, "application_default_credentials.json") + + plugintest.TestProvisioner(t, ServiceAccountKey().DefaultProvisioner, map[string]plugintest.ProvisionCase{ + "service account key": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Credential: saKeyJSON, + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/gcloud-credentials.json", + "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "/tmp/gcloud-credentials.json", + "CLOUDSDK_CORE_PROJECT": "my-gcp-project", + "CLOUDSDK_CORE_ACCOUNT": "test@my-gcp-project.iam.gserviceaccount.com", + }, + Files: map[string]sdk.OutputFile{ + "/tmp/gcloud-credentials.json": {Contents: []byte(saKeyJSON)}, + }, + }, + }, + "service account key with project": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Credential: saKeyJSON, + fieldname.ProjectID: "explicit-project", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/gcloud-credentials.json", + "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "/tmp/gcloud-credentials.json", + "CLOUDSDK_CORE_PROJECT": "explicit-project", + "CLOUDSDK_CORE_ACCOUNT": "test@my-gcp-project.iam.gserviceaccount.com", + }, + Files: map[string]sdk.OutputFile{ + "/tmp/gcloud-credentials.json": {Contents: []byte(saKeyJSON)}, + }, + }, + }, + "service account key with explicit account": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Credential: saKeyJSON, + fieldname.Account: "explicit-account@example.com", + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/gcloud-credentials.json", + "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "/tmp/gcloud-credentials.json", + "CLOUDSDK_CORE_PROJECT": "my-gcp-project", + "CLOUDSDK_CORE_ACCOUNT": "explicit-account@example.com", + }, + Files: map[string]sdk.OutputFile{ + "/tmp/gcloud-credentials.json": {Contents: []byte(saKeyJSON)}, + }, + }, + }, + "authorized user credential": { + ItemFields: map[sdk.FieldName]string{ + fieldname.Credential: adcJSON, + }, + ExpectedOutput: sdk.ProvisionOutput{ + Environment: map[string]string{ + "GOOGLE_APPLICATION_CREDENTIALS": "/tmp/gcloud-credentials.json", + "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "/tmp/gcloud-credentials.json", + }, + Files: map[string]sdk.OutputFile{ + "/tmp/gcloud-credentials.json": {Contents: []byte(adcJSON)}, + }, + }, + }, + }) +} + +func TestServiceAccountKeyImporter(t *testing.T) { + saKeyJSON := plugintest.LoadFixture(t, "service_account_key.json") + adcJSON := plugintest.LoadFixture(t, "application_default_credentials.json") + + plugintest.TestImporter(t, ServiceAccountKey().Importer, map[string]plugintest.ImportCase{ + "ADC file with service account key": { + Files: map[string]string{ + "~/.config/gcloud/application_default_credentials.json": saKeyJSON, + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Credential: saKeyJSON, + fieldname.ProjectID: "my-gcp-project", + }, + NameHint: "test@my-gcp-project.iam…", + }, + }, + }, + "ADC file with authorized user credential": { + Files: map[string]string{ + "~/.config/gcloud/application_default_credentials.json": adcJSON, + }, + ExpectedCandidates: []sdk.ImportCandidate{ + { + Fields: map[sdk.FieldName]string{ + fieldname.Credential: adcJSON, + }, + }, + }, + }, + }) +} + +func TestGCloudCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, GCloudCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "no args": { + Args: []string{}, + ExpectedNeedsAuth: false, + }, + "help flag": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "version flag": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "auth login": { + Args: []string{"auth", "login"}, + ExpectedNeedsAuth: false, + }, + "auth list": { + Args: []string{"auth", "list"}, + ExpectedNeedsAuth: false, + }, + "config set": { + Args: []string{"config", "set", "project", "my-project"}, + ExpectedNeedsAuth: false, + }, + "info": { + Args: []string{"info"}, + ExpectedNeedsAuth: false, + }, + "components list": { + Args: []string{"components", "list"}, + ExpectedNeedsAuth: false, + }, + "compute instances list": { + Args: []string{"compute", "instances", "list"}, + ExpectedNeedsAuth: true, + }, + "storage ls": { + Args: []string{"storage", "ls"}, + ExpectedNeedsAuth: true, + }, + "projects list": { + Args: []string{"projects", "list"}, + ExpectedNeedsAuth: true, + }, + }) +} + +func TestGsutilCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, GsutilCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "no args": { + Args: []string{}, + ExpectedNeedsAuth: false, + }, + "help flag": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "version flag": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "ls": { + Args: []string{"ls"}, + ExpectedNeedsAuth: true, + }, + }) +} + +func TestBqCLINeedsAuth(t *testing.T) { + plugintest.TestNeedsAuth(t, BqCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{ + "no args": { + Args: []string{}, + ExpectedNeedsAuth: false, + }, + "help flag": { + Args: []string{"--help"}, + ExpectedNeedsAuth: false, + }, + "version flag": { + Args: []string{"--version"}, + ExpectedNeedsAuth: false, + }, + "query": { + Args: []string{"query", "SELECT 1"}, + ExpectedNeedsAuth: true, + }, + }) +} diff --git a/plugins/gcloud/test-fixtures/application_default_credentials.json b/plugins/gcloud/test-fixtures/application_default_credentials.json new file mode 100644 index 000000000..79b9dc754 --- /dev/null +++ b/plugins/gcloud/test-fixtures/application_default_credentials.json @@ -0,0 +1,6 @@ +{ + "type": "authorized_user", + "client_id": "000000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "1//test-refresh-token" +} diff --git a/plugins/gcloud/test-fixtures/service_account_key.json b/plugins/gcloud/test-fixtures/service_account_key.json new file mode 100644 index 000000000..d1844c3a4 --- /dev/null +++ b/plugins/gcloud/test-fixtures/service_account_key.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "my-gcp-project", + "private_key_id": "abc123", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\ntest-private-key\n-----END RSA PRIVATE KEY-----\n", + "client_email": "test@my-gcp-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test%40my-gcp-project.iam.gserviceaccount.com" +} diff --git a/sdk/schema/credname/names.go b/sdk/schema/credname/names.go index c230ed51b..05b4c1b2c 100644 --- a/sdk/schema/credname/names.go +++ b/sdk/schema/credname/names.go @@ -22,6 +22,7 @@ const ( PersonalAccessToken = sdk.CredentialName("Personal Access Token") RegistryCredentials = sdk.CredentialName("Registry Credentials") SecretKey = sdk.CredentialName("Secret Key") + ServiceAccountKey = sdk.CredentialName("Service Account Key") UserLogin = sdk.CredentialName("User Login") ) @@ -45,6 +46,7 @@ func ListAll() []sdk.CredentialName { PersonalAccessToken, RegistryCredentials, SecretKey, + ServiceAccountKey, UserLogin, } }