Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions rds-postgres-db/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# rds-postgres-db

A nullplatform dependency service that provisions and manages a **PostgreSQL database** within an existing RDS instance managed by [`rds-postgres-server`](../rds-postgres-server). It handles database creation, app-level user management, and per-link fine-grained access control — without creating any AWS infrastructure itself.
A nullplatform dependency service that provisions and manages a **PostgreSQL database** within an existing RDS instance managed by [`rds-postgres-server`](../rds-postgres-server). It handles database creation, app-level user management, and per-link fine-grained access control. Unlike `rds-postgres-server`, it does not provision any RDS/EC2 infrastructure — the one AWS resource it does create is the Secrets Manager secret holding the app-level PostgreSQL credentials.

## What It Does

- Auto-discovers a compatible `rds-postgres-server` in the same nullplatform namespace using dimension matching
- Creates a dedicated PostgreSQL database and application-level user within that server
- Manages per-link permissions: each link to an application gets its own PostgreSQL user with scoped grants (`read`, `write`, or `read-write`)
- Stores connection credentials in nullplatform service and link attributes for injection into applications
- Stores connection credentials in nullplatform service and link attributes for injection into applications, and mirrors the app-level credentials into a Secrets Manager secret (same convention as the `rds-postgres-server` master secret)

## Architecture

