Skip to content

Commit fe7db0a

Browse files
Add a tool to initialize new RDS databases (#16)
1 parent 48d6992 commit fe7db0a

4 files changed

Lines changed: 370 additions & 0 deletions

File tree

cmd/rds-init/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# RDS Database Initialization Tool
2+
3+
A utility for initializing and configuring RDS PostgreSQL databases. This tool manages the `nessus_scan_user` account for security scanning and enables RDS IAM authentication for the admin user.
4+
5+
## Overview
6+
7+
This tool performs two main tasks:
8+
9+
1. **Nessus scan user management** — Creates and maintains a dedicated database user (`nessus_scan_user`) for Nessus security scans, with credentials stored in AWS Secrets Manager.
10+
11+
2. **RDS IAM authentication setup** — Grants the `rds_iam` role to the admin user, enabling IAM-based database authentication for future connections.
12+
13+
## How It Works
14+
15+
### Nessus User Setup
16+
17+
The tool follows this workflow for the `nessus_scan_user`:
18+
19+
1. Parses the provided PostgreSQL DSN to extract connection details
20+
2. Looks up the RDS instance identifier by matching the endpoint host and port
21+
3. Checks for an existing secret named `{db-identifier}_nessus` in Secrets Manager
22+
4. If no secret exists, generates a random 22-character password and creates the secret
23+
5. If a secret exists, reuses the stored password
24+
6. Creates the `nessus_scan_user` role in PostgreSQL if it doesn't exist
25+
7. Sets (or updates) the user's password to match the Secrets Manager value
26+
8. Grants `pg_read_all_settings` to the user for Nessus compliance scanning
27+
28+
### RDS IAM Setup
29+
30+
After configuring the Nessus user, the tool grants `rds_iam` to the currently authenticated user (the admin user specified in the DSN).
31+
32+
## Usage
33+
34+
```bash
35+
./rds-init <postgres-dsn>
36+
```
37+
38+
### Example
39+
40+
```bash
41+
./rds-init "postgres://admin_user:password@mydb.abc123.us-east-1.rds.amazonaws.com:5432/myappdb"
42+
```
43+
44+
## When to Run
45+
46+
- **Initial provisioning** — Run after creating a new RDS instance
47+
- **Safe to rerun on failure** — On success password auth will be disabled for the admin user. As this tool requires password auth, subsequent runs will not be possible. If the script exits unsuccessfully before disabling password auth, it is safe to execute multiple times.
48+
49+
## Prerequisites
50+
51+
- AWS credentials configured with permissions for:
52+
- `secretsmanager:GetSecretValue`
53+
- `secretsmanager:CreateSecret`
54+
- `rds:DescribeDBInstances`
55+
- Network access to the target RDS instance
56+
- A PostgreSQL admin user with privileges to create roles and grant permissions
57+
58+
## Secrets Manager Secret
59+
60+
Secret names follow the pattern: `{rds-instance-identifier}_nessus`
61+
62+
## Notes
63+
64+
This tool is safe to run multiple times (e.g., if the admin user's IAM auth mode was reset). However, after the initial run, password authentication is typically disabled for the admin user. Subsequent executions using the same password-based DSN will fail.

cmd/rds-init/main.go

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"log"
11+
"net/url"
12+
"os"
13+
"strconv"
14+
"strings"
15+
"time"
16+
17+
"github.com/jmoiron/sqlx"
18+
"github.com/lib/pq"
19+
20+
"github.com/aws/aws-sdk-go-v2/aws"
21+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
22+
"github.com/aws/aws-sdk-go-v2/service/rds"
23+
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
24+
smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
25+
)
26+
27+
const (
28+
nessusUserName = "nessus_scan_user"
29+
defaultPostgresPort = "5432"
30+
)
31+
32+
type dbConnInfo struct {
33+
Host string
34+
Port int
35+
DBName string
36+
User string
37+
RawDSN string
38+
}
39+
40+
type nessusSecret struct {
41+
Engine string `json:"engine"`
42+
Host string `json:"host"`
43+
Port int `json:"port"`
44+
Username string `json:"username"`
45+
Password string `json:"password"`
46+
DBName string `json:"dbname"`
47+
}
48+
49+
func randomHexPassword() (string, error) {
50+
b := make([]byte, 16) // 16 bytes (32 hex chars)
51+
52+
if _, err := rand.Read(b); err != nil {
53+
return "", fmt.Errorf("failed to read random bytes: %w", err)
54+
}
55+
56+
s := hex.EncodeToString(b) // lowercase hex
57+
return s[:32], nil // 32 hex chars (16 bytes)
58+
}
59+
60+
func parseDSN(dsn string) (*dbConnInfo, error) {
61+
u, err := url.Parse(dsn)
62+
if err != nil {
63+
return nil, fmt.Errorf("failed to parse DSN as URL: %w", err)
64+
}
65+
if u.Scheme != "postgres" && u.Scheme != "postgresql" {
66+
return nil, fmt.Errorf("unexpected scheme %q in DSN, expected postgres or postgresql", u.Scheme)
67+
}
68+
69+
user := ""
70+
if u.User != nil {
71+
user = u.User.Username()
72+
}
73+
74+
host := u.Hostname()
75+
76+
portStr := u.Port()
77+
if portStr == "" {
78+
portStr = defaultPostgresPort
79+
}
80+
port, err := strconv.Atoi(portStr)
81+
if err != nil {
82+
return nil, fmt.Errorf("invalid port %q: %w", portStr, err)
83+
}
84+
85+
dbname := strings.TrimPrefix(u.Path, "/")
86+
87+
// If path is empty, optionally look for ?dbname=foo
88+
if dbname == "" {
89+
if qName := u.Query().Get("dbname"); qName != "" {
90+
dbname = qName
91+
}
92+
}
93+
94+
// If still empty, fall back to username (lib/pq's default behavior)
95+
if dbname == "" && user != "" {
96+
dbname = user
97+
}
98+
99+
return &dbConnInfo{
100+
Host: host,
101+
Port: port,
102+
DBName: dbname,
103+
User: user,
104+
RawDSN: dsn,
105+
}, nil
106+
}
107+
108+
func loadAWSConfig(ctx context.Context) (aws.Config, error) {
109+
return awsconfig.LoadDefaultConfig(ctx)
110+
}
111+
112+
func getSecretIfExists(ctx context.Context, sm *secretsmanager.Client, name string) (*nessusSecret, error) {
113+
out, err := sm.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
114+
SecretId: aws.String(name),
115+
})
116+
if err != nil {
117+
var rnfe *smtypes.ResourceNotFoundException
118+
if errors.As(err, &rnfe) {
119+
return nil, nil
120+
}
121+
return nil, fmt.Errorf("GetSecretValue failed: %w", err)
122+
}
123+
124+
if out.SecretString == nil {
125+
return nil, fmt.Errorf("existing secret %q does not have SecretString", name)
126+
}
127+
128+
var sec nessusSecret
129+
if err := json.Unmarshal([]byte(*out.SecretString), &sec); err != nil {
130+
return nil, fmt.Errorf("failed to unmarshal existing secret JSON: %w", err)
131+
}
132+
return &sec, nil
133+
}
134+
135+
func createSecret(ctx context.Context, sm *secretsmanager.Client, name string, sec *nessusSecret) error {
136+
payload, err := json.Marshal(sec)
137+
if err != nil {
138+
return fmt.Errorf("failed to marshal secret JSON: %w", err)
139+
}
140+
_, err = sm.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
141+
Name: aws.String(name),
142+
SecretString: aws.String(string(payload)),
143+
})
144+
if err != nil {
145+
return fmt.Errorf("CreateSecret failed: %w", err)
146+
}
147+
return nil
148+
}
149+
150+
func findDBInstanceByEndpoint(ctx context.Context, client *rds.Client, host string, port int32) (string, error) {
151+
var marker *string
152+
153+
for {
154+
out, err := client.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{
155+
Marker: marker,
156+
})
157+
if err != nil {
158+
return "", fmt.Errorf("DescribeDBInstances failed: %w", err)
159+
}
160+
161+
for _, inst := range out.DBInstances {
162+
if inst.Endpoint == nil {
163+
continue
164+
}
165+
if aws.ToString(inst.Endpoint.Address) == host && aws.ToInt32(inst.Endpoint.Port) == port {
166+
return aws.ToString(inst.DBInstanceIdentifier), nil
167+
}
168+
}
169+
170+
if out.Marker == nil || len(out.DBInstances) == 0 {
171+
break
172+
}
173+
marker = out.Marker
174+
}
175+
176+
return "", fmt.Errorf("no RDS DB instance found with endpoint %s:%d", host, port)
177+
}
178+
179+
func main() {
180+
log.SetFlags(0)
181+
182+
if len(os.Args) != 2 {
183+
log.Fatalf("Usage: %s <postgres-dsn>", os.Args[0])
184+
}
185+
dsn := os.Args[1]
186+
187+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
188+
defer cancel()
189+
190+
connInfo, err := parseDSN(dsn)
191+
if err != nil {
192+
log.Fatalf("Error parsing DSN: %v", err)
193+
}
194+
195+
db, err := sqlx.Connect("postgres", dsn)
196+
if err != nil {
197+
log.Fatalf("Error connecting to database: %s", err)
198+
}
199+
defer db.Close()
200+
201+
awsCfg, err := loadAWSConfig(ctx)
202+
if err != nil {
203+
log.Fatalf("Failed to load AWS config: %v", err)
204+
}
205+
206+
smClient := secretsmanager.NewFromConfig(awsCfg)
207+
rdsClient := rds.NewFromConfig(awsCfg)
208+
209+
dbIdentifier, err := findDBInstanceByEndpoint(ctx, rdsClient, connInfo.Host, int32(connInfo.Port))
210+
if err != nil {
211+
log.Fatalf("Could not look up RDS DB instance for endpoint %s:%d: %v", connInfo.Host, connInfo.Port, err)
212+
}
213+
log.Printf("Discovered RDS DB instance identifier: %s", dbIdentifier)
214+
215+
secretName := dbIdentifier + "_nessus"
216+
log.Printf("Using secret name: %s", secretName)
217+
218+
// Secrets Manager is canonical for password:
219+
// - If it exists, reuse its password
220+
// - If it doesn't, generate & create it
221+
existingSecret, err := getSecretIfExists(ctx, smClient, secretName)
222+
if err != nil {
223+
log.Fatalf("Error checking existing secret: %v", err)
224+
}
225+
226+
var password string
227+
if existingSecret != nil {
228+
if existingSecret.Password == "" {
229+
log.Fatal("Malformed secret with an empty password. Please manually verify the secret and re-run.")
230+
}
231+
password = existingSecret.Password
232+
log.Printf("Reusing existing password from secret %q.", secretName)
233+
} else {
234+
password, err = randomHexPassword()
235+
if err != nil {
236+
log.Fatalf("Error generating random password: %v", err)
237+
}
238+
log.Printf("Generated new password for %s.", nessusUserName)
239+
}
240+
241+
secretBody := &nessusSecret{
242+
Engine: "postgres",
243+
Host: connInfo.Host,
244+
Port: connInfo.Port,
245+
Username: nessusUserName,
246+
Password: password,
247+
DBName: connInfo.DBName,
248+
}
249+
250+
if existingSecret == nil {
251+
if err := createSecret(ctx, smClient, secretName, secretBody); err != nil {
252+
log.Fatalf("Error creating secret %q: %v", secretName, err)
253+
}
254+
log.Printf("Created secret %q in Secrets Manager.", secretName)
255+
}
256+
257+
// 1) Ensure user exists (no password embedded)
258+
ensureUserSQL := fmt.Sprintf(`
259+
DO $do$
260+
BEGIN
261+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = %s) THEN
262+
CREATE USER %s;
263+
END IF;
264+
END
265+
$do$;
266+
`,
267+
pq.QuoteLiteral(nessusUserName),
268+
pq.QuoteIdentifier(nessusUserName),
269+
)
270+
271+
if _, err := db.ExecContext(ctx, ensureUserSQL); err != nil {
272+
log.Fatalf("Failed to ensure %s exists: %v", nessusUserName, err)
273+
}
274+
275+
// 2) Set password (ALTER ROLE does not support parameter placeholders)
276+
alterRoleSQL := fmt.Sprintf(
277+
"ALTER ROLE %s WITH PASSWORD %s",
278+
pq.QuoteIdentifier(nessusUserName),
279+
pq.QuoteLiteral(password),
280+
)
281+
282+
if _, err := db.ExecContext(ctx, alterRoleSQL); err != nil {
283+
log.Fatalf("Failed to set password for %s: %v", nessusUserName, err)
284+
}
285+
286+
// 3) Grants
287+
grantSQL := fmt.Sprintf(
288+
"GRANT pg_read_all_settings TO %s",
289+
pq.QuoteIdentifier(nessusUserName),
290+
)
291+
292+
if _, err := db.ExecContext(ctx, grantSQL); err != nil {
293+
log.Fatalf("Failed to grant pg_read_all_settings to %s: %v", nessusUserName, err)
294+
}
295+
296+
// CURRENT_USER is the user this script authenticated as on this connection.
297+
if _, err := db.ExecContext(ctx, "GRANT rds_iam TO CURRENT_USER"); err != nil {
298+
log.Fatalf("Failed to grant rds_iam to CURRENT_USER: %v", err)
299+
}
300+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.31.0
88
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
99
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16
10+
github.com/aws/aws-sdk-go-v2/service/rds v1.113.1
11+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0
1012
github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0
1113
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
1214
github.com/coreos/go-oidc/v3 v3.6.0

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEd
1818
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
1919
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
2020
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
21+
github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 h1:/vV0g/Su8rCTqT57UUYiFU/aRrPXz//fGDn1dkXblG4=
22+
github.com/aws/aws-sdk-go-v2/service/rds v1.113.1/go.mod h1:q02df+DL73LN+jDXzj86tMsI6kKf1kfv61nB684H+o8=
23+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk=
24+
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc=
2125
github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0 h1:1T8wFNEtOP4lgLC7v8Fzgbb4kFrMmnscG7kOqkbA26c=
2226
github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0/go.mod h1:CDVmu8K5JKdgdJakdZ9gC3K6OJ/+izv/kUncFeGRIj4=
2327
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=

0 commit comments

Comments
 (0)