From d655ae69fe76071b1f68d8899ebd79ab42097303 Mon Sep 17 00:00:00 2001 From: smalaviya-crest Date: Mon, 28 Sep 2026 18:00:26 +0530 Subject: [PATCH 1/3] feat(secretmanager): add Cloud SQL managed-rotation and secret-type samples Adds Cloud SQL managed-rotation samples for regional secrets (create/enable/rotate/schedule/get-type) plus secret-type samples for global secrets (create-with-type/get-type), ported from the completed Python reference implementation. Bumps google/cloud-secret-manager to ^2.4.0, the minimum version with Cloud SQL managed-rotation support. --- secretmanager/README.md | 1 + secretmanager/composer.json | 2 +- ...onal_secret_with_cloud_sql_credentials.php | 79 +++++ secretmanager/src/create_secret_with_type.php | 78 +++++ ...nable_regional_secret_managed_rotation.php | 86 ++++++ .../src/get_regional_secret_type.php | 71 +++++ secretmanager/src/get_secret_type.php | 67 +++++ secretmanager/src/rotate_regional_secret.php | 68 +++++ ..._secret_with_managed_rotation_schedule.php | 100 +++++++ .../test/regionalsecretmanagerTest.php | 273 ++++++++++++++++++ secretmanager/test/secretmanagerTest.php | 33 +++ 11 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php create mode 100644 secretmanager/src/create_secret_with_type.php create mode 100644 secretmanager/src/enable_regional_secret_managed_rotation.php create mode 100644 secretmanager/src/get_regional_secret_type.php create mode 100644 secretmanager/src/get_secret_type.php create mode 100644 secretmanager/src/rotate_regional_secret.php create mode 100644 secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php diff --git a/secretmanager/README.md b/secretmanager/README.md index 278ff9da1c..e802df65aa 100644 --- a/secretmanager/README.md +++ b/secretmanager/README.md @@ -21,6 +21,7 @@ This simple command-line application demonstrates how to invoke - GOOGLE_CLOUD_PUBSUB_TOPIC - Full name of topic (projects/{project}/topics/{topic}). - GOOGLE_CLOUD_KMS_KEY - Full name of global KMS key (projects/{project}/locations/global/keyRings/{keyring}/cryptoKeys/{key}). - GOOGLE_CLOUD_REGIONAL_KMS_KEY - Full name of regional KMS key (projects/{project}/locations/{location}/keyRings/{keyring}/cryptoKeys/{key}). + - CLOUD_SQL_INSTANCE / CLOUD_SQL_USER - Bare Cloud SQL instance ID and database username, required by the Cloud SQL managed-rotation tests. 1. **Download The Credentials** - Click "Go to credentials" after enabling the APIs. Click "New Credentials" and select "Service Account Key". Create a new diff --git a/secretmanager/composer.json b/secretmanager/composer.json index 2a20b5355d..38f064bb61 100644 --- a/secretmanager/composer.json +++ b/secretmanager/composer.json @@ -1,6 +1,6 @@ { "require": { - "google/cloud-secret-manager": "^2.1.0", + "google/cloud-secret-manager": "^2.4.0", "google/cloud-resource-manager": "^1.0" } } diff --git a/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php b/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php new file mode 100644 index 0000000000..9076afc29f --- /dev/null +++ b/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php @@ -0,0 +1,79 @@ + "secretmanager.$locationId.rep.googleapis.com"]; + + // Create the Secret Manager client. + $client = new SecretManagerServiceClient($options); + + // Build the resource name of the parent project. + $parent = $client->locationName($projectId, $locationId); + + $secret = new Secret([ + 'secret_type' => SecretType::CLOUD_SQL_DB_CREDENTIALS, + ]); + + $request = CreateSecretRequest::build($parent, $secretId, $secret); + + // Create the secret. + $newSecret = $client->createSecret($request); + + printf('Created secret: %s%s', $newSecret->getName(), PHP_EOL); + + // This built-in identity is what you grant Cloud SQL IAM permissions to, + // so that Secret Manager can rotate the database password on its behalf. + printf( + 'Grant this identity Cloud SQL IAM permissions to enable rotation: %s%s', + $newSecret->getPolicyMember()->getIamPolicyUidPrincipal(), + PHP_EOL + ); +} +// [END secretmanager_create_regional_secret_with_cloud_sql_credentials] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/create_secret_with_type.php b/secretmanager/src/create_secret_with_type.php new file mode 100644 index 0000000000..8253ee8471 --- /dev/null +++ b/secretmanager/src/create_secret_with_type.php @@ -0,0 +1,78 @@ +projectName($projectId); + + $secret = new Secret([ + 'replication' => new Replication([ + 'automatic' => new Automatic(), + ]), + 'secret_type' => SecretType::value($secretType), + ]); + + // Build the request. + $request = CreateSecretRequest::build($parent, $secretId, $secret); + + // Create the secret, with the given secret type restriction. + $newSecret = $client->createSecret($request); + + // Print the new secret name. + printf('Created secret with secret type: %s%s', $newSecret->getName(), PHP_EOL); +} +// [END secretmanager_create_secret_with_type] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/enable_regional_secret_managed_rotation.php b/secretmanager/src/enable_regional_secret_managed_rotation.php new file mode 100644 index 0000000000..00491616d2 --- /dev/null +++ b/secretmanager/src/enable_regional_secret_managed_rotation.php @@ -0,0 +1,86 @@ + "secretmanager.$locationId.rep.googleapis.com"]; + + // Create the Secret Manager client. + $client = new SecretManagerServiceClient($options); + + // Build the resource name of the secret. + $parent = $client->projectLocationSecretName($projectId, $locationId, $secretId); + + $credentials = new CloudSQLSingleUserCredentials([ + 'instance_id' => $instanceId, + 'username' => $username, + // Leaving password unset lets Secret Manager generate a secure + // password itself. + ]); + + $request = (new EnableManagedRotationRequest()) + ->setParent($parent) + ->setCloudSqlSingleUserCredentials($credentials); + + // Enable managed rotation. + $version = $client->enableManagedRotation($request); + + printf('Enabled managed rotation, created secret version: %s%s', $version->getName(), PHP_EOL); +} +// [END secretmanager_enable_regional_secret_managed_rotation] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/get_regional_secret_type.php b/secretmanager/src/get_regional_secret_type.php new file mode 100644 index 0000000000..8f30a5a314 --- /dev/null +++ b/secretmanager/src/get_regional_secret_type.php @@ -0,0 +1,71 @@ + "secretmanager.$locationId.rep.googleapis.com"]; + + // Create the Secret Manager client. + $client = new SecretManagerServiceClient($options); + + // Build the resource name of the secret. + $name = $client->projectLocationSecretName($projectId, $locationId, $secretId); + + // Build the request. + $request = GetSecretRequest::build($name); + + // Get the secret. + $secret = $client->getSecret($request); + + printf( + 'Found regional secret %s with secret type %s%s', + $secret->getName(), + SecretType::name($secret->getSecretType()), + PHP_EOL + ); +} +// [END secretmanager_get_regional_secret_type] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/get_secret_type.php b/secretmanager/src/get_secret_type.php new file mode 100644 index 0000000000..ba5d00b9ea --- /dev/null +++ b/secretmanager/src/get_secret_type.php @@ -0,0 +1,67 @@ +secretName($projectId, $secretId); + + // Build the request. + $request = GetSecretRequest::build($name); + + // Get the secret. + $secret = $client->getSecret($request); + + printf( + 'Found secret %s with secret type %s%s', + $secret->getName(), + SecretType::name($secret->getSecretType()), + PHP_EOL + ); +} +// [END secretmanager_get_secret_type] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/rotate_regional_secret.php b/secretmanager/src/rotate_regional_secret.php new file mode 100644 index 0000000000..9f251a9cc2 --- /dev/null +++ b/secretmanager/src/rotate_regional_secret.php @@ -0,0 +1,68 @@ + "secretmanager.$locationId.rep.googleapis.com"]; + + // Create the Secret Manager client. + $client = new SecretManagerServiceClient($options); + + // Build the resource name of the secret. Note that although the field is + // named "parent", its value is the full secret resource name, not a + // collection parent. + $parent = $client->projectLocationSecretName($projectId, $locationId, $secretId); + + $request = (new RotateSecretRequest()) + ->setParent($parent); + + // Rotate the secret. + $version = $client->rotateSecret($request); + + printf('Rotated secret, created secret version: %s%s', $version->getName(), PHP_EOL); +} +// [END secretmanager_rotate_regional_secret] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php b/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php new file mode 100644 index 0000000000..4f34858297 --- /dev/null +++ b/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php @@ -0,0 +1,100 @@ + "secretmanager.$locationId.rep.googleapis.com"]; + + // Create the Secret Manager client. + $client = new SecretManagerServiceClient($options); + + // Build the resource name of the secret. + $name = $client->projectLocationSecretName($projectId, $locationId, $secretId); + + // next_rotation_time and rotation_period must be set together. + $nextRotationTimeSeconds = time() + $rotationPeriodSeconds; + + $rotation = new Rotation([ + 'next_rotation_time' => new Timestamp(['seconds' => $nextRotationTimeSeconds]), + 'rotation_period' => new Duration(['seconds' => $rotationPeriodSeconds]), + ]); + + $secret = new Secret([ + 'name' => $name, + 'rotation' => $rotation, + ]); + + // Mask only the two subfields being set here, not the whole "rotation" + // submessage -- that would also include managed_rotation_status, which + // is output-only and rejects a whole-submessage replace with "immutable + // and cannot be updated" (confirmed empirically against a live + // project). + $fieldMask = new FieldMask(); + $fieldMask->setPaths(['rotation.next_rotation_time', 'rotation.rotation_period']); + + $request = (new UpdateSecretRequest()) + ->setSecret($secret) + ->setUpdateMask($fieldMask); + + // Update the secret. + $updatedSecret = $client->updateSecret($request); + + printf('Updated regional secret rotation schedule: %s%s', $updatedSecret->getName(), PHP_EOL); +} +// [END secretmanager_update_regional_secret_with_managed_rotation_schedule] + +// The following 2 lines are only needed to execute the samples on the CLI +require_once __DIR__ . '/../../testing/sample_helpers.php'; +\Google\Cloud\Samples\execute_sample(__FILE__, __NAMESPACE__, $argv); diff --git a/secretmanager/test/regionalsecretmanagerTest.php b/secretmanager/test/regionalsecretmanagerTest.php index b778c84ea8..3c70b39e53 100644 --- a/secretmanager/test/regionalsecretmanagerTest.php +++ b/secretmanager/test/regionalsecretmanagerTest.php @@ -20,8 +20,12 @@ namespace Google\Cloud\Samples\SecretManager; use Google\ApiCore\ApiException as GaxApiException; +use Google\Cloud\Iam\V1\Binding; +use Google\Cloud\Iam\V1\GetIamPolicyRequest; +use Google\Cloud\Iam\V1\SetIamPolicyRequest; use Google\Cloud\ResourceManager\V3\DeleteTagKeyRequest; use Google\Cloud\ResourceManager\V3\DeleteTagValueRequest; +use Google\Cloud\ResourceManager\V3\Client\ProjectsClient; use Google\Cloud\ResourceManager\V3\Client\TagKeysClient; use Google\Cloud\ResourceManager\V3\CreateTagKeyRequest; use Google\Cloud\ResourceManager\V3\TagKey; @@ -35,6 +39,7 @@ use Google\Cloud\SecretManager\V1\DisableSecretVersionRequest; use Google\Cloud\SecretManager\V1\GetSecretRequest; use Google\Cloud\SecretManager\V1\Secret; +use Google\Cloud\SecretManager\V1\Secret\SecretType; use Google\Cloud\SecretManager\V1\SecretPayload; use Google\Cloud\SecretManager\V1\SecretVersion; use Google\Cloud\TestUtils\TestTrait; @@ -44,9 +49,17 @@ class regionalsecretmanagerTest extends TestCase { use TestTrait; + // Role granted to a Cloud SQL DB credentials secret's built-in identity + // so that managed rotation can update the Cloud SQL user's password. + // This grant is per-secret (the member is the secret's own generated + // principal), so it has to be made fresh for every secret the Cloud SQL + // managed-rotation tests create. + private const CLOUD_SQL_ROLE = 'roles/cloudsql.admin'; + private static $client; private static $tagKeyClient; private static $tagValuesClient; + private static $projectsClient; private static $testSecret; private static $testSecretToDelete; @@ -68,6 +81,7 @@ class regionalsecretmanagerTest extends TestCase private static $testSecretWithCMEKToCreateName; private static $testSecretWithTopicToCreateName; private static $testSecretWithRotationToCreateName; + private static $testSecretCloudSqlToCreateName; private static $iamUser = 'user:kapishsingh@google.com'; private static $locationId = 'us-central1'; @@ -85,12 +99,17 @@ class regionalsecretmanagerTest extends TestCase private static $skipRotationTests = false; private static $testRotationTopic; + private static $skipCloudSqlTests = false; + private static $cloudSqlInstanceId; + private static $cloudSqlUsername; + public static function setUpBeforeClass(): void { $options = ['apiEndpoint' => 'secretmanager.' . self::$locationId . '.rep.googleapis.com' ]; self::$client = new SecretManagerServiceClient($options); self::$tagKeyClient = new TagKeysClient(); self::$tagValuesClient = new TagValuesClient(); + self::$projectsClient = new ProjectsClient(); self::$testSecret = self::createSecret(); self::$testSecretToDelete = self::createSecret(); @@ -105,6 +124,7 @@ public static function setUpBeforeClass(): void self::$testSecretWithCMEKToCreateName = self::$client->projectLocationSecretName(self::$projectId, self::$locationId, self::randomSecretId()); self::$testSecretWithTopicToCreateName = self::$client->projectLocationSecretName(self::$projectId, self::$locationId, self::randomSecretId()); self::$testSecretWithRotationToCreateName = self::$client->projectLocationSecretName(self::$projectId, self::$locationId, self::randomSecretId()); + self::$testSecretCloudSqlToCreateName = self::$client->projectLocationSecretName(self::$projectId, self::$locationId, self::randomSecretId()); self::$testSecretVersion = self::addSecretVersion(self::$testSecretWithVersions); self::$testSecretVersionToDestroy = self::addSecretVersion(self::$testSecretWithVersions); @@ -127,6 +147,22 @@ public static function setUpBeforeClass(): void } else { self::$testRotationTopic = $envTopic; } + + // CLOUD_SQL_INSTANCE is the bare Cloud SQL instance ID (no project + // or region prefix). The instance must be in self::$locationId's + // region, and CLOUD_SQL_USER must already exist as a database user + // on it. Standing up a real Cloud SQL instance per test run is + // expensive, so it's supplied as a pre-provisioned fixture via env + // vars rather than created here. + $cloudSqlInstance = getenv('CLOUD_SQL_INSTANCE'); + $cloudSqlUser = getenv('CLOUD_SQL_USER'); + if ($cloudSqlInstance === false || $cloudSqlInstance === '' || $cloudSqlUser === false || $cloudSqlUser === '') { + self::$skipCloudSqlTests = true; + printf('Skipping Cloud SQL managed-rotation tests dependent on CLOUD_SQL_INSTANCE/CLOUD_SQL_USER as they are not set.%s', PHP_EOL); + } else { + self::$cloudSqlInstanceId = $cloudSqlInstance; + self::$cloudSqlUsername = $cloudSqlUser; + } } public static function tearDownAfterClass(): void @@ -147,6 +183,7 @@ public static function tearDownAfterClass(): void self::deleteSecret(self::$testSecretWithCMEKToCreateName); self::deleteSecret(self::$testSecretWithTopicToCreateName); self::deleteSecret(self::$testSecretWithRotationToCreateName); + self::deleteSecret(self::$testSecretCloudSqlToCreateName); sleep(15); // Added a sleep to wait for the tag unbinding self::deleteTagValue(); self::deleteTagKey(); @@ -206,6 +243,110 @@ private static function getSecret(string $projectId, string $locationId, string return self::$client->getSecret($getSecretRequest); } + private static function createCloudSqlCredentialsSecret(): Secret + { + $parent = self::$client->locationName(self::$projectId, self::$locationId); + $secretId = self::randomSecretId(); + $secret = new Secret(['secret_type' => SecretType::CLOUD_SQL_DB_CREDENTIALS]); + $createSecretRequest = CreateSecretRequest::build($parent, $secretId, $secret); + + return self::$client->createSecret($createSecretRequest); + } + + /** + * Grants self::CLOUD_SQL_ROLE to $member on the project. SetIamPolicy + * replaces the whole policy, so this reads the current policy, adds the + * member to the existing (or a new) binding for the role, and writes it + * back -- retrying the whole read-modify-write if another writer raced + * us (an ABORTED status from an etag mismatch). + */ + private static function grantCloudSqlRole(string $member): void + { + $resource = 'projects/' . self::$projectId; + + for ($attempt = 0; $attempt < 5; $attempt++) { + $policy = self::$projectsClient->getIamPolicy( + (new GetIamPolicyRequest())->setResource($resource) + ); + + $bindings = $policy->getBindings(); + $found = false; + foreach ($bindings as $binding) { + if ($binding->getRole() === self::CLOUD_SQL_ROLE) { + $members = $binding->getMembers(); + if (!in_array($member, iterator_to_array($members), true)) { + $members[] = $member; + } + $found = true; + break; + } + } + if (!$found) { + $bindings[] = new Binding([ + 'role' => self::CLOUD_SQL_ROLE, + 'members' => [$member], + ]); + } + + try { + self::$projectsClient->setIamPolicy( + (new SetIamPolicyRequest())->setResource($resource)->setPolicy($policy) + ); + // IAM grants are eventually consistent; give it a moment + // before a caller tries to use it for managed rotation. + sleep(10); + return; + } catch (GaxApiException $e) { + if ($e->getStatus() !== 'ABORTED' || $attempt === 4) { + throw $e; + } + } + } + } + + /** Removes $member from self::CLOUD_SQL_ROLE on the project, added by grantCloudSqlRole(). */ + private static function revokeCloudSqlRole(string $member): void + { + $resource = 'projects/' . self::$projectId; + + for ($attempt = 0; $attempt < 5; $attempt++) { + $policy = self::$projectsClient->getIamPolicy( + (new GetIamPolicyRequest())->setResource($resource) + ); + + $changed = false; + foreach ($policy->getBindings() as $binding) { + if ($binding->getRole() === self::CLOUD_SQL_ROLE) { + $members = $binding->getMembers(); + $remaining = array_values(array_filter( + iterator_to_array($members), + fn ($m) => $m !== $member + )); + if (count($remaining) !== count($members)) { + $binding->setMembers($remaining); + $changed = true; + } + break; + } + } + + if (!$changed) { + return; + } + + try { + self::$projectsClient->setIamPolicy( + (new SetIamPolicyRequest())->setResource($resource)->setPolicy($policy) + ); + return; + } catch (GaxApiException $e) { + if ($e->getStatus() !== 'ABORTED' || $attempt === 4) { + throw $e; + } + } + } + } + private static function createTagKey(string $short_name): string { $parent = self::$client->projectName(self::$projectId); @@ -942,4 +1083,136 @@ public function testDeleteTagFromSecret() $this->assertStringContainsString('Deleted tag binding', $output); } + + public function testCreateSecretWithCloudSqlCredentials() + { + $name = self::$client->parseName(self::$testSecretCloudSqlToCreateName); + + $output = $this->runFunctionSnippet('create_regional_secret_with_cloud_sql_credentials', [ + $name['project'], + $name['location'], + $name['secret'], + ]); + + $this->assertStringContainsString('Created secret', $output); + $this->assertStringContainsString('Grant this identity Cloud SQL IAM permissions', $output); + + $secret = self::getSecret($name['project'], $name['location'], $name['secret']); + $this->assertSame(SecretType::CLOUD_SQL_DB_CREDENTIALS, $secret->getSecretType()); + } + + public function testEnableRegionalSecretManagedRotation() + { + if (self::$skipCloudSqlTests) { + $this->markTestSkipped('CLOUD_SQL_INSTANCE/CLOUD_SQL_USER not set'); + } + + $secret = self::createCloudSqlCredentialsSecret(); + $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); + self::grantCloudSqlRole($member); + + try { + $name = self::$client->parseName($secret->getName()); + + $output = $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ + $name['project'], + $name['location'], + $name['secret'], + self::$cloudSqlInstanceId, + self::$cloudSqlUsername, + ]); + + $this->assertStringContainsString('Enabled managed rotation', $output); + } finally { + self::revokeCloudSqlRole($member); + self::deleteSecret($secret->getName()); + } + } + + public function testRotateRegionalSecret() + { + if (self::$skipCloudSqlTests) { + $this->markTestSkipped('CLOUD_SQL_INSTANCE/CLOUD_SQL_USER not set'); + } + + $secret = self::createCloudSqlCredentialsSecret(); + $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); + self::grantCloudSqlRole($member); + + try { + $name = self::$client->parseName($secret->getName()); + + $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ + $name['project'], + $name['location'], + $name['secret'], + self::$cloudSqlInstanceId, + self::$cloudSqlUsername, + ]); + + $output = $this->runFunctionSnippet('rotate_regional_secret', [ + $name['project'], + $name['location'], + $name['secret'], + ]); + + $this->assertStringContainsString('Rotated secret', $output); + } finally { + self::revokeCloudSqlRole($member); + self::deleteSecret($secret->getName()); + } + } + + public function testUpdateRegionalSecretWithManagedRotationSchedule() + { + if (self::$skipCloudSqlTests) { + $this->markTestSkipped('CLOUD_SQL_INSTANCE/CLOUD_SQL_USER not set'); + } + + $secret = self::createCloudSqlCredentialsSecret(); + $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); + self::grantCloudSqlRole($member); + + try { + $name = self::$client->parseName($secret->getName()); + + $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ + $name['project'], + $name['location'], + $name['secret'], + self::$cloudSqlInstanceId, + self::$cloudSqlUsername, + ]); + + $rotationPeriodSeconds = 3600; + $output = $this->runFunctionSnippet('update_regional_secret_with_managed_rotation_schedule', [ + $name['project'], + $name['location'], + $name['secret'], + $rotationPeriodSeconds, + ]); + + $this->assertStringContainsString('Updated regional secret rotation schedule', $output); + + $updatedSecret = self::getSecret($name['project'], $name['location'], $name['secret']); + $this->assertSame($rotationPeriodSeconds, $updatedSecret->getRotation()->getRotationPeriod()->getSeconds()); + } finally { + self::revokeCloudSqlRole($member); + self::deleteSecret($secret->getName()); + } + } + + public function testGetRegionalSecretType() + { + $name = self::$client->parseName(self::$testSecretCloudSqlToCreateName); + + $output = $this->runFunctionSnippet('get_regional_secret_type', [ + $name['project'], + $name['location'], + $name['secret'], + ]); + + $this->assertStringContainsString('Found regional secret', $output); + $this->assertStringContainsString('CLOUD_SQL_DB_CREDENTIALS', $output); + } } diff --git a/secretmanager/test/secretmanagerTest.php b/secretmanager/test/secretmanagerTest.php index 0e02bea7dd..51039d8feb 100644 --- a/secretmanager/test/secretmanagerTest.php +++ b/secretmanager/test/secretmanagerTest.php @@ -37,6 +37,7 @@ use Google\Cloud\SecretManager\V1\Replication; use Google\Cloud\SecretManager\V1\Replication\Automatic; use Google\Cloud\SecretManager\V1\Secret; +use Google\Cloud\SecretManager\V1\Secret\SecretType; use Google\Cloud\SecretManager\V1\SecretPayload; use Google\Cloud\SecretManager\V1\SecretVersion; use Google\Cloud\TestUtils\TestTrait; @@ -71,6 +72,7 @@ class secretmanagerTest extends TestCase private static $testSecretWithCMEKToCreateName; private static $testSecretWithTopicToCreateName; private static $testSecretWithRotationToCreateName; + private static $testSecretWithTypeToCreateName; private static $iamUser = 'user:sethvargo@google.com'; private static $testLabelKey = 'test-label-key'; @@ -107,6 +109,7 @@ public static function setUpBeforeClass(): void self::$testSecretWithCMEKToCreateName = self::$client->secretName(self::$projectId, self::randomSecretId()); self::$testSecretWithTopicToCreateName = self::$client->secretName(self::$projectId, self::randomSecretId()); self::$testSecretWithRotationToCreateName = self::$client->secretName(self::$projectId, self::randomSecretId()); + self::$testSecretWithTypeToCreateName = self::$client->secretName(self::$projectId, self::randomSecretId()); self::$testSecretVersion = self::addSecretVersion(self::$testSecretWithVersions); self::$testSecretVersionToDestroy = self::addSecretVersion(self::$testSecretWithVersions); @@ -148,6 +151,7 @@ public static function tearDownAfterClass(): void self::deleteSecret(self::$testSecretWithCMEKToCreateName); self::deleteSecret(self::$testSecretWithTopicToCreateName); self::deleteSecret(self::$testSecretWithRotationToCreateName); + self::deleteSecret(self::$testSecretWithTypeToCreateName); sleep(15); // Added a sleep to wait for the tag unbinding self::deleteTagValue(); self::deleteTagKey(); @@ -919,4 +923,33 @@ public function testDeleteTagFromSecret() $this->assertStringContainsString('Deleted tag binding', $output); } + + public function testCreateSecretWithType() + { + $name = self::$client->parseName(self::$testSecretWithTypeToCreateName); + + $output = $this->runFunctionSnippet('create_secret_with_type', [ + $name['project'], + $name['secret'], + 'ACCESS_KEY', + ]); + + $this->assertStringContainsString('Created secret with secret type', $output); + + $secret = self::getSecret($name['project'], $name['secret']); + $this->assertSame(SecretType::ACCESS_KEY, $secret->getSecretType()); + } + + public function testGetSecretType() + { + $name = self::$client->parseName(self::$testSecretWithTypeToCreateName); + + $output = $this->runFunctionSnippet('get_secret_type', [ + $name['project'], + $name['secret'], + ]); + + $this->assertStringContainsString('Found secret', $output); + $this->assertStringContainsString('ACCESS_KEY', $output); + } } From b70546578573acb1cf68b81996e1f0b219e7f70c Mon Sep 17 00:00:00 2001 From: smalaviya-crest Date: Mon, 28 Sep 2026 18:23:07 +0530 Subject: [PATCH 2/3] fix(secretmanager): address review comments on Cloud SQL rotation tests - Add @depends annotations so testGetRegionalSecretType/testGetSecretType explicitly declare their dependency on the test that creates their secret, instead of relying on declaration order. - Move grantCloudSqlRole() inside the try block in the three Cloud SQL rotation tests so the secret is still cleaned up in finally if the grant itself throws. --- secretmanager/test/regionalsecretmanagerTest.php | 9 ++++++--- secretmanager/test/secretmanagerTest.php | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/secretmanager/test/regionalsecretmanagerTest.php b/secretmanager/test/regionalsecretmanagerTest.php index 3c70b39e53..01071a6001 100644 --- a/secretmanager/test/regionalsecretmanagerTest.php +++ b/secretmanager/test/regionalsecretmanagerTest.php @@ -1109,9 +1109,9 @@ public function testEnableRegionalSecretManagedRotation() $secret = self::createCloudSqlCredentialsSecret(); $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); - self::grantCloudSqlRole($member); try { + self::grantCloudSqlRole($member); $name = self::$client->parseName($secret->getName()); $output = $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ @@ -1137,9 +1137,9 @@ public function testRotateRegionalSecret() $secret = self::createCloudSqlCredentialsSecret(); $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); - self::grantCloudSqlRole($member); try { + self::grantCloudSqlRole($member); $name = self::$client->parseName($secret->getName()); $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ @@ -1171,9 +1171,9 @@ public function testUpdateRegionalSecretWithManagedRotationSchedule() $secret = self::createCloudSqlCredentialsSecret(); $member = $secret->getPolicyMember()->getIamPolicyUidPrincipal(); - self::grantCloudSqlRole($member); try { + self::grantCloudSqlRole($member); $name = self::$client->parseName($secret->getName()); $this->runFunctionSnippet('enable_regional_secret_managed_rotation', [ @@ -1202,6 +1202,9 @@ public function testUpdateRegionalSecretWithManagedRotationSchedule() } } + /** + * @depends testCreateSecretWithCloudSqlCredentials + */ public function testGetRegionalSecretType() { $name = self::$client->parseName(self::$testSecretCloudSqlToCreateName); diff --git a/secretmanager/test/secretmanagerTest.php b/secretmanager/test/secretmanagerTest.php index 51039d8feb..b41decb22f 100644 --- a/secretmanager/test/secretmanagerTest.php +++ b/secretmanager/test/secretmanagerTest.php @@ -940,6 +940,9 @@ public function testCreateSecretWithType() $this->assertSame(SecretType::ACCESS_KEY, $secret->getSecretType()); } + /** + * @depends testCreateSecretWithType + */ public function testGetSecretType() { $name = self::$client->parseName(self::$testSecretWithTypeToCreateName); From 50ba42f81115ed209c0b543fff143c32c5df0b69 Mon Sep 17 00:00:00 2001 From: smalaviya-crest Date: Fri, 9 Oct 2026 16:09:46 +0530 Subject: [PATCH 3/3] docs(secretmanager): address review feedback on Cloud SQL managed-rotation samples Align docblocks with other samples, simplify comments and clarify rotation schedule behavior. --- ...onal_secret_with_cloud_sql_credentials.php | 12 ++------ secretmanager/src/create_secret_with_type.php | 17 ++++------- ...nable_regional_secret_managed_rotation.php | 30 +++++++------------ .../src/get_regional_secret_type.php | 4 +-- secretmanager/src/get_secret_type.php | 4 +-- secretmanager/src/rotate_regional_secret.php | 15 ++++------ ..._secret_with_managed_rotation_schedule.php | 30 +++++++------------ .../test/regionalsecretmanagerTest.php | 21 ++----------- 8 files changed, 40 insertions(+), 93 deletions(-) diff --git a/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php b/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php index 9076afc29f..8701eacbae 100644 --- a/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php +++ b/secretmanager/src/create_regional_secret_with_cloud_sql_credentials.php @@ -32,14 +32,10 @@ use Google\Cloud\SecretManager\V1\Client\SecretManagerServiceClient; /** - * Create a new secret with the Cloud SQL DB credentials secret type. This - * type is required to enable Secret Manager's automatic rotation of Cloud - * SQL passwords. It can only be set when the secret is created, and the - * secret's location must match the region of the target Cloud SQL instance. + * Creates a new regional secret with type CLOUD_SQL_DB_CREDENTIALS. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') - * @param string $locationId Location of the secret; must match the Cloud SQL - * instance's region (e.g. 'us-central1') + * @param string $locationId Your secret Location (e.g. 'us-central1') * @param string $secretId Your secret ID (e.g. 'my-secret') */ function create_regional_secret_with_cloud_sql_credentials(string $projectId, string $locationId, string $secretId): void @@ -64,10 +60,8 @@ function create_regional_secret_with_cloud_sql_credentials(string $projectId, st printf('Created secret: %s%s', $newSecret->getName(), PHP_EOL); - // This built-in identity is what you grant Cloud SQL IAM permissions to, - // so that Secret Manager can rotate the database password on its behalf. printf( - 'Grant this identity Cloud SQL IAM permissions to enable rotation: %s%s', + 'Grant the Cloud SQL User rotate IAM permissions to enable managed rotation to: %s%s', $newSecret->getPolicyMember()->getIamPolicyUidPrincipal(), PHP_EOL ); diff --git a/secretmanager/src/create_secret_with_type.php b/secretmanager/src/create_secret_with_type.php index 8253ee8471..56e0a53502 100644 --- a/secretmanager/src/create_secret_with_type.php +++ b/secretmanager/src/create_secret_with_type.php @@ -35,17 +35,12 @@ use Google\Cloud\SecretManager\V1\Client\SecretManagerServiceClient; /** - * Create a new secret with the given secret type restriction (e.g. - * ACCESS_KEY, CERTIFICATE, OTHER_DB_CREDENTIALS, or OTHER -- use - * CLOUD_SQL_DB_CREDENTIALS only for a regional secret that will go through - * enable_regional_secret_managed_rotation.php). Unlike - * CLOUD_SQL_DB_CREDENTIALS, these other secret types are plain metadata - * tags: they don't require any additional credentials payload at creation - * time. + * Creates a new secret with the given secret type. + * Note: CLOUD_SQL_DB_CREDENTIALS is only supported in the regional secret. * - * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') - * @param string $secretId Your secret ID (e.g. 'my-secret') - * @param string $secretType Secret type restriction to apply (e.g. 'ACCESS_KEY', 'CERTIFICATE', 'OTHER_DB_CREDENTIALS', 'OTHER') + * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') + * @param string $secretId Your secret ID (e.g. 'my-secret') + * @param string $secretType Your secret type (e.g. 'OTHER') */ function create_secret_with_type(string $projectId, string $secretId, string $secretType): void { @@ -65,7 +60,7 @@ function create_secret_with_type(string $projectId, string $secretId, string $se // Build the request. $request = CreateSecretRequest::build($parent, $secretId, $secret); - // Create the secret, with the given secret type restriction. + // Create the secret. $newSecret = $client->createSecret($request); // Print the new secret name. diff --git a/secretmanager/src/enable_regional_secret_managed_rotation.php b/secretmanager/src/enable_regional_secret_managed_rotation.php index 00491616d2..f366f88d13 100644 --- a/secretmanager/src/enable_regional_secret_managed_rotation.php +++ b/secretmanager/src/enable_regional_secret_managed_rotation.php @@ -31,26 +31,18 @@ use Google\Cloud\SecretManager\V1\Client\SecretManagerServiceClient; /** - * Enable managed rotation for a Cloud SQL DB credentials secret. This links - * the secret to a Cloud SQL instance and database user, and can only be - * called once per secret. It adds the secret's first version and sets the - * matching password on the Cloud SQL user, taking the place of a manually - * added secret version, which this secret type doesn't support. Afterwards, - * use rotate_regional_secret.php to trigger further rotations. - * - * $instanceId is the bare Cloud SQL instance ID (e.g. "my-instance") -- not a - * connection name. Neither the project nor the region should be included: - * passing "PROJECT_ID:INSTANCE_ID" (as gcloud's own - * `enable-managed-rotation --help` examples misleadingly show) or the full - * "PROJECT_ID:LOCATION_ID:INSTANCE_ID" connection name both fail -- the - * service already knows the project from the secret's own path, and prepends - * it internally, so a qualified value ends up double-prefixed. + * Enables managed rotation of a CLOUD_SQL_DB_CREDENTIALS typed secret. + * It validates and enables the rotation, adding a version and sets the + * passed password (optional). + * Note: AddSecretVersion is disabled on the CLOUD_SQL_DB_CREDENTIALS + * currently and for any necessary manual rotations please trigger + * rotate_secret. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') - * @param string $locationId Location of the secret (e.g. 'us-central1') - * @param string $secretId ID of the Cloud SQL DB credentials secret to enable rotation on - * @param string $instanceId Bare ID of the Cloud SQL instance (no project or region prefix) - * @param string $username Username of the Cloud SQL database user + * @param string $locationId Your secret Location (e.g. 'us-central1') + * @param string $secretId Your secret ID (e.g. 'my-secret') + * @param string $instanceId Your Cloud SQL instance ID + * @param string $username Your Cloud SQL database username */ function enable_regional_secret_managed_rotation(string $projectId, string $locationId, string $secretId, string $instanceId, string $username): void { @@ -66,8 +58,6 @@ function enable_regional_secret_managed_rotation(string $projectId, string $loca $credentials = new CloudSQLSingleUserCredentials([ 'instance_id' => $instanceId, 'username' => $username, - // Leaving password unset lets Secret Manager generate a secure - // password itself. ]); $request = (new EnableManagedRotationRequest()) diff --git a/secretmanager/src/get_regional_secret_type.php b/secretmanager/src/get_regional_secret_type.php index 8f30a5a314..2c71167fc4 100644 --- a/secretmanager/src/get_regional_secret_type.php +++ b/secretmanager/src/get_regional_secret_type.php @@ -32,9 +32,7 @@ use Google\Cloud\SecretManager\V1\Secret\SecretType; /** - * Get and print the secret type (e.g. CLOUD_SQL_DB_CREDENTIALS, ACCESS_KEY, - * CERTIFICATE, OTHER_DB_CREDENTIALS, OTHER, or SECRET_TYPE_UNSPECIFIED for a - * secret with no type restriction) of the given regional secret. + * Gets the secret type of the given regional secret. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') * @param string $locationId Location of the secret (e.g. 'us-central1') diff --git a/secretmanager/src/get_secret_type.php b/secretmanager/src/get_secret_type.php index ba5d00b9ea..5e8d29701a 100644 --- a/secretmanager/src/get_secret_type.php +++ b/secretmanager/src/get_secret_type.php @@ -32,9 +32,7 @@ use Google\Cloud\SecretManager\V1\Secret\SecretType; /** - * Get and print the secret type (e.g. CLOUD_SQL_DB_CREDENTIALS, ACCESS_KEY, - * CERTIFICATE, OTHER_DB_CREDENTIALS, OTHER, or SECRET_TYPE_UNSPECIFIED for a - * secret with no type restriction) of the given secret. + * Gets the secret type of the given secret. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') * @param string $secretId Your secret ID (e.g. 'my-secret') diff --git a/secretmanager/src/rotate_regional_secret.php b/secretmanager/src/rotate_regional_secret.php index 9f251a9cc2..ab5345e778 100644 --- a/secretmanager/src/rotate_regional_secret.php +++ b/secretmanager/src/rotate_regional_secret.php @@ -30,15 +30,12 @@ use Google\Cloud\SecretManager\V1\Client\SecretManagerServiceClient; /** - * Trigger a managed rotation for a Cloud SQL DB credentials secret. Managed - * rotation must already be enabled on the secret (see - * enable_regional_secret_managed_rotation.php). Each call generates a new - * password, updates the Cloud SQL user, and adds the result as a new secret - * version. + * Triggers an adhoc rotation for the managed CLOUD_SQL_DB_CREDENTIALS typed + * secret. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') - * @param string $locationId Location of the secret (e.g. 'us-central1') - * @param string $secretId ID of the Cloud SQL DB credentials secret to rotate + * @param string $locationId Your secret Location (e.g. 'us-central1') + * @param string $secretId Your secret ID (e.g. 'my-secret') */ function rotate_regional_secret(string $projectId, string $locationId, string $secretId): void { @@ -48,9 +45,7 @@ function rotate_regional_secret(string $projectId, string $locationId, string $s // Create the Secret Manager client. $client = new SecretManagerServiceClient($options); - // Build the resource name of the secret. Note that although the field is - // named "parent", its value is the full secret resource name, not a - // collection parent. + // Build the resource name of the secret. $parent = $client->projectLocationSecretName($projectId, $locationId, $secretId); $request = (new RotateSecretRequest()) diff --git a/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php b/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php index 4f34858297..feeea5c379 100644 --- a/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php +++ b/secretmanager/src/update_regional_secret_with_managed_rotation_schedule.php @@ -35,22 +35,12 @@ use Google\Protobuf\FieldMask; /** - * Reconfigure the recurring rotation schedule on a secret that already has - * Cloud SQL managed rotation enabled (see - * enable_regional_secret_managed_rotation.php). This only applies to - * regional secrets of the CLOUD_SQL_DB_CREDENTIALS type -- calling it on any - * other secret type, or before managed rotation has been enabled, fails. - * - * $rotationPeriodSeconds is the interval between rotations, in whole - * seconds. The service requires it to be at least 3600 (1 hour), and the - * derived next rotation time (now + $rotationPeriodSeconds) must be at least - * 300 seconds (5 minutes) in the future -- both are enforced by the API, not - * checked client-side here. + * Updates the rotation schedule of a CLOUD_SQL_DB_CREDENTIALS typed secret. * * @param string $projectId Your Google Cloud Project ID (e.g. 'my-project') - * @param string $locationId Location of the secret (e.g. 'us-central1') - * @param string $secretId ID of the Cloud SQL DB credentials secret to reconfigure - * @param int $rotationPeriodSeconds Seconds between rotations; must be at least 3600 (1 hour) + * @param string $locationId Your secret Location (e.g. 'us-central1') + * @param string $secretId Your secret ID (e.g. 'my-secret') + * @param int $rotationPeriodSeconds Rotation period in seconds (e.g. 3600) */ function update_regional_secret_with_managed_rotation_schedule(string $projectId, string $locationId, string $secretId, int $rotationPeriodSeconds): void { @@ -63,6 +53,11 @@ function update_regional_secret_with_managed_rotation_schedule(string $projectId // Build the resource name of the secret. $name = $client->projectLocationSecretName($projectId, $locationId, $secretId); + // The rotation schedule of a CLOUD_SQL_DB_CREDENTIALS secret can be set + // before or after enabling managed rotation; EnableManagedRotation does not + // need to be called first. Other secret types also support a rotation + // schedule, but only when Pub/Sub topics are configured. Pub/Sub topics are + // not required for CLOUD_SQL_DB_CREDENTIALS. // next_rotation_time and rotation_period must be set together. $nextRotationTimeSeconds = time() + $rotationPeriodSeconds; @@ -76,11 +71,8 @@ function update_regional_secret_with_managed_rotation_schedule(string $projectId 'rotation' => $rotation, ]); - // Mask only the two subfields being set here, not the whole "rotation" - // submessage -- that would also include managed_rotation_status, which - // is output-only and rejects a whole-submessage replace with "immutable - // and cannot be updated" (confirmed empirically against a live - // project). + // Mask only the rotation subfields being set, not the whole "rotation" + // submessage. $fieldMask = new FieldMask(); $fieldMask->setPaths(['rotation.next_rotation_time', 'rotation.rotation_period']); diff --git a/secretmanager/test/regionalsecretmanagerTest.php b/secretmanager/test/regionalsecretmanagerTest.php index 01071a6001..7790d67abf 100644 --- a/secretmanager/test/regionalsecretmanagerTest.php +++ b/secretmanager/test/regionalsecretmanagerTest.php @@ -49,11 +49,7 @@ class regionalsecretmanagerTest extends TestCase { use TestTrait; - // Role granted to a Cloud SQL DB credentials secret's built-in identity - // so that managed rotation can update the Cloud SQL user's password. - // This grant is per-secret (the member is the secret's own generated - // principal), so it has to be made fresh for every secret the Cloud SQL - // managed-rotation tests create. + // Role granted to the secret's identity to enable managed rotation. private const CLOUD_SQL_ROLE = 'roles/cloudsql.admin'; private static $client; @@ -148,12 +144,6 @@ public static function setUpBeforeClass(): void self::$testRotationTopic = $envTopic; } - // CLOUD_SQL_INSTANCE is the bare Cloud SQL instance ID (no project - // or region prefix). The instance must be in self::$locationId's - // region, and CLOUD_SQL_USER must already exist as a database user - // on it. Standing up a real Cloud SQL instance per test run is - // expensive, so it's supplied as a pre-provisioned fixture via env - // vars rather than created here. $cloudSqlInstance = getenv('CLOUD_SQL_INSTANCE'); $cloudSqlUser = getenv('CLOUD_SQL_USER'); if ($cloudSqlInstance === false || $cloudSqlInstance === '' || $cloudSqlUser === false || $cloudSqlUser === '') { @@ -254,11 +244,7 @@ private static function createCloudSqlCredentialsSecret(): Secret } /** - * Grants self::CLOUD_SQL_ROLE to $member on the project. SetIamPolicy - * replaces the whole policy, so this reads the current policy, adds the - * member to the existing (or a new) binding for the role, and writes it - * back -- retrying the whole read-modify-write if another writer raced - * us (an ABORTED status from an etag mismatch). + * Grants self::CLOUD_SQL_ROLE to $member on the project. */ private static function grantCloudSqlRole(string $member): void { @@ -292,8 +278,7 @@ private static function grantCloudSqlRole(string $member): void self::$projectsClient->setIamPolicy( (new SetIamPolicyRequest())->setResource($resource)->setPolicy($policy) ); - // IAM grants are eventually consistent; give it a moment - // before a caller tries to use it for managed rotation. + // Wait for the IAM grant to propagate. sleep(10); return; } catch (GaxApiException $e) {