Expand All @@ -25,7 +25,7 @@ nullplatform Application
postgresql_grant.*
```

Unlike `rds-postgres-server`, this service creates no AWS resources. It only manages PostgreSQL-level objects (databases, roles, grants) on the shared RDS instance.
Unlike `rds-postgres-server`, this service creates no RDS/EC2 infrastructure — it only manages PostgreSQL-level objects (databases, roles, grants) on the shared RDS instance, plus one Secrets Manager secret for the app-level credentials it generates.

## Nullplatform Integration

Expand All @@ -43,7 +43,8 @@ Unlike `rds-postgres-server`, this service creates no AWS resources. It only man
| `username` | exported | Service-level PostgreSQL user |
| `password` | hidden | Service-level PostgreSQL password |
| `database_name` | exported | PostgreSQL database name |
| `master_secret_arn` | internal | Secrets Manager ARN (used for link operations) |
| `master_secret_arn` | internal | Secrets Manager ARN for the `rds-postgres-server` master credentials (used for link operations) |
| `app_secret_arn` | internal | Secrets Manager ARN for this service's own app-level credentials |

### Link Attributes (written per link)

Expand All @@ -52,6 +53,7 @@ Unlike `rds-postgres-server`, this service creates no AWS resources. It only man
| `username` | Per-link PostgreSQL user (`np_<first 16 chars of link_id>`) |
| `password` | Per-link PostgreSQL password |
| `database_name` | Database name (same as service-level database) |
| `app_secret_arn` | Secrets Manager ARN for the service-level app credentials (mirrored from the service attribute) |

## Link Parameters

Expand All @@ -73,9 +75,9 @@ All access levels include `DEFAULT PRIVILEGES` so future tables created after th

| Workflow | Trigger | What It Does |
|---|---|---|
| `create` | Service created | Auto-discovers server, creates database + app user, writes service attributes |
| `create` | Service created | Auto-discovers server, creates database + app user, stores app credentials in Secrets Manager, writes service attributes |
| `update` | Service updated | No-op (no configurable parameters) |
| `delete` | Service deleted | Reassigns owned objects to master, destroys app user; **database is preserved** |
| `delete` | Service deleted | Reassigns owned objects to master, destroys app user and its Secrets Manager secret; **database is preserved** |
| `link` | Application linked | Creates per-link PostgreSQL user with scoped grants |
| `unlink` | Application unlinked | Revokes grants only; user and database are **preserved** |

Expand All @@ -97,6 +99,16 @@ username = "np_<first 16 hex chars of link_id>"

This ensures usernames are stable and reproducible even if the service is recreated.

## Infrastructure Resources Created

| Resource | Description |
|---|---|
| `postgresql_database` | The application database (preserved on delete) |
| `postgresql_role` | The service-level app user |
| `aws_secretsmanager_secret` | Stores the app-level credentials (`nullplatform/rds/<service_id>/app`); destroyed alongside the app user on service delete |

No RDS, EC2, or VPC resources are created — those belong to the auto-discovered `rds-postgres-server`.

## Requirements

### nullplatform Prerequisites
Expand All @@ -113,9 +125,9 @@ This ensures usernames are stable and reproducible even if the service is recrea

### AWS IAM Permissions

This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent needs:
This service requires fewer AWS permissions than `rds-postgres-server` (no RDS/EC2), but it does manage its own Secrets Manager secret. The agent needs:

- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes
- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes; plus `CreateSecret`, `PutSecretValue`, `UpdateSecret`, `DeleteSecret`, `DescribeSecret`, `TagResource`, `UntagResource`, `GetResourcePolicy`, `ListSecretVersionIds` — to create, update, and delete the app-level credentials secret this service owns
- **S3**: full lifecycle on the `np-service-<SERVICE_ID>` bucket — `build_context` creates and manages its own per-service Terraform state bucket, same as `rds-postgres-server`

No RDS or EC2 permissions are needed.
Expand All @@ -131,12 +143,15 @@ applying it. Granting the agent itself permission to assume this role is
handled separately, outside this module.

This role and its policy are shared per **cluster**, not per linked
`rds-postgres-server` instance — the `GetSecretValue` grant is scoped to the
`nullplatform/rds/*` secret-name prefix (every master secret in the cluster
following that naming convention), not to the single secret this particular
service instance's link actually uses. Anything that assumes this role can
read the master password of any `rds-postgres-server` in the cluster, not
just the linked one.
`rds-postgres-server` instance — the Secrets Manager grant is scoped to the
`nullplatform/rds/*` secret-name prefix (every master and app secret in the
cluster following that naming convention), not to the single secrets this
particular service instance actually owns. Anything that assumes this role
can read, create, update, or delete any secret under that prefix — not just
the master password of the linked server, and not just this instance's own
app secret. This is a wider blast radius than a read-only grant would be;
narrowing it to per-instance secret ARNs would require generating the policy
per service instance instead of once per cluster.

### AssumeRole Setup Guide

Expand Down
27 changes: 27 additions & 0 deletions rds-postgres-db/db_setup/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,30 @@
password = random_password.user.result
login = true
}

# ---------------------------------------------------------------------------
# App credentials, mirrored into Secrets Manager (same convention as the
# rds-postgres-server master secret) so they're not only reachable via
# nullplatform service/link attributes.
# ---------------------------------------------------------------------------

resource "aws_secretsmanager_secret" "app" {
name = "nullplatform/rds/${var.service_id}/app"
recovery_window_in_days = 0

tags = {
"managed-by" = "nullplatform"
"service-id" = var.service_id
}
}
Comment on lines +44 to +52

resource "aws_secretsmanager_secret_version" "app" {
secret_id = aws_secretsmanager_secret.app.id
secret_string = jsonencode({
username = postgresql_role.app_user.name
password = random_password.user.result
host = var.db_host
port = var.db_port
dbname = postgresql_database.app.name
})
}
5 changes: 5 additions & 0 deletions rds-postgres-db/db_setup/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ output "database_name" {
value = postgresql_database.app.name
description = "Database name"
}

output "app_secret_arn" {
value = aws_secretsmanager_secret.app.arn
description = "ARN of the Secrets Manager secret holding the app-level credentials"
}
8 changes: 8 additions & 0 deletions rds-postgres-db/db_setup/providers.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ terraform {
source = "hashicorp/random"
version = "~> 3.0"
}
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}

provider "aws" {
region = var.region
}

provider "postgresql" {
host = var.db_host
port = var.db_port
Expand Down
6 changes: 6 additions & 0 deletions rds-postgres-db/db_setup/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ variable "service_id" {
description = "Nullplatform service ID (used as keeper to stabilize password across re-applies)"
}

variable "region" {
type = string
default = "us-east-1"
description = "AWS region (used to create the app credentials secret in Secrets Manager)"
}

variable "db_host" {
type = string
description = "RDS endpoint hostname"
Expand Down
1 change: 1 addition & 0 deletions rds-postgres-db/deployment/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ resource "random_password" "master" {
resource "aws_secretsmanager_secret" "master" {
name = "nullplatform/rds/${var.instance_name}/master"
recovery_window_in_days = 0
kms_key_id = var.secret_kms_key_id

tags = {
"managed-by" = "nullplatform"
Expand Down
6 changes: 6 additions & 0 deletions rds-postgres-db/deployment/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ variable "maintenance_window" {
default = "Mon:04:00-Mon:05:00"
description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)"
}

variable "secret_kms_key_id" {
type = string
default = null
description = "KMS key ID or ARN used to encrypt the RDS master secret in Secrets Manager. If not set, AWS encrypts it with the default aws/secretsmanager managed key."
}
2 changes: 1 addition & 1 deletion rds-postgres-db/scripts/aws/build_db_setup_context
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,4 @@ export TOFU_IMPORT_DB_NAME="$DB_NAME_VAL"

export TOFU_INIT_VARIABLES="-backend-config=bucket=${TFSTATE_BUCKET} -backend-config=key=db_setup.tfstate -backend-config=region=${REGION}"

export TOFU_VARIABLES="-var=service_id=${SERVICE_ID} -var=db_host=${DB_HOST} -var=db_port=${DB_PORT} -var=db_name=${DB_NAME_VAL} -var=db_username=${DB_USERNAME_VAL} -var=master_username=${MASTER_USER} -var=master_secret_arn=${MASTER_SECRET_ARN}"
export TOFU_VARIABLES="-var=service_id=${SERVICE_ID} -var=region=${REGION} -var=db_host=${DB_HOST} -var=db_port=${DB_PORT} -var=db_name=${DB_NAME_VAL} -var=db_username=${DB_USERNAME_VAL} -var=master_username=${MASTER_USER} -var=master_secret_arn=${MASTER_SECRET_ARN}"
5 changes: 4 additions & 1 deletion rds-postgres-db/scripts/aws/write_link_outputs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ USERNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.username /
PASSWORD=$(echo "$SERVICE_JSON" | jq -r '.attributes.password // ""')
DATABASE_NAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.database_name // ""')
MASTER_SECRET_ARN=$(echo "$SERVICE_JSON"| jq -r '.attributes.master_secret_arn // ""')
APP_SECRET_ARN=$(echo "$SERVICE_JSON" | jq -r '.attributes.app_secret_arn // ""')

if [ -z "$USERNAME" ]; then
echo "WARNING: No username found in service attributes. Skipping link attribute update."
Expand All @@ -38,13 +39,15 @@ ATTRS=$(jq -n \
--arg password "$PASSWORD" \
--arg database_name "$DATABASE_NAME" \
--arg master_secret_arn "$MASTER_SECRET_ARN" \
--arg app_secret_arn "$APP_SECRET_ARN" \
'{
hostname: $hostname,
port: ($port | tonumber),
username: $username,
password: $password,
database_name: $database_name,
master_secret_arn: $master_secret_arn
master_secret_arn: $master_secret_arn,
app_secret_arn: $app_secret_arn
}')

echo "Updating link ${LINK_ID_VAL} attributes:"
Expand Down
16 changes: 10 additions & 6 deletions rds-postgres-db/scripts/aws/write_service_outputs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ USERNAME=$(echo "$ALL_OUTPUTS" | jq -r '.db_username.value // ""')
PASSWORD=$(echo "$ALL_OUTPUTS" | jq -r '.db_password.value // ""')
DATABASE_NAME=$(echo "$ALL_OUTPUTS" | jq -r '.database_name.value // ""')
MASTER_SECRET_ARN=$(echo "$ALL_OUTPUTS"| jq -r '.master_secret_arn.value // ""')
APP_SECRET_ARN=$(echo "$ALL_OUTPUTS" | jq -r '.app_secret_arn.value // ""')

if [ -z "$HOSTNAME" ]; then
echo "WARNING: No hostname output found. Skipping service attribute update."
Expand All @@ -36,21 +37,24 @@ ATTRS=$(jq -n \
--arg password "$PASSWORD" \
--arg database_name "$DATABASE_NAME" \
--arg master_secret_arn "$MASTER_SECRET_ARN" \
--arg app_secret_arn "$APP_SECRET_ARN" \
'{
hostname: $hostname,
port: ($port | tonumber),
username: $username,
password: $password,
database_name: $database_name,
master_secret_arn: $master_secret_arn
master_secret_arn: $master_secret_arn,
app_secret_arn: $app_secret_arn
}')

echo "Updating service ${SERVICE_ID} attributes:"
echo " hostname: $HOSTNAME"
echo " port: $PORT"
echo " username: $USERNAME"
echo " database_name: $DATABASE_NAME"
echo " password: ****"
echo " hostname: $HOSTNAME"
echo " port: $PORT"
echo " username: $USERNAME"
echo " database_name: $DATABASE_NAME"
echo " app_secret_arn: $APP_SECRET_ARN"
echo " password: ****"

np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}"
echo "Service attributes updated successfully."
7 changes: 7 additions & 0 deletions rds-postgres-db/specs/links/connect.json.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
"visibleOn": [],
"editableOn": [],
"description": "ARN of the Secrets Manager secret for master credentials (internal use)"
},
"app_secret_arn": {
"type": "string",
"export": false,
"visibleOn": [],
"editableOn": [],
"description": "ARN of the Secrets Manager secret holding the app-level credentials (internal use)"
}
}
},
Expand Down
20 changes: 16 additions & 4 deletions rds-postgres-db/specs/requirements/aws/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,32 @@ resource "aws_iam_role" "nullplatform_rds_postgres_db" {
}

################################################################################
# Secrets Manager IAM policy — read-only access to the RDS master password
# Secrets Manager IAM policy — read the RDS master password, manage the
# app-level credentials secret this service creates in db_setup/
################################################################################

resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" {
count = local.iam_create ? 1 : 0

name = "${local.policies_name_prefix}-rds-postgres-db-secretsmanager-policy"
description = "Policy for reading the RDS master password from Secrets Manager"
description = "Policy for reading the RDS master password and managing the app-level credentials secret in Secrets Manager"

policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Effect = "Allow"
Action = [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:GetResourcePolicy",
"secretsmanager:ListSecretVersionIds"
]
Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*"
}]
})
Expand Down
2 changes: 1 addition & 1 deletion rds-postgres-db/specs/requirements/aws/output.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ output "permissions_role_id" {
}

output "secretsmanager_policy_arn" {
description = "ARN of the Secrets Manager read policy"
description = "ARN of the Secrets Manager policy (read master secret, manage app credentials secret)"
value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : ""
}

Expand Down
7 changes: 7 additions & 0 deletions rds-postgres-db/specs/service-spec.json.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"editableOn": [],
"description": "ARN of the Secrets Manager secret for master credentials (internal use)",
"order": 6
},
"app_secret_arn": {
"type": "string",
"visibleOn": [],
"editableOn": [],
"description": "ARN of the Secrets Manager secret holding the app-level credentials (internal use)",
"order": 7
}
}
},
Expand Down
7 changes: 4 additions & 3 deletions rds-postgres-db/workflows/aws/delete.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ steps:
file: $SERVICE_PATH/scripts/aws/do_tofu
configuration:
TOFU_ACTION: destroy
# Destroy only the user. The database is preserved with master as owner
# so data remains available for potential future use.
TOFU_TARGETS: "postgresql_role.app_user,random_password.user"
# Destroy only the user and its Secrets Manager secret. The database is
# preserved with master as owner so data remains available for
# potential future use.
TOFU_TARGETS: "postgresql_role.app_user,random_password.user,aws_secretsmanager_secret_version.app,aws_secretsmanager_secret.app"

- name: cleanup tfstate bucket
type: script
Expand Down
3 changes: 3 additions & 0 deletions rds-postgres-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ Exposed in the nullplatform UI when creating or updating the service:
| `instance_class` | string | `db.t3.micro` | `db.t3.micro`, `db.t3.small`, `db.t3.medium`, `db.m5.large` | Yes |
| `allocated_storage` | number | `20` | 20–1000 (GB) | Yes |
| `postgres_version` | string | `16` | `14`, `15`, `16` | No |
| `secret_kms_key_id` | string | _(none)_ | Any KMS key ID/ARN in the account | Yes |

> `postgres_version` cannot be changed after creation because PostgreSQL major version upgrades require manual intervention and are not managed by this service.

> `secret_kms_key_id` controls which KMS key encrypts the master password secret in Secrets Manager. If left unset, AWS encrypts it with the default `aws/secretsmanager` managed key — pass a customer-managed key ARN here only if this service instance requires its own key.

## Workflows

| Workflow | Trigger | What It Does |
Expand Down
1 change: 1 addition & 0 deletions rds-postgres-server/deployment/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ resource "random_password" "master" {
resource "aws_secretsmanager_secret" "master" {
name = "nullplatform/rds/${var.instance_name}/master"
recovery_window_in_days = 0
kms_key_id = var.secret_kms_key_id

tags = {
"managed-by" = "nullplatform"
Expand Down
6 changes: 6 additions & 0 deletions rds-postgres-server/deployment/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ variable "maintenance_window" {
default = "Mon:04:00-Mon:05:00"
description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)"
}

variable "secret_kms_key_id" {
type = string
default = null
description = "KMS key ID or ARN used to encrypt the RDS master secret in Secrets Manager. If not set, AWS encrypts it with the default aws/secretsmanager managed key."
}
Loading