-
Notifications
You must be signed in to change notification settings - Fork 4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(rds): customizing secret results in unusable password and lost attachment #11237
Conversation
…tachment When the `excludeCharacters` prop is updated the secret is correctly regenerated but the database is not updated because the `MasterUserPassword` prop does not change. Moreover the connection fields created by the attachment are lost because the secret is regenerated but the `AWS::SecretsManager::SecretAttachment` doesn't "rerun". Introduce a new `fromFixedUsername()` static method in `Credentials`. When used the `MasterUsername` prop of the database references the username as a string instead of a dynamic reference to the username field of the secret. Moreover the logical id of the secret is a hash of its customization options. This way the secret gets replaced when it's customized, the database gets updated and the attachement reruns. This without updating the `MasterUsername` prop, avoiding a replacement of the database. Closes aws#11040
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your efforts @jogold ! Some initial comments I have.
* Creates Credentials for the given username, and optional password and key. | ||
* If no password is provided, one will be generated and stored in SecretsManager. | ||
*/ | ||
public static fromFixedUsername(username: string, options: CredentialsFromUsernameOptions = {}): Credentials { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also have a mechanism for making the username optional, by defaulting to either the engine-specific default, or admin
if that wasn't provided... Should we make username
here optional too? Maybe through something like
export interface CredentialsFromFixedUsernameOptions extends CredentialsFromUsernameOptions {
readonly username?: string;
}
export abstract class Credentials {
public static fromFixedUsername(options: CredentialsFromFixedUsernameOptions = {}): Credentials {
// ...
}
}
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default is currently handled in the instance/cluster code when credentials
is not specified. To avoid a breaking change it still falls back to fromUsername()
. Do you want to move the default handling in here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder whether it makes sense for customers to do something like this:
new rds.DatabaseCluster(this, 'Cluster', {
// ...
credentials: rds.Credentials.fromFixedUsername({ excludeCharacters: '!&*^#@()' }),
});
"I don't care about the username - just use the default, but I want to change something about the other properties"
Does something like this make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since Credentials
has a username
property that cannot be undefined, I think this change means that we need to make Credentials
"engine aware" to find the right default? What do you suggest?
masterUsername: credentials.username, | ||
masterUsername: props.credentials?.usernameAsString | ||
? props.credentials.username | ||
: credentials.username, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code reads kind of weird, I have to say. I'm wondering whether we're making Credentials
too low-level. It seems like the Instance
class shouldn't have to figure out all of these details... it should be able to say masterUsername: credentials.username
, and the correct value should be returned from the Credentials
class itself...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, will see how to improve this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem I have here is that the code currently calls fromSecret(new DatabaseSecret(...))
when the user specifies fromUsername()
without password.
fromSecret()
expects a secretsmanager.Secret
so it cannot know when to render the username as a string. To do that I'll have to add an additional username
argument to fromSecret()
and specify it based on the new usernameAsString
. This means that it's still the instance responsibility? Any suggestions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about creating a new, module-private factory for Credentials
that we can use here? Like _fromSecretUsernameRendered(secret: ISecret, usernameAsString: boolean): Credentials
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll (also) need to pass the username
, right?
Because you cannot extract a field out of a ISecret
without creating a dynamic reference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure 🙂
Then maybe it's enough to have a username?: string
as the second argument, and if it's undefined
, you use the reference to the Secret? I'm sure you'll figure out the details 🙂.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Chosen to add a optional username
to fromSecret()
which allows users using fromSecret()
to also have the behavior of the username referenced as a string.
The same kind of logic is now a few lines above.
masterUsername: credentials.username, | ||
masterUsername: props.credentials?.usernameAsString | ||
? props.credentials.username | ||
: credentials.username, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment here as in Instance
- there's something off about the code here, it's too low-level.
@@ -534,7 +534,7 @@ | |||
] | |||
} | |||
}, | |||
"InstanceSecret478E0A47": { | |||
"653e16d6b669bc291aa4827a4721a98c": { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add either id
, or even uniqueId
, to the generated hash - right now, these are too unreadable as just random hashes.
@@ -301,7 +301,7 @@ export = { | |||
expect(stack).to(haveResourceLike('AWS::RDS::DBInstance', { | |||
MasterUsername: ABSENT, | |||
MasterUserPassword: { | |||
'Fn::Join': ['', ['{{resolve:secretsmanager:', { Ref: 'InstanceSecret478E0A47' }, ':SecretString:password::}}']], | |||
'Fn::Join': ['', ['{{resolve:secretsmanager:', { Ref: '29ae4bfb9ee908ebe1611900a1e2469e' }, ':SecretString:password::}}']], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this change needed? Nothing changed in the body of this test, so this is very worrying that this needs updating... This would mean a database replacement for a customer when they upgrade...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a DatabaseFromSnaphot()
so its secret logical id is now a hash. As the test shows MasterUsername
is expected to be ABSENT
so there won't be any replacement.
Co-authored-by: Adam Ruka <[email protected]>
Pull request has been modified.
@skinny85 I finally decided to introduce two new static methods to replace Since making I also moved common code to a new util. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice. We're almost there. A couple of minor notes.
const cfnSecret1 = db1Secret.node.defaultChild as cdk.CfnResource; | ||
const db2Secret = db2.node.tryFindChild('Secret') as rds.DatabaseSecret; | ||
const cfnSecret2 = db2Secret.node.defaultChild as cdk.CfnResource; | ||
test.notEqual(cfnSecret1.logicalId, cfnSecret2.logicalId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logicalIds
are tokens, so you need to resolve them first before you do the comparison (try it be attempting to make this test fail by changing the hashing algorithm, it shouldn't work - this assertion should always pass).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
of course! moved these kind of assertions to test.database-secret.ts
where it actually belongs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're almost there! I have 2 minor comments/questions.
// Use here the options that influence the password generation. | ||
// If at some point we add other password customization options | ||
// they sould be added here below (e.g. `passwordLength`). | ||
excludeCharacters: props.excludeCharacters, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So excludeCharacters
is actually optional. Which means this object will have undefined
there as the default. Is that a mistake? Should the default value be filled here? (See line 66 above)
// they sould be added here below (e.g. `passwordLength`). | ||
excludeCharacters: props.excludeCharacters, | ||
})); | ||
const logicalId = `${this.node.id.replace(/[^A-Za-z0-9]/g, '')}${hash.digest('hex')}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like id
is too short here. You'll wind up with Secret<some-hash>
everywhere, which is too ambiguous. Can you use uniqueId
here instead? (No need for the replace
part then).
* | ||
* @default false | ||
*/ | ||
readonly overrideLogicalId?: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder whether a name like replaceOnPasswordChanges
might be better here?
This module is now stable. We can't have breaking changes to it. Please revert the changes to |
Got it. But do you really consider this as a breaking change? If the secret is consumed programmatically or if rotation is in place it will have no impact... your call. The password will change but the new one is available in Secrets Manager, you never loose access. |
* | ||
* @default false | ||
*/ | ||
readonly replaceOnPasswordChanges?: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be more appropriately named replaceOnPasswordCriteriaChanges?
or something like that? I'm afraid the name and documentation as-is may mislead people to think the secret may be replaced when rotation is configured.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm ok with this name, it's indeed better. @skinny85 do you validate this name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes 🙂
Co-authored-by: Nick Lynch <[email protected]>
If you want to avoid this we can expose the |
I don't love that. How about new static factory methods in |
Any suggestion for the name? I would expect How about |
That's a good point about consistency between I like |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks for taking care of this @jogold !
Pull request has been modified.
@skinny85 can you reapprove here? Had to merge from master because the build was stuck... |
AWS CodeBuild CI Report
Powered by github-codebuild-logs, available on the AWS Serverless Application Repository |
Thank you for contributing! Your pull request will be updated from master and then merged automatically (do not update manually, and be sure to allow changes to be pushed to your fork). |
When the
excludeCharacters
prop is updated the secret is correctlyregenerated but the database is not updated because the
MasterUserPassword
prop does not change. Moreover the connectionfields created by the attachment are lost because the
AWS::SecretsManager::SecretAttachment
doesn't "rerun" (it referencesthe same secret).
Introduce
fromGeneratedSecret()
andfromPassword()
static methodsin
Credentials
. When used theMasterUsername
prop of the databasereferences the username as a string instead of a dynamic reference to
the username field of the secret. Moreover the logical id of the secret
is a hash of its customization options. This way the secret gets replaced
when it's customized, the database gets updated and the attachement reruns.
This without updating the
MasterUsername
prop, avoiding a replacementof the database.
For instances that were created from a snapshot the
MasterUsername
propis not specified so there's no replacement risk. Add a new static method
fromGeneratedSecret()
inSnapshotCredentials
to replace the secretwhen its customization options change (also hash in logical id).
Deprecate
Credentials.fromUsername()
but keep it as default to avoida breaking change that would replace existing databases that were not
created from a snapshot.
Deprecate
SnapshotCredentials.fromGeneratedPassword()
which doesn'treplace the secret when customization options are changed.
Closes #11040
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license