diff --git a/.github/workflows/branch-validation.yml b/.github/workflows/branch-validation.yml
new file mode 100644
index 0000000..6d75b77
--- /dev/null
+++ b/.github/workflows/branch-validation.yml
@@ -0,0 +1,10 @@
+name: branch-validation
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ branch-validation:
+ uses: nullplatform/actions-nullplatform/.github/workflows/branch-validation.yml@main
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index ce7b173..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-name: ci-nullplatform
-env:
- NULLPLATFORM_API_KEY: ${{ secrets.NULLPLATFORM_API_KEY }}
-on:
- push:
- branches:
- - main
-permissions:
- id-token: write
- contents: read
- packages: read
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - name: Install nullplatform cli
- run: curl https://cli.nullplatform.com/install.sh | sh
- - name: Checkout code
- uses: actions/checkout@v4
- - name: Start nullplatform CI
- run: np build start
- - name: Build asset
- run: docker build -t main .
- - name: Push asset
- run: np asset push --type docker-image --source main
- - name: End nullplatform CI
- if: ${{ always() }}
- run: np build update --status ${{ contains(fromJSON('["failure", "cancelled"]'), job.status) && 'failed' || 'successful' }}
\ No newline at end of file
diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml
new file mode 100644
index 0000000..92952e1
--- /dev/null
+++ b/.github/workflows/conventional-commit.yml
@@ -0,0 +1,10 @@
+name: conventional-commit
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ conventional-commit:
+ uses: nullplatform/actions-nullplatform/.github/workflows/conventional-commit.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..8a65e73
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,15 @@
+name: release
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ release:
+ uses: nullplatform/actions-nullplatform/.github/workflows/release.yml@main
+ secrets: inherit
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
new file mode 100644
index 0000000..abf7bd9
--- /dev/null
+++ b/.github/workflows/shellcheck.yml
@@ -0,0 +1,10 @@
+name: shellcheck
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ shellcheck:
+ uses: nullplatform/actions-nullplatform/.github/workflows/shellcheck.yml@main
diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml
new file mode 100644
index 0000000..1a3c210
--- /dev/null
+++ b/.github/workflows/trivy.yml
@@ -0,0 +1,14 @@
+name: trivy
+
+on:
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ trivy:
+ uses: nullplatform/actions-nullplatform/.github/workflows/trivy-tofu-scan.yml@main
diff --git a/.gitignore b/.gitignore
index c1ae460..b85eb15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,134 +1,21 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-.pnpm-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# Bower dependency directory (https://bower.io/)
-bower_components
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# Snowpack dependency directory (https://snowpack.dev/)
-web_modules/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional stylelint cache
-.stylelintcache
-
-# Microbundle cache
-.rpt2_cache/
-.rts2_cache_cjs/
-.rts2_cache_es/
-.rts2_cache_umd/
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variable files
-.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-.parcel-cache
-
-# Next.js build output
-.next
+# IntelliJ project files
+.idea
+*.iml
out
+gen
-# Nuxt.js build / generate output
-.nuxt
-dist
-
-# Gatsby files
-.cache/
-# Comment in the public line in if your project uses Gatsby and not Next.js
-# https://nextjs.org/blog/next-9-1#public-directory-support
-# public
+# VSCode project files
+.vscode/
-# vuepress build output
-.vuepress/dist
+# Terraform / OpenTofu
+*.tfstate
+*.tfstate.backup
+.terraform/
-# vuepress v2.x temp and cache directory
-.temp
-.cache
+# .terraform.lock.hcl is intentionally tracked (pins provider versions) —
+# do not add it here.
-# Docusaurus cache and generated files
-.docusaurus
+*.tfvars
+!terraform.tfvars.example
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# TernJS port file
-.tern-port
-
-# Stores VSCode versions used for testing VSCode extensions
-.vscode-test
-
-# yarn v2
-.yarn/cache
-.yarn/unplugged
-.yarn/build-state.yml
-.yarn/install-state.gz
-.pnp.*
-
-# Intellij
-
-.idea
+.DS_Store
diff --git a/.trivyignore b/.trivyignore
new file mode 100644
index 0000000..5f8c40e
--- /dev/null
+++ b/.trivyignore
@@ -0,0 +1,9 @@
+# Trivy misconfiguration suppressions for this repo.
+# Each entry is intentional and documented; revisit if the module changes.
+
+# AVD-AWS-0104 — RDS security group allows unrestricted egress (0.0.0.0/0).
+# Standard for an RDS instance's own security group (matches the AWS default
+# VPC SG behavior): the instance needs outbound access for things like CA
+# bundle/extension fetches and Secrets Manager rotation. Inbound is already
+# restricted to PostgreSQL (5432) from the VPC's own CIDR blocks.
+AVD-AWS-0104
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 2c788e0..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM hashicorp/http-echo:1.0.0
-
-CMD ["-text={\"status\":\"ok\",\"msg\":\"Hola mundo\"}", "-listen=:8080", "-status-code=200"]
-
-
diff --git a/README.md b/README.md
index 4e481b6..7e64c53 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,20 @@
-
-
-
-
-
-
- Nullplatform "Any Technology" Template
-
-
+# services-postgresql-rds
-This is a minimalistic sample on how you can create an application on arbitrary technology.
-In particular, we're spinning up an image that contains an echo server.
-You can check *Echo Server* documentation [here](https://ealenn.github.io/Echo-Server/).
+nullplatform service definitions for AWS RDS PostgreSQL:
-## How do I modify this template to build my own application?
+- [`rds-postgres-server/`](rds-postgres-server/README.md) — provisions the RDS
+ PostgreSQL instance itself.
+- [`rds-postgres-db/`](rds-postgres-db/README.md) — provisions a database +
+ application user on an existing `rds-postgres-server` instance, linked via
+ the `connect` link.
-1. Change the Dockerfile to run the application / binary that you are building
-2. Deploy your application in nullplatform
+Each service directory is self-contained: `entrypoint/`, `workflows/`,
+`scripts/`, and `specs/` are read directly by the nullplatform agent at
+runtime. `specs/requirements/aws/` and `specs/install/aws/` are one-time
+setup Terraform, applied out-of-band by an account operator — see each
+service's own README for the full setup guide ("AssumeRole Setup Guide") and
+`specs/install/README.md`.
+
+This repository was extracted from `nullplatform/services` (the
+`databases/rds-postgres-server` and `databases/rds-postgres-db` directories),
+preserving their commit history.
diff --git a/rds-postgres-db/README.md b/rds-postgres-db/README.md
new file mode 100644
index 0000000..448a9f9
--- /dev/null
+++ b/rds-postgres-db/README.md
@@ -0,0 +1,306 @@
+# 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.
+
+## 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
+
+## Architecture
+
+```
+nullplatform Application
+ │
+ │ link (creates user + grants)
+ ▼
+ rds-postgres-db ──────► rds-postgres-server ──────► AWS RDS PostgreSQL
+ (this service) (auto-discovered) │
+ │ ├─ database: app_
+ │ ├─ user: app_ (service-level)
+ └─ per link: └─ user: np_ (per link)
+ postgresql_role.
+ 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.
+
+## Nullplatform Integration
+
+- **Dependency service type**: registered as a `dependency` service in nullplatform
+- **Auto-discovery**: at creation time, queries nullplatform for `dependency` services in the same namespace with `status=active` and attributes `hostname` + `master_secret_arn` set, filtered by matching dimensions
+- **Service attributes**: writes connection metadata back to nullplatform via `np service patch`
+- **Link attributes**: writes per-link credentials to nullplatform via `np link patch` for injection into application environment
+
+### Service Attributes (written after create)
+
+| Attribute | Visibility | Description |
+|---|---|---|
+| `hostname` | exported | RDS endpoint hostname |
+| `port` | exported | RDS port (5432) |
+| `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) |
+
+### Link Attributes (written per link)
+
+| Attribute | Description |
+|---|---|
+| `username` | Per-link PostgreSQL user (`np_`) |
+| `password` | Per-link PostgreSQL password |
+| `database_name` | Database name (same as service-level database) |
+
+## Link Parameters
+
+| Parameter | Type | Required | Default | Allowed Values |
+|---|---|---|---|---|
+| `access_level` | enum | No | `read-write` | `read`, `write`, `read-write` |
+
+### Access Level Grants
+
+| Level | Grants |
+|---|---|
+| `read` | `CONNECT` on database, `USAGE` on schema, `SELECT` on tables and sequences |
+| `write` | `CONNECT` on database, `USAGE` on schema, `INSERT`, `UPDATE`, `DELETE` on tables, `USAGE` on sequences |
+| `read-write` | All of the above + `CREATE` on schema (allows running migrations) |
+
+All access levels include `DEFAULT PRIVILEGES` so future tables created after the link also inherit the grants automatically.
+
+## Workflows
+
+| Workflow | Trigger | What It Does |
+|---|---|---|
+| `create` | Service created | Auto-discovers server, creates database + app user, 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** |
+| `link` | Application linked | Creates per-link PostgreSQL user with scoped grants |
+| `unlink` | Application unlinked | Revokes grants only; user and database are **preserved** |
+
+## Database and Username Derivation
+
+Database and username values are derived deterministically from nullplatform metadata:
+
+**Service level** (one per service):
+```
+database_name = "app_"
+username = "app_"
+```
+
+**Link level** (one per link):
+```
+username = "np_"
+# e.g., link_id = "a1b2c3d4-e5f6-..." → username = "np_a1b2c3d4e5f6..."
+```
+
+This ensures usernames are stable and reproducible even if the service is recreated.
+
+## Requirements
+
+### nullplatform Prerequisites
+
+- The service itself must be registered on the nullplatform account first — see
+ [`specs/install/README.md`](specs/install/README.md) for the Terraform that
+ registers the service specification and agent association.
+- An active **`rds-postgres-server`** service in the same nullplatform namespace with:
+ - `status: active`
+ - Matching dimensions (e.g., both services must have `cluster: prod`)
+ - Attributes `hostname` and `master_secret_arn` already set (i.e., RDS instance successfully provisioned)
+- The `rds-postgres-server` must expose a Secrets Manager secret with master PostgreSQL credentials
+- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **account-level NRN** (or any ascendant of the service's NRN — resolution walks up the hierarchy). Unlike `rds-postgres-server`, this service does not need `aws-configuration`/`aws-networking-configuration` providers — `build_context` reads `region` from `values.yaml` (default `us-east-1`), not from a nullplatform provider.
+
+### AWS IAM Permissions
+
+This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent needs:
+
+- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes
+- **S3**: full lifecycle on the `np-service-` 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.
+
+The `requirements/` Terraform module creates a dedicated IAM role
+(`nullplatform--rds-postgres-db-role`) holding these policies,
+with a trust policy allowing the nullplatform agent role to `sts:AssumeRole`
+on it. Pass `cluster_name` (required) and optionally `agent_role_arn`
+(defaults to `nullplatform--agent-role`), `role_name` (defaults
+to `nullplatform--rds-postgres-db-role`) and
+`policies_name_prefix` (defaults to `nullplatform-`) when
+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.
+
+### AssumeRole Setup Guide
+
+Three separate pieces must all be in place for the agent to actually assume
+`nullplatform--rds-postgres-db-role` at runtime — applying
+`requirements/` alone is not enough:
+
+1. **Apply `requirements/`** with `cluster_name` (and optionally
+ `agent_role_arn`) — creates the role and its trust policy (see above):
+ ```hcl
+ module "service_requirements_rds_postgres_db" {
+ source = "git::https://github.com/nullplatform/services-postgresql-rds.git//rds-postgres-db/specs/requirements/aws?ref="
+
+ cluster_name = ""
+ # agent_role_arn = "" # optional override; defaults to
+ # arn:aws:iam:::role/nullplatform--agent-role
+ }
+ ```
+ Read `module.service_requirements_rds_postgres_db.permissions_role_arn`
+ for the ARN needed in steps 2 and 3 below.
+2. **Grant the agent permission to assume it.** Not managed by
+ `requirements/` — add an inline (or managed) policy to the **agent's own**
+ IAM role:
+ ```json
+ {
+ "Effect": "Allow",
+ "Action": "sts:AssumeRole",
+ "Resource": "arn:aws:iam:::role/nullplatform--rds-postgres-db-role"
+ }
+ ```
+3. **Register the role as an `identity-access-control` provider** in
+ nullplatform, at the **account-level NRN**
+ (`organization=...:account=...` — no `:namespace=...`), with selector
+ `rds-postgres-db`. If `rds-postgres-server` is also installed in this
+ account, fold both selectors into the **same** provider instead of
+ registering a second one at the same NRN (see the comment in
+ `rds-postgres-server`'s `specs/install/aws/main.tf` for why a second,
+ independent registration breaks `assume_role_step`'s lookup):
+ ```hcl
+ module "identity_access_control" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref="
+ nrn = "organization=:account="
+ attributes = {
+ iam_role_arns = {
+ arns = [
+ { selector = "rds-postgres-db", arn = "" },
+ # { selector = "rds-postgres-server", arn = "" },
+ ]
+ }
+ }
+ }
+ ```
+
+`scripts/aws/assume_role_step` resolves the role by querying
+`np provider list --categories identity-access-control` with the service's
+**full NRN as-is** (not stripped) — not by reading `CONTEXT.providers[...]`.
+`--categories` resolves up the NRN hierarchy, so it finds the provider
+registered at the account level above regardless of how deep the service's
+own NRN is. This was confirmed live: an earlier version of this script
+stripped the NRN to namespace level and did a plain `np provider list`
+filtered by `stored_keys`, which does **not** inherit providers from
+ascendant NRN levels — the account-level provider was never found and the
+step silently fell back to agent credentials. Separately, `CONTEXT.providers[...]`
+itself was also confirmed live to never populate on this platform version
+regardless of the `provider_categories` declared in `values.yaml` or the
+workflow YAMLs, so the lookup goes through the `np` CLI directly either way.
+
+The lookup also passes `--dimensions` (derived from `.service.dimensions` in
+`CONTEXT`, e.g. `cluster:prod`) so that if more than one
+`identity-access-control` provider is ever registered at the same NRN
+for different dimensions, `np` resolves the most-specific match instead
+of an arbitrary one being picked client-side. Today the setup above
+registers a single, dimension-less provider per account, so this is a
+no-op — it only matters if per-dimension AssumeRole roles are introduced
+later.
+
+**If any of the 3 steps is missing**, `assume_role_step` logs
+`assume_role=skipped (using agent credentials)` and the workflow proceeds
+under the **agent's own role** — which fails with `AccessDenied` on S3/
+Secrets Manager calls unless the agent happens to have those permissions
+directly attached. This fail-open behavior is intentional (mirrors
+`nullplatform/scopes-static-files`), but it means a misconfigured
+AssumeRole setup fails *silently* as what looks like a permissions problem
+rather than a missing-provider problem — check the `assume role` step's
+log line first when debugging `AccessDenied` errors from later steps.
+
+### Auto-Discovery NRN Requirement
+
+`scripts/aws/build_db_setup_context` looks up the active `rds-postgres-server`
+via `np service list --nrn `, using the service's **full
+`entity_nrn` as-is** (including the `:application=...` segment) — not a
+namespace-level NRN with that segment stripped. Confirmed live: querying
+`np service list` at the namespace level (`:application=...` removed)
+returns **zero results**, even when a healthy, matching `rds-postgres-server`
+exists — `np service list --nrn` requires the exact NRN a service is scoped
+at, it does not search hierarchically down from a broader NRN. If you see
+`ERROR: No active RDS server found in matching dimensions: ...` and
+you're certain a matching, active server exists, check that this NRN is
+being derived correctly rather than assuming the server itself is
+misconfigured.
+
+### Runtime Dependencies
+
+These tools are required inside the agent container:
+
+- **OpenTofu 1.9.0** — auto-downloaded to `/tmp/np-tofu-bin/` if not available in `PATH`
+- **AWS CLI** — for Secrets Manager queries
+- **jq** — for JSON parsing
+- **PostgreSQL client (`psql`)** — installed via `apk add postgresql-client`, used for the `reassign_owned` step during service deletion
+
+## Important Considerations
+
+### Auto-Discovery Behavior
+
+At creation time, the service queries nullplatform for compatible `rds-postgres-server` instances. The discovery fails if:
+
+- **0 servers found**: No active `rds-postgres-server` exists with matching dimensions in the namespace. Create one first.
+- **More than 1 server found**: Multiple candidates match. The error output lists all matching servers. Add or adjust dimensions to make the match unambiguous.
+
+### Database Is Never Destroyed
+
+The PostgreSQL database has `lifecycle { prevent_destroy = true }` in Terraform. Even on service deletion, only the app-level user is destroyed — the database and all its data persist on the RDS instance. This is intentional to prevent accidental data loss.
+
+To fully drop the database, connect directly to the RDS instance using the master credentials from Secrets Manager.
+
+### Two-Phase Deletion
+
+Service deletion runs in two steps:
+1. **`reassign_owned`**: Transfers ownership of all database objects (tables, sequences, etc.) from the app user to the master user. This is required before dropping the app user, since PostgreSQL prevents dropping roles that own objects.
+2. **`tofu destroy`** (targeted): Destroys only `postgresql_role.app_user` and `random_password.user`. The database is not touched.
+
+### Stable Passwords
+
+- The service-level password is stable for the lifetime of the service (keyed by `service_id`)
+- Per-link passwords are stable across unlink/relink cycles (keyed by `link_id`)
+
+Neither password changes unless the underlying Terraform resource is tainted or recreated.
+
+### `read-write` Allows Schema Modifications
+
+The `read-write` access level includes `CREATE` on the `public` schema. This is intentional to allow applications to run database migrations. If you need to prevent schema changes, use `read` or `write` instead.
+
+### Dimension Alignment Is Critical
+
+This service uses dimensions to match the correct `rds-postgres-server`. If dimensions are not aligned between the two services, discovery fails at creation time with a clear error. Ensure both services are created with the same dimension values.
+
+### Service Must Exist Before Linking
+
+If the service was created but the `hostname` attribute is empty (e.g., provisioning failed), link operations exit cleanly without performing any database changes. Ensure the service is fully created before attempting to link applications.
+
+### Orphaned PostgreSQL Roles From Failed Creates
+
+The service-level username/database name are derived from `application_id`
+(`app_`), which is the **same across every retry** of
+creating this service for a given application — unlike `service_id`, which
+is different each time. If a `create` action fails *after*
+`postgresql_role.app_user` is created in Postgres but *before* the
+workflow reaches `write service outputs` (so the service's `hostname`
+attribute never gets set), a later `delete` action can't clean it up: it
+checks the stored `hostname` attribute first, finds it empty, and skips
+DB cleanup entirely (see "Service Must Exist Before Linking" above) —
+leaving the Postgres role behind. The next `create` retry then fails with
+`role "app_" already exists`, even though nullplatform has
+no record of a working service. If you hit this, connect to the RDS
+instance with master credentials and run
+`DROP ROLE IF EXISTS app_;` (after reassigning/dropping any
+objects it owns, if it had time to create any) before retrying.
diff --git a/rds-postgres-db/db_setup/backend.tf b/rds-postgres-db/db_setup/backend.tf
new file mode 100644
index 0000000..12c0dbe
--- /dev/null
+++ b/rds-postgres-db/db_setup/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "s3" {}
+}
diff --git a/rds-postgres-db/db_setup/main.tf b/rds-postgres-db/db_setup/main.tf
new file mode 100644
index 0000000..fc4516c
--- /dev/null
+++ b/rds-postgres-db/db_setup/main.tf
@@ -0,0 +1,36 @@
+# ---------------------------------------------------------------------------
+# Database — created on service create, preserved on service delete.
+#
+# On re-creates (database already exists), do_tofu runs
+# "tofu import postgresql_database.app " before apply so no data
+# is lost. prevent_destroy ensures tofu destroy never drops the DB.
+# ---------------------------------------------------------------------------
+
+resource "postgresql_database" "app" {
+ name = var.db_name
+ owner = var.master_username
+
+ lifecycle {
+ prevent_destroy = true
+ }
+}
+
+# ---------------------------------------------------------------------------
+# App user — password is stable for the lifetime of the service.
+# keepers use service_id so the password only regenerates if the service
+# itself is recreated with a different ID.
+# ---------------------------------------------------------------------------
+
+resource "random_password" "user" {
+ length = 32
+ special = false
+ keepers = {
+ service_id = var.service_id
+ }
+}
+
+resource "postgresql_role" "app_user" {
+ name = var.db_username
+ password = random_password.user.result
+ login = true
+}
diff --git a/rds-postgres-db/db_setup/outputs.tf b/rds-postgres-db/db_setup/outputs.tf
new file mode 100644
index 0000000..20b5ff5
--- /dev/null
+++ b/rds-postgres-db/db_setup/outputs.tf
@@ -0,0 +1,30 @@
+output "hostname" {
+ value = var.db_host
+ description = "RDS endpoint hostname"
+}
+
+output "port" {
+ value = var.db_port
+ description = "RDS port"
+}
+
+output "master_secret_arn" {
+ value = var.master_secret_arn
+ description = "ARN of the Secrets Manager secret for master credentials"
+}
+
+output "db_username" {
+ value = postgresql_role.app_user.name
+ description = "Created database username"
+}
+
+output "db_password" {
+ value = random_password.user.result
+ sensitive = true
+ description = "Database user password"
+}
+
+output "database_name" {
+ value = postgresql_database.app.name
+ description = "Database name"
+}
diff --git a/rds-postgres-db/db_setup/providers.tf b/rds-postgres-db/db_setup/providers.tf
new file mode 100644
index 0000000..976a2bf
--- /dev/null
+++ b/rds-postgres-db/db_setup/providers.tf
@@ -0,0 +1,22 @@
+terraform {
+ required_providers {
+ postgresql = {
+ source = "cyrilgdn/postgresql"
+ version = "~> 1.21"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "postgresql" {
+ host = var.db_host
+ port = var.db_port
+ database = "postgres"
+ username = var.master_username
+ password = var.master_password
+ sslmode = "require"
+ superuser = false
+}
diff --git a/rds-postgres-db/db_setup/variables.tf b/rds-postgres-db/db_setup/variables.tf
new file mode 100644
index 0000000..06f00c3
--- /dev/null
+++ b/rds-postgres-db/db_setup/variables.tf
@@ -0,0 +1,41 @@
+variable "service_id" {
+ type = string
+ description = "Nullplatform service ID (used as keeper to stabilize password across re-applies)"
+}
+
+variable "db_host" {
+ type = string
+ description = "RDS endpoint hostname"
+}
+
+variable "db_port" {
+ type = number
+ default = 5432
+ description = "RDS port"
+}
+
+variable "db_name" {
+ type = string
+ description = "Database name to create"
+}
+
+variable "db_username" {
+ type = string
+ description = "Username for the database user"
+}
+
+variable "master_username" {
+ type = string
+ description = "Master username for connecting to PostgreSQL"
+}
+
+variable "master_secret_arn" {
+ type = string
+ description = "ARN of the Secrets Manager secret for master credentials"
+}
+
+variable "master_password" {
+ type = string
+ sensitive = true
+ description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)"
+}
diff --git a/rds-postgres-db/deployment/backend.tf b/rds-postgres-db/deployment/backend.tf
new file mode 100644
index 0000000..12c0dbe
--- /dev/null
+++ b/rds-postgres-db/deployment/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "s3" {}
+}
diff --git a/rds-postgres-db/deployment/data.tf b/rds-postgres-db/deployment/data.tf
new file mode 100644
index 0000000..9854479
--- /dev/null
+++ b/rds-postgres-db/deployment/data.tf
@@ -0,0 +1,15 @@
+# Discover shared RDS infrastructure (tagged by nullplatform during setup)
+
+data "aws_vpc" "main" {
+ id = var.vpc_id
+}
+
+data "aws_subnets" "private" {
+ filter {
+ name = "vpc-id"
+ values = [var.vpc_id]
+ }
+ tags = {
+ "nullplatform/subnet-type" = "private"
+ }
+}
diff --git a/rds-postgres-db/deployment/main.tf b/rds-postgres-db/deployment/main.tf
new file mode 100644
index 0000000..24f05dc
--- /dev/null
+++ b/rds-postgres-db/deployment/main.tf
@@ -0,0 +1,103 @@
+# ---------------------------------------------------------------------------
+# Security group for RDS (allows PostgreSQL traffic from within the VPC)
+# ---------------------------------------------------------------------------
+
+resource "aws_security_group" "rds" {
+ name = "np-rds-${var.instance_name}"
+ description = "Allow PostgreSQL access from within the VPC"
+ vpc_id = var.vpc_id
+
+ ingress {
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ cidr_blocks = [data.aws_vpc.main.cidr_block]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Master password (stored in Secrets Manager, used by link permissions)
+# ---------------------------------------------------------------------------
+
+resource "random_password" "master" {
+ length = 32
+ special = false
+}
+
+resource "aws_secretsmanager_secret" "master" {
+ name = "nullplatform/rds/${var.instance_name}/master"
+ recovery_window_in_days = 0
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "rds-instance" = var.instance_name
+ "service-id" = var.service_id
+ }
+}
+
+resource "aws_secretsmanager_secret_version" "master" {
+ secret_id = aws_secretsmanager_secret.master.id
+ secret_string = jsonencode({
+ username = "master"
+ password = random_password.master.result
+ })
+}
+
+# ---------------------------------------------------------------------------
+# RDS instance
+# ---------------------------------------------------------------------------
+
+resource "aws_db_subnet_group" "main" {
+ name = var.instance_name
+ subnet_ids = data.aws_subnets.private.ids
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+}
+
+resource "aws_db_instance" "main" {
+ identifier = var.instance_name
+ engine = "postgres"
+ engine_version = var.postgres_version
+ instance_class = var.instance_class
+ allocated_storage = var.allocated_storage
+ storage_type = "gp3"
+ storage_encrypted = true
+
+ db_name = "postgres"
+ username = "master"
+ password = random_password.master.result
+
+ db_subnet_group_name = aws_db_subnet_group.main.name
+ vpc_security_group_ids = [aws_security_group.rds.id]
+
+ multi_az = var.multi_az
+ publicly_accessible = false
+ skip_final_snapshot = true
+ deletion_protection = false
+
+ backup_retention_period = var.backup_retention_period
+ backup_window = var.backup_window
+ maintenance_window = var.maintenance_window
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+
+ depends_on = [aws_secretsmanager_secret_version.master]
+}
diff --git a/rds-postgres-db/deployment/outputs.tf b/rds-postgres-db/deployment/outputs.tf
new file mode 100644
index 0000000..31bd6ae
--- /dev/null
+++ b/rds-postgres-db/deployment/outputs.tf
@@ -0,0 +1,19 @@
+output "hostname" {
+ value = aws_db_instance.main.address
+ description = "RDS endpoint hostname"
+}
+
+output "port" {
+ value = aws_db_instance.main.port
+ description = "RDS port"
+}
+
+output "db_instance_identifier" {
+ value = aws_db_instance.main.identifier
+ description = "AWS RDS instance identifier"
+}
+
+output "master_secret_arn" {
+ value = aws_secretsmanager_secret.master.arn
+ description = "ARN of the Secrets Manager secret for master credentials"
+}
diff --git a/rds-postgres-db/deployment/providers.tf b/rds-postgres-db/deployment/providers.tf
new file mode 100644
index 0000000..4988945
--- /dev/null
+++ b/rds-postgres-db/deployment/providers.tf
@@ -0,0 +1,16 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.region
+}
diff --git a/rds-postgres-db/deployment/variables.tf b/rds-postgres-db/deployment/variables.tf
new file mode 100644
index 0000000..673ba42
--- /dev/null
+++ b/rds-postgres-db/deployment/variables.tf
@@ -0,0 +1,62 @@
+variable "service_id" {
+ type = string
+ description = "Nullplatform service ID"
+}
+
+variable "instance_name" {
+ type = string
+ description = "Unique instance name for AWS resource naming (format: np-)"
+}
+
+variable "region" {
+ type = string
+ default = "us-east-1"
+ description = "AWS region"
+}
+
+variable "vpc_id" {
+ type = string
+ description = "VPC ID where the RDS instance will be deployed"
+}
+
+variable "instance_class" {
+ type = string
+ default = "db.t3.micro"
+ description = "RDS instance class"
+}
+
+variable "allocated_storage" {
+ type = number
+ default = 20
+ description = "Allocated storage in GB"
+}
+
+variable "postgres_version" {
+ type = string
+ default = "16"
+ description = "PostgreSQL major version"
+}
+
+variable "multi_az" {
+ type = bool
+ default = false
+ description = "Enable Multi-AZ deployment for high availability"
+}
+
+variable "backup_retention_period" {
+ type = number
+ default = 7
+ description = "Number of days to retain automated backups. 0 disables backups."
+}
+
+variable "backup_window" {
+ type = string
+ default = "03:00-04:00"
+ description = "Daily time range for automated backups (UTC, hh:mm-hh:mm)"
+}
+
+variable "maintenance_window" {
+ type = string
+ default = "Mon:04:00-Mon:05:00"
+ description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)"
+}
diff --git a/rds-postgres-db/entrypoint/entrypoint b/rds-postgres-db/entrypoint/entrypoint
new file mode 100755
index 0000000..f8b84ad
--- /dev/null
+++ b/rds-postgres-db/entrypoint/entrypoint
@@ -0,0 +1,83 @@
+#!/bin/bash
+set -euo pipefail
+
+if [ -z "${NP_ACTION_CONTEXT:-}" ]; then
+ echo "NP_ACTION_CONTEXT is not set. Exiting."
+ exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# Bridge agent API key to the np CLI.
+# np-agent passes NP_API_KEY; the np CLI expects NULLPLATFORM_API_KEY.
+# Without this bridge, commands like "np service patch" fail with:
+# "please login first, refresh token or organization id not found"
+# ---------------------------------------------------------------------------
+if [ -n "${NP_API_KEY:-}" ] && [ -z "${NULLPLATFORM_API_KEY:-}" ]; then
+ export NULLPLATFORM_API_KEY="$NP_API_KEY"
+fi
+
+# Strip surrounding single quotes that np-agent may wrap the context in
+CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//")
+export NP_ACTION_CONTEXT="$CLEAN_CONTEXT"
+
+export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification')
+export SERVICE_ACTION=$(echo "$CONTEXT" | jq -r '.slug')
+export SERVICE_ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type')
+export NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action')
+export LINK=$(echo "$CONTEXT" | jq '.link')
+
+ACTION_SOURCE=service
+IS_LINK_ACTION=$(echo "$CONTEXT" | jq '.link != null')
+
+if [ "$IS_LINK_ACTION" = "true" ]; then
+ ACTION_SOURCE=link
+fi
+
+export WORKING_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+SERVICE_PATH=""
+OVERRIDES_PATH=""
+
+for arg in "$@"; do
+ case $arg in
+ --service-path=*)
+ SERVICE_PATH="${arg#*=}"
+ ;;
+ --overrides-path=*)
+ OVERRIDES_PATH="${arg#*=}"
+ ;;
+ *)
+ echo "Unknown argument: $arg"
+ exit 1
+ ;;
+ esac
+done
+
+SERVICE_PATH="${SERVICE_PATH:-$(dirname "$WORKING_DIRECTORY")}"
+
+# ---------------------------------------------------------------------------
+# Resolve relative SERVICE_PATH to absolute.
+# The agent child process inherits the CWD of where np-agent was started,
+# which is NOT necessarily ~/.np/ (the basepath). Try the current CWD first,
+# then fall back to ~/.np/ as the basepath.
+# Without this fallback, "cd " fails silently (exit 1, no output)
+# when the CWD doesn't contain the service path.
+# ---------------------------------------------------------------------------
+if [[ "$SERVICE_PATH" != /* ]]; then
+ if [ -d "$SERVICE_PATH" ]; then
+ SERVICE_PATH="$(cd "$SERVICE_PATH" && pwd)"
+ elif [ -d "$HOME/.np/$SERVICE_PATH" ]; then
+ SERVICE_PATH="$(cd "$HOME/.np/$SERVICE_PATH" && pwd)"
+ else
+ echo "ERROR: Cannot resolve SERVICE_PATH='$SERVICE_PATH' from CWD=$(pwd) or $HOME/.np/"
+ exit 1
+ fi
+fi
+
+OVERRIDES_PATH="${OVERRIDES_PATH:-$SERVICE_PATH/overrides}"
+
+export SERVICE_PATH
+export OVERRIDES_PATH
+export ACTION_SOURCE
+
+np service-action exec --live-output --live-report --script="$WORKING_DIRECTORY/$ACTION_SOURCE"
diff --git a/rds-postgres-db/entrypoint/link b/rds-postgres-db/entrypoint/link
new file mode 100755
index 0000000..f755e81
--- /dev/null
+++ b/rds-postgres-db/entrypoint/link
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+echo "Executing link action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE"
+
+ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE"
+
+case "$SERVICE_ACTION_TYPE" in
+ "custom")
+ ACTION_TO_EXECUTE="$SERVICE_ACTION"
+ ;;
+ "create"|"update")
+ ACTION_TO_EXECUTE="link"
+ ;;
+ "delete")
+ ACTION_TO_EXECUTE="unlink"
+ ;;
+esac
+
+WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+VALUES_PATH="$SERVICE_PATH/values.yaml"
+
+CMD="np service workflow exec --workflow $WORKFLOW_PATH"
+
+if [[ -f "$VALUES_PATH" ]]; then
+ CMD="$CMD --values $VALUES_PATH"
+fi
+
+if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then
+ CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH"
+fi
+
+echo "Executing command: $CMD"
+eval "$CMD"
diff --git a/rds-postgres-db/entrypoint/service b/rds-postgres-db/entrypoint/service
new file mode 100755
index 0000000..e876d23
--- /dev/null
+++ b/rds-postgres-db/entrypoint/service
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+echo "Executing service action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE"
+
+ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE"
+
+case "$SERVICE_ACTION_TYPE" in
+ "custom")
+ ACTION_TO_EXECUTE="$SERVICE_ACTION"
+ ;;
+esac
+
+WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+VALUES_PATH="$SERVICE_PATH/values.yaml"
+
+CMD="np service workflow exec --workflow $WORKFLOW_PATH"
+
+if [[ -f "$VALUES_PATH" ]]; then
+ CMD="$CMD --values $VALUES_PATH"
+fi
+
+if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then
+ CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH"
+fi
+
+echo "Executing command: $CMD"
+eval "$CMD"
diff --git a/rds-postgres-db/permissions/backend.tf b/rds-postgres-db/permissions/backend.tf
new file mode 100644
index 0000000..12c0dbe
--- /dev/null
+++ b/rds-postgres-db/permissions/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "s3" {}
+}
diff --git a/rds-postgres-db/permissions/locals.tf b/rds-postgres-db/permissions/locals.tf
new file mode 100644
index 0000000..edcf72e
--- /dev/null
+++ b/rds-postgres-db/permissions/locals.tf
@@ -0,0 +1,19 @@
+locals {
+ table_privileges = {
+ # read: SELECT only
+ "read" = ["SELECT"]
+
+ # write: INSERT/UPDATE/DELETE without SELECT (write-only)
+ "write" = ["INSERT", "UPDATE", "DELETE"]
+
+ # read-write: full DML access
+ "read-write" = ["SELECT", "INSERT", "UPDATE", "DELETE"]
+ }
+
+ # read-write gets CREATE so the app can run schema migrations
+ schema_privileges = {
+ "read" = ["USAGE"]
+ "write" = ["USAGE"]
+ "read-write" = ["USAGE", "CREATE"]
+ }
+}
diff --git a/rds-postgres-db/permissions/main.tf b/rds-postgres-db/permissions/main.tf
new file mode 100644
index 0000000..812cd45
--- /dev/null
+++ b/rds-postgres-db/permissions/main.tf
@@ -0,0 +1,59 @@
+# ---------------------------------------------------------------------------
+# Permissions only — the database and user are created at service level
+# (db_setup module). This module manages grants for a specific link.
+# On unlink, only these grant resources are destroyed; the user and DB
+# are preserved at service level.
+# ---------------------------------------------------------------------------
+
+resource "postgresql_grant" "connect" {
+ database = var.db_name
+ role = var.db_username
+ object_type = "database"
+ privileges = ["CONNECT"]
+}
+
+resource "postgresql_grant" "schema_usage" {
+ database = var.db_name
+ role = var.db_username
+ schema = "public"
+ object_type = "schema"
+ privileges = local.schema_privileges[var.access_level]
+}
+
+# Grant on existing tables
+resource "postgresql_grant" "tables" {
+ database = var.db_name
+ role = var.db_username
+ schema = "public"
+ object_type = "table"
+ privileges = local.table_privileges[var.access_level]
+}
+
+# Grant on future tables (default privileges applied by master user)
+resource "postgresql_default_privileges" "tables" {
+ role = var.db_username
+ database = var.db_name
+ schema = "public"
+ owner = var.master_username
+ object_type = "table"
+ privileges = local.table_privileges[var.access_level]
+}
+
+# Grant USAGE on existing sequences (needed for INSERT on serial/bigserial columns)
+resource "postgresql_grant" "sequences" {
+ database = var.db_name
+ role = var.db_username
+ schema = "public"
+ object_type = "sequence"
+ privileges = ["USAGE", "SELECT"]
+}
+
+# Grant USAGE on future sequences
+resource "postgresql_default_privileges" "sequences" {
+ role = var.db_username
+ database = var.db_name
+ schema = "public"
+ owner = var.master_username
+ object_type = "sequence"
+ privileges = ["USAGE", "SELECT"]
+}
diff --git a/rds-postgres-db/permissions/outputs.tf b/rds-postgres-db/permissions/outputs.tf
new file mode 100644
index 0000000..eebae82
--- /dev/null
+++ b/rds-postgres-db/permissions/outputs.tf
@@ -0,0 +1,2 @@
+# Credentials are stored at service level (written by write_service_outputs).
+# This module has no outputs — write_link_outputs reads directly from service attributes.
diff --git a/rds-postgres-db/permissions/providers.tf b/rds-postgres-db/permissions/providers.tf
new file mode 100644
index 0000000..976a2bf
--- /dev/null
+++ b/rds-postgres-db/permissions/providers.tf
@@ -0,0 +1,22 @@
+terraform {
+ required_providers {
+ postgresql = {
+ source = "cyrilgdn/postgresql"
+ version = "~> 1.21"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "postgresql" {
+ host = var.db_host
+ port = var.db_port
+ database = "postgres"
+ username = var.master_username
+ password = var.master_password
+ sslmode = "require"
+ superuser = false
+}
diff --git a/rds-postgres-db/permissions/variables.tf b/rds-postgres-db/permissions/variables.tf
new file mode 100644
index 0000000..6b5c37e
--- /dev/null
+++ b/rds-postgres-db/permissions/variables.tf
@@ -0,0 +1,42 @@
+variable "db_host" {
+ type = string
+ description = "RDS endpoint hostname"
+}
+
+variable "db_port" {
+ type = number
+ default = 5432
+ description = "RDS port"
+}
+
+variable "db_name" {
+ type = string
+ description = "Database name (from service attributes)"
+}
+
+variable "db_username" {
+ type = string
+ description = "Database username (from service attributes)"
+}
+
+variable "master_username" {
+ type = string
+ description = "Master username for connecting to PostgreSQL"
+}
+
+variable "master_password" {
+ type = string
+ sensitive = true
+ description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)"
+}
+
+variable "access_level" {
+ type = string
+ default = "read-write"
+ description = "Permission level: read, write, or read-write"
+
+ validation {
+ condition = contains(["read", "write", "read-write"], var.access_level)
+ error_message = "access_level must be one of: read, write, read-write"
+ }
+}
diff --git a/rds-postgres-db/scripts/aws/assume_role b/rds-postgres-db/scripts/aws/assume_role
new file mode 100755
index 0000000..d2c8cbd
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/assume_role
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Sourceable helper — do NOT execute directly.
+# Reads RDS_POSTGRES_DB_ASSUME_ROLE_ARN from the environment. If set, calls
+# sts:AssumeRole and exports temporary credentials so all subsequent AWS calls
+# (including tofu) use that role. If empty, does nothing — the agent's
+# credentials (pod IRSA) handle auth.
+#
+# Requires: aws CLI, jq.
+# Expects: RDS_POSTGRES_DB_ASSUME_ROLE_ARN (set by scripts/aws/assume_role_step),
+# SERVICE_ID (optional, used for the session name).
+
+if [ -n "${RDS_POSTGRES_DB_ASSUME_ROLE_ARN:-}" ]; then
+ echo " 🔑 Assuming role: $RDS_POSTGRES_DB_ASSUME_ROLE_ARN"
+
+ _ar_sts_error=$(mktemp)
+ if ! ASSUMED_CREDS=$(aws sts assume-role \
+ --role-arn "$RDS_POSTGRES_DB_ASSUME_ROLE_ARN" \
+ --role-session-name "np-rds-postgres-db-${SERVICE_ID:-workflow}" \
+ --output json 2>"$_ar_sts_error"); then
+ echo " ❌ sts:AssumeRole failed for $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2
+ cat "$_ar_sts_error" >&2
+ rm -f "$_ar_sts_error"
+ return 1
+ fi
+ rm -f "$_ar_sts_error"
+
+ _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""')
+ _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""')
+ _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""')
+
+ if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then
+ echo " ❌ sts:AssumeRole returned incomplete credentials for $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2
+ return 1
+ fi
+
+ export AWS_ACCESS_KEY_ID="$_ar_access_key"
+ export AWS_SECRET_ACCESS_KEY="$_ar_secret_key"
+ export AWS_SESSION_TOKEN="$_ar_session_token"
+
+ echo " ✅ Role assumed successfully"
+else
+ echo " ✅ assume_role=skipped (using agent credentials)"
+fi
diff --git a/rds-postgres-db/scripts/aws/assume_role_lib b/rds-postgres-db/scripts/aws/assume_role_lib
new file mode 100755
index 0000000..38dc5aa
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/assume_role_lib
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Sourceable library of PURE helpers for assume-role resolution.
+# Makes NO np/aws calls and has no source side effects — fully unit-testable.
+# Identical across rds-postgres-server and rds-postgres-db so it can be
+# copied as-is into any new AWS service module.
+#
+# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion).
+
+# arn_for_selector
+# Given the IAM provider attributes (.iam_role_arns.arns[...]), echoes the ARN
+# whose entry matches , or "" if none. First match wins. Never
+# crashes on empty/malformed input.
+arn_for_selector() {
+ local json="$1" selector="$2"
+ [ -n "$json" ] || return 0
+ [ -n "$selector" ] || return 0
+ printf '%s' "$json" | jq -r --arg sel "$selector" '
+ [ .iam_role_arns.arns[]?
+ | select(.selector == $sel)
+ | .arn ]
+ | first // ""' 2>/dev/null || true
+}
+
+# resolve_assume_role_arn
+# Echoes the ARN to assume ("" = use agent credentials), in precedence order:
+# 1. ${!override_env_name} — explicit per-run override
+# 2. iam_attributes_json entry matching (caller pre-resolved the
+# provider via `np provider list` for the service's NRN + dimensions)
+# 3. ${!default_env_name} — per-account agent default
+# Empty override/default env names are treated as unset (chain continues).
+resolve_assume_role_arn() {
+ local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn=""
+
+ if [ -n "$override_env" ]; then
+ arn="${!override_env:-}"
+ fi
+
+ if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then
+ arn=$(arn_for_selector "$iam_json" "$selector")
+ fi
+
+ if [ -z "$arn" ] && [ -n "$default_env" ]; then
+ arn="${!default_env:-}"
+ fi
+
+ printf '%s' "$arn"
+}
diff --git a/rds-postgres-db/scripts/aws/assume_role_step b/rds-postgres-db/scripts/aws/assume_role_step
new file mode 100755
index 0000000..16e4eb5
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/assume_role_step
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Dedicated workflow step: resolve the target IAM role and assume it, exporting
+# temporary credentials so every subsequent step (including tofu) inherits them.
+#
+# Runs FIRST in each AWS-touching workflow. The workflow YAML must declare
+# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as
+# output:environment so the engine propagates them to the following steps.
+#
+# The AWS IAM provider (type "aws-iam-configuration", stored key
+# "iam_role_arns.arns") is looked up directly via the np CLI, using the
+# service's full NRN (resolution walks up the hierarchy to the account level,
+# where this provider is actually registered — see below). CONTEXT.providers[...]
+# is NOT used here:
+# a live end-to-end test against a real agent confirmed it is never populated
+# by this platform version regardless of provider_categories declarations
+# (values.yaml and/or the workflow) — provider_categories is still declared
+# for forward-compatibility, but this script does not rely on it.
+#
+# The lookup also passes --dimensions (from .service.dimensions in CONTEXT,
+# e.g. cluster:prod) so that if more than one identity-access-control provider
+# is ever registered at the same NRN for different dimensions, np
+# resolves the most-specific match instead of us picking an arbitrary one
+# client-side. Today only one such provider is documented/registered per
+# account (no dimensions of its own), so this is a no-op in practice.
+#
+# Resolution precedence (see resolve_assume_role_arn in assume_role_lib):
+# $RDS_POSTGRES_DB_ASSUME_ROLE_ARN -> IAM provider by selector
+# -> $RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT -> agent credentials
+#
+# Requires: aws CLI, np CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional).
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/assume_role_lib"
+
+RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR:-rds-postgres-db}"
+
+# NRN of the service from CONTEXT (falls back to scope / generic event). Use the
+# full NRN as-is — do NOT strip it. Resolution walks UP the NRN hierarchy, so the
+# identity-access-control provider (registered at account level) is found via the
+# --categories query below regardless of the depth the service runs at.
+NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .scope.nrn // .entity_nrn // ""' 2>/dev/null)
+
+# Dimensions (if any) as key:value,key:value — lets np resolve the
+# most-specific IAM provider the same way it would for a k8s scope.
+DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r '
+ if (.service.dimensions | type) == "object" and ((.service.dimensions | length) > 0)
+ then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",")
+ else empty end' 2>/dev/null)
+
+# Resolve the IAM provider for this NRN + dimensions via the category query, which
+# resolves up the NRN hierarchy and returns the effective provider .attributes.
+# NOTE: --limit is incompatible with --categories (np rejects it), so it is NOT
+# passed here.
+IAM_PROVIDER=$(np provider list \
+ --nrn "$NRN" \
+ --categories identity-access-control \
+ ${DIMENSIONS:+--dimensions "$DIMENSIONS"} \
+ --format json 2>/dev/null \
+ | jq -c '(.results // [])[0].attributes // {}')
+
+RDS_POSTGRES_DB_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \
+ "$IAM_PROVIDER" \
+ "$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR" \
+ "RDS_POSTGRES_DB_ASSUME_ROLE_ARN" \
+ "RDS_POSTGRES_DB_ASSUME_ROLE_ARN_DEFAULT")
+export RDS_POSTGRES_DB_ASSUME_ROLE_ARN
+
+# scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set,
+# or no-ops (leaving agent credentials in place) when empty. Non-zero only when
+# sts:AssumeRole itself fails.
+if ! source "$SCRIPT_DIR/assume_role"; then
+ echo " ❌ assume_role step failed: could not assume $RDS_POSTGRES_DB_ASSUME_ROLE_ARN" >&2
+ echo "" >&2
+ echo "💡 Possible causes:" >&2
+ echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2
+ echo " • The target role does not exist or does not trust the agent role" >&2
+ echo " • There is no role ARN configured for selector=$RDS_POSTGRES_DB_ASSUME_ROLE_SELECTOR at NRN=$NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2
+ echo "" >&2
+ exit 1
+fi
diff --git a/rds-postgres-db/scripts/aws/build_context b/rds-postgres-db/scripts/aws/build_context
new file mode 100755
index 0000000..96854f1
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/build_context
@@ -0,0 +1,89 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# build_context — Prepares the execution environment for rds-postgres-db
+# service and link actions.
+#
+# All actions: creates/verifies the per-service S3 tfstate bucket and reads
+# the service's stored attributes (hostname, port, master_secret_arn,
+# database_name, username) into environment variables.
+#
+# Link/unlink: additionally exports link-specific variables (LINK_ID,
+# LINK_ACCESS_LEVEL, etc.). For unlink, checks if the service was ever
+# created; if not, sets LINK_NEVER_CREATED=true and exits cleanly.
+# ---------------------------------------------------------------------------
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+
+# --- Read static config from values.yaml ------------------------------------
+
+yaml_value() {
+ local key="$1" default="$2" file="$3"
+ local val
+ val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+REGION=$(yaml_value "region" "us-east-1" "$VALUES")
+AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES")
+
+if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then
+ export AWS_PROFILE="${AWS_PROFILE_VAL}"
+fi
+
+# --- Ensure per-service S3 bucket exists (used for all tfstate files) -------
+
+TFSTATE_BUCKET="np-service-${SERVICE_ID}"
+
+if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then
+ echo "Creating tfstate bucket: ${TFSTATE_BUCKET}"
+ if [ "$REGION" = "us-east-1" ]; then
+ aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION"
+ else
+ aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" \
+ --create-bucket-configuration LocationConstraint="$REGION"
+ fi
+ aws s3api put-bucket-versioning --bucket "$TFSTATE_BUCKET" \
+ --versioning-configuration Status=Enabled
+ echo "Bucket ${TFSTATE_BUCKET} created with versioning enabled."
+else
+ echo "Using existing tfstate bucket: ${TFSTATE_BUCKET}"
+fi
+
+export TFSTATE_BUCKET
+
+export OUTPUT_DIR="/tmp/np-service-${SERVICE_ID}"
+mkdir -p "$OUTPUT_DIR"
+
+# --- Read stored service attributes (available after service create) --------
+
+echo "Reading service ${SERVICE_ID} attributes..."
+SERVICE_JSON=$(np service read --id "$SERVICE_ID" --format json)
+
+export SERVER_HOSTNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.hostname // ""')
+export SERVER_PORT=$(echo "$SERVICE_JSON" | jq -r '.attributes.port // "5432"')
+export SERVER_MASTER_SECRET_ARN=$(echo "$SERVICE_JSON" | jq -r '.attributes.master_secret_arn // ""')
+export DB_NAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.database_name // ""')
+export DB_USERNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.username // ""')
+
+# --- Link context (link/unlink workflows only) ------------------------------
+
+if [ "${ACTION_SOURCE:-}" = "link" ]; then
+ export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""')
+ export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""')
+ export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""')
+ export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""')
+
+ LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})')
+ export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"')
+
+ ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""')
+
+ if [ "$ACTION_TYPE" = "delete" ] && [ -z "$SERVER_HOSTNAME" ]; then
+ echo "WARNING: service has no hostname — service was never created successfully. Nothing to destroy."
+ export LINK_NEVER_CREATED=true
+ exit 0
+ fi
+ export LINK_NEVER_CREATED=false
+fi
diff --git a/rds-postgres-db/scripts/aws/build_db_setup_context b/rds-postgres-db/scripts/aws/build_db_setup_context
new file mode 100644
index 0000000..c323457
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/build_db_setup_context
@@ -0,0 +1,135 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# build_db_setup_context — Prepares Tofu execution context for the db_setup
+# module. Used by both service create and service delete.
+#
+# Service create: auto-discovers the rds-postgres-server with matching
+# dimensions, derives DB name/username from application_id, then
+# retrieves master credentials from Secrets Manager.
+#
+# Service delete: SERVER_HOSTNAME is already exported by build_context
+# (read from service attributes), so server discovery is skipped.
+# If service was never created (SERVER_HOSTNAME empty), exits cleanly.
+# ---------------------------------------------------------------------------
+
+yaml_value() {
+ local key="$1" default="$2" file="$3"
+ local val
+ val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+REGION=$(yaml_value "region" "us-east-1" "$VALUES")
+AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES")
+
+if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then
+ export AWS_PROFILE="${AWS_PROFILE_VAL}"
+fi
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""')
+
+# --- Resolve connection info -----------------------------------------------
+
+if [ -n "${SERVER_HOSTNAME:-}" ]; then
+ # Service delete: use stored service attributes already exported by build_context
+ echo "Using stored service attributes for DB setup context..."
+ DB_HOST="$SERVER_HOSTNAME"
+ DB_PORT="${SERVER_PORT:-5432}"
+ MASTER_SECRET_ARN="$SERVER_MASTER_SECRET_ARN"
+ DB_NAME_VAL="${DB_NAME:-}"
+ DB_USERNAME_VAL="${DB_USERNAME:-}"
+
+else
+ if [ "$ACTION_TYPE" = "delete" ]; then
+ echo "Service has no stored hostname — was never created successfully. Skipping DB cleanup."
+ export SETUP_SKIPPED=true
+ exit 0
+ fi
+
+ # Service create: auto-discover rds-postgres-server with matching dimensions.
+ # Use entity_nrn as-is: np service list requires the exact NRN a service is
+ # scoped at (confirmed live — a namespace-level NRN with :application=...
+ # stripped off returns zero results even when matching services exist).
+ ENTITY_NRN=$(echo "$CONTEXT" | jq -r '.entity_nrn // ""')
+ SERVICE_DIMENSIONS=$(echo "$CONTEXT" | jq -c '.service.dimensions // {}')
+ echo "Auto-discovering RDS server in ${ENTITY_NRN} (dimensions: ${SERVICE_DIMENSIONS})..."
+
+ SERVER_SERVICES=$(np service list \
+ --nrn "$ENTITY_NRN" \
+ --type dependency \
+ --status active \
+ --format json | \
+ jq --argjson dims "$SERVICE_DIMENSIONS" \
+ '[(.results // .) | .[] | . as $svc | select(
+ (.attributes.hostname // "") != "" and
+ (.attributes.master_secret_arn // "") != "" and
+ ($dims | to_entries | all(. as $kv | ($svc.dimensions[$kv.key] // null) == $kv.value))
+ )]')
+ SERVER_COUNT=$(echo "$SERVER_SERVICES" | jq 'length')
+
+ if [ "$SERVER_COUNT" -eq 0 ]; then
+ echo "ERROR: No active RDS server found in ${ENTITY_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2
+ echo " Create an rds-postgres-server service with matching dimensions first." >&2
+ exit 1
+ elif [ "$SERVER_COUNT" -gt 1 ]; then
+ echo "ERROR: Multiple RDS servers found in ${ENTITY_NRN} matching dimensions: ${SERVICE_DIMENSIONS}" >&2
+ echo " Available options:" >&2
+ echo "$SERVER_SERVICES" | jq -r '.[] | " - \(.id) \(.name) (\(.attributes.hostname))"' >&2
+ exit 1
+ fi
+
+ SERVER_SERVICE_ID=$(echo "$SERVER_SERVICES" | jq -r '.[0].id')
+ echo "Auto-discovered server: ${SERVER_SERVICE_ID} ($(echo "$SERVER_SERVICES" | jq -r '.[0].name'))"
+
+ SERVER_JSON=$(np service read --id "$SERVER_SERVICE_ID" --format json)
+ DB_HOST=$(echo "$SERVER_JSON" | jq -r '.attributes.hostname // ""')
+ DB_PORT=$(echo "$SERVER_JSON" | jq -r '.attributes.port // "5432"')
+ MASTER_SECRET_ARN=$(echo "$SERVER_JSON" | jq -r '.attributes.master_secret_arn // ""')
+
+ if [ -z "$DB_HOST" ]; then
+ echo "ERROR: Server ${SERVER_SERVICE_ID} has no hostname attribute." >&2
+ echo " Has the rds-postgres-server been created successfully?" >&2
+ exit 1
+ fi
+
+ # Derive DB name and username from application_id
+ APPLICATION_ID=$(echo "$CONTEXT" | jq -r '.tags.application_id // ""')
+ if [ -z "$APPLICATION_ID" ]; then
+ echo "ERROR: Could not extract application_id from context tags." >&2
+ exit 1
+ fi
+
+ DB_NAME_VAL="app_${APPLICATION_ID}"
+ DB_USERNAME_VAL="app_${APPLICATION_ID}"
+ echo "Database: ${DB_NAME_VAL}, Username: ${DB_USERNAME_VAL}"
+fi
+
+# --- Retrieve master credentials from Secrets Manager ----------------------
+
+echo "Retrieving master credentials from Secrets Manager..."
+MASTER_CREDS=$(aws secretsmanager get-secret-value \
+ --secret-id "$MASTER_SECRET_ARN" \
+ --query 'SecretString' \
+ --output text)
+
+MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username')
+MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password')
+
+# --- Prepare working directory and sensitive vars --------------------------
+
+mkdir -p "$OUTPUT_DIR"
+cat > "$OUTPUT_DIR/sensitive.auto.tfvars" </dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+REGION=$(yaml_value "region" "us-east-1" "$VALUES")
+AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES")
+
+if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then
+ export AWS_PROFILE="${AWS_PROFILE_VAL}"
+fi
+
+if [ "${LINK_NEVER_CREATED:-}" = "true" ]; then
+ echo "Link was never created successfully — skipping permissions context setup."
+ exit 0
+fi
+
+DB_HOST="${SERVER_HOSTNAME:-}"
+DB_PORT="${SERVER_PORT:-5432}"
+MASTER_SECRET_ARN="${SERVER_MASTER_SECRET_ARN:-}"
+
+if [ -z "$DB_HOST" ]; then
+ echo "ERROR: SERVER_HOSTNAME not set. Did build_context run successfully?" >&2
+ exit 1
+fi
+
+if [ -z "$MASTER_SECRET_ARN" ]; then
+ echo "ERROR: SERVER_MASTER_SECRET_ARN not set. Did build_context run successfully?" >&2
+ exit 1
+fi
+
+if [ -z "${DB_NAME:-}" ] || [ -z "${DB_USERNAME:-}" ]; then
+ echo "ERROR: DB_NAME or DB_USERNAME not set. Has the service been created successfully?" >&2
+ exit 1
+fi
+
+echo "Database: ${DB_NAME}, Username: ${DB_USERNAME}"
+
+# --- Retrieve master credentials from Secrets Manager ----------------------
+
+echo "Retrieving master credentials from Secrets Manager..."
+MASTER_CREDS=$(aws secretsmanager get-secret-value \
+ --secret-id "$MASTER_SECRET_ARN" \
+ --query 'SecretString' \
+ --output text)
+
+MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username')
+MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password')
+
+# --- Prepare link-specific working directory --------------------------------
+
+export OUTPUT_DIR="/tmp/np-link-${LINK_ID}"
+mkdir -p "$OUTPUT_DIR"
+
+# Write sensitive master password to auto.tfvars to keep it out of the process list.
+cat > "$OUTPUT_DIR/sensitive.auto.tfvars" <)
+# ---------------------------------------------------------------------------
+
+yaml_value() {
+ local key="$1" default="$2" file="$3"
+ local val
+ val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+REGION=$(yaml_value "region" "us-east-1" "$VALUES")
+
+if [ -z "${TFSTATE_BUCKET:-}" ]; then
+ echo "TFSTATE_BUCKET is not set, skipping bucket cleanup."
+ exit 0
+fi
+
+if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then
+ echo "Bucket ${TFSTATE_BUCKET} does not exist, nothing to delete."
+ exit 0
+fi
+
+delete_objects() {
+ local query="$1"
+ local label="$2"
+ local objects
+ local rc=0
+
+ objects=$(aws s3api list-object-versions \
+ --bucket "$TFSTATE_BUCKET" \
+ --region "$REGION" \
+ --query "${query}" \
+ --output json) || rc=$?
+
+ if [ "$rc" -ne 0 ]; then
+ echo "WARNING: list-object-versions failed (exit $rc) for ${label}, skipping."
+ return 0
+ fi
+
+ if [ "$objects" = "null" ] || [ -z "$objects" ] || [ "$objects" = "[]" ]; then
+ echo "No ${label} to delete."
+ return 0
+ fi
+
+ echo "Deleting ${label}..."
+ local tmpfile
+ tmpfile=$(mktemp)
+ printf '{"Objects": %s, "Quiet": true}' "$objects" > "$tmpfile"
+ aws s3api delete-objects \
+ --bucket "$TFSTATE_BUCKET" \
+ --region "$REGION" \
+ --delete "file://${tmpfile}"
+ rm -f "$tmpfile"
+}
+
+delete_objects 'Versions[].{Key:Key,VersionId:VersionId}' "object versions"
+delete_objects 'DeleteMarkers[].{Key:Key,VersionId:VersionId}' "delete markers"
+
+echo "Deleting bucket ${TFSTATE_BUCKET}..."
+aws s3api delete-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION"
+echo "Bucket ${TFSTATE_BUCKET} deleted."
diff --git a/rds-postgres-db/scripts/aws/do_tofu b/rds-postgres-db/scripts/aws/do_tofu
new file mode 100755
index 0000000..b2d47a1
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/do_tofu
@@ -0,0 +1,91 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# do_tofu — Copies the Terraform module to OUTPUT_DIR and executes tofu.
+#
+# Expected environment variables (set by build_context or build_permissions_context):
+# OUTPUT_DIR — working directory for this execution
+# TOFU_MODULE_DIR — path to the Terraform module (deployment/ or permissions/)
+# TOFU_INIT_VARIABLES — -backend-config= flags for tofu init
+# TOFU_VARIABLES — -var= flags for tofu apply/destroy
+#
+# Set by the workflow YAML configuration block:
+# TOFU_ACTION — "apply" or "destroy"
+# ---------------------------------------------------------------------------
+
+TOFU_ACTION="${TOFU_ACTION:-apply}"
+
+if [ "${LINK_NEVER_CREATED:-}" = "true" ] || [ "${SETUP_SKIPPED:-}" = "true" ]; then
+ echo "Skipping tofu ${TOFU_ACTION} — setup was not completed."
+ exit 0
+fi
+
+if [ -z "${TOFU_MODULE_DIR:-}" ]; then
+ echo "TOFU_MODULE_DIR not set — skipping tofu ${TOFU_ACTION}."
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# Ensure tofu is available. On agents where tofu is not pre-installed, download
+# and cache the binary in /tmp/np-tofu-bin so it is only fetched once per pod
+# lifetime (the directory survives across actions but is cleared on pod restart).
+# ---------------------------------------------------------------------------
+if ! command -v tofu &>/dev/null; then
+ TOFU_VERSION="1.9.0"
+ TOFU_BIN_DIR="/tmp/np-tofu-bin"
+ TOFU_BIN="$TOFU_BIN_DIR/tofu"
+
+ if [ ! -f "$TOFU_BIN" ]; then
+ echo "tofu not found in PATH, installing v${TOFU_VERSION} to ${TOFU_BIN_DIR}..."
+ mkdir -p "$TOFU_BIN_DIR"
+ curl -fsSL \
+ "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" \
+ -o /tmp/tofu.zip
+ unzip -qo /tmp/tofu.zip tofu -d "$TOFU_BIN_DIR"
+ chmod +x "$TOFU_BIN"
+ rm -f /tmp/tofu.zip
+ echo "tofu installed: $("$TOFU_BIN" version | head -1)"
+ else
+ echo "Using cached tofu at ${TOFU_BIN}"
+ fi
+
+ export PATH="$TOFU_BIN_DIR:$PATH"
+fi
+
+cd "$OUTPUT_DIR"
+
+# Copy module files into the working directory.
+# Note: sensitive.auto.tfvars (if written by build_permissions_context) only
+# exists in OUTPUT_DIR, not in TOFU_MODULE_DIR, so cp will not overwrite it.
+cp -r "$TOFU_MODULE_DIR"/* .
+
+echo "Running: tofu init"
+# shellcheck disable=SC2086
+tofu init $TOFU_INIT_VARIABLES
+
+TOFU_TARGET_FLAGS=""
+if [ -n "${TOFU_TARGETS:-}" ]; then
+ IFS=',' read -ra _TARGETS <<< "$TOFU_TARGETS"
+ for target in "${_TARGETS[@]}"; do
+ TOFU_TARGET_FLAGS="$TOFU_TARGET_FLAGS -target=$target"
+ done
+fi
+
+# ---------------------------------------------------------------------------
+# Pre-import: if TOFU_IMPORT_DB_NAME is set, try to import the database into
+# state before apply. This handles re-links where the DB already exists in
+# PostgreSQL but not in the current tfstate. Failures are silently ignored
+# (the database simply doesn't exist yet and will be created by apply).
+# ---------------------------------------------------------------------------
+if [ "${TOFU_ACTION}" = "apply" ] && [ -n "${TOFU_IMPORT_DB_NAME:-}" ]; then
+ echo "Attempting pre-import of postgresql_database.app (${TOFU_IMPORT_DB_NAME})..."
+ # shellcheck disable=SC2086
+ tofu import $TOFU_VARIABLES "postgresql_database.app" "$TOFU_IMPORT_DB_NAME" 2>&1 \
+ && echo "Pre-import succeeded (re-link: database already existed)" \
+ || echo "Pre-import skipped (first link: database will be created)"
+fi
+
+echo "Running: tofu $TOFU_ACTION"
+# shellcheck disable=SC2086
+tofu "$TOFU_ACTION" -auto-approve $TOFU_VARIABLES $TOFU_TARGET_FLAGS
diff --git a/rds-postgres-db/scripts/aws/reassign_owned b/rds-postgres-db/scripts/aws/reassign_owned
new file mode 100755
index 0000000..fff4fd0
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/reassign_owned
@@ -0,0 +1,50 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# reassign_owned — Before dropping the app role (service delete), transfers
+# ownership of all objects owned by the app user to the master user.
+#
+# PostgreSQL refuses to DROP ROLE when the role still owns objects (tables,
+# sequences, etc. created by app migrations). REASSIGN OWNED transfers
+# ownership to master so the role can be dropped cleanly.
+#
+# Requires: SERVER_HOSTNAME, SERVER_PORT, SERVER_MASTER_SECRET_ARN,
+# DB_NAME, DB_USERNAME (all exported by build_context)
+# ---------------------------------------------------------------------------
+
+if [ -z "${DB_NAME:-}" ] || [ -z "${DB_USERNAME:-}" ]; then
+ echo "DB_NAME or DB_USERNAME not set — skipping ownership reassignment."
+ exit 0
+fi
+
+echo "Reassigning objects owned by ${DB_USERNAME} in database ${DB_NAME}..."
+
+MASTER_CREDS=$(aws secretsmanager get-secret-value \
+ --secret-id "$SERVER_MASTER_SECRET_ARN" \
+ --query 'SecretString' \
+ --output text)
+
+MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username')
+MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password')
+DB_HOST="$SERVER_HOSTNAME"
+DB_PORT="${SERVER_PORT:-5432}"
+
+# Ensure psql is available. Agent pod runs Alpine Linux — use apk.
+if ! command -v psql &>/dev/null; then
+ echo "psql not found, installing postgresql-client via apk..."
+ apk add --no-cache postgresql-client -q
+fi
+
+PGPASSWORD="$MASTER_PASS" psql \
+ -h "$DB_HOST" \
+ -p "$DB_PORT" \
+ -U "$MASTER_USER" \
+ -d "$DB_NAME" \
+ -v ON_ERROR_STOP=on \
+ -c "GRANT \"${DB_USERNAME}\" TO \"${MASTER_USER}\";" \
+ -c "REASSIGN OWNED BY \"${DB_USERNAME}\" TO \"${MASTER_USER}\";" \
+ -c "DROP OWNED BY \"${DB_USERNAME}\";" \
+ -c "REVOKE \"${DB_USERNAME}\" FROM \"${MASTER_USER}\";"
+
+echo "Ownership reassignment complete."
diff --git a/rds-postgres-db/scripts/aws/write_link_outputs b/rds-postgres-db/scripts/aws/write_link_outputs
new file mode 100755
index 0000000..4495670
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/write_link_outputs
@@ -0,0 +1,58 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# write_link_outputs — Reads credentials from the service attributes and
+# copies them to the NP link attributes via the API.
+#
+# Credentials (hostname, port, username, password, database_name) live at
+# service level. This script mirrors them to the link so that Nullplatform
+# can inject them as environment variables into the application.
+#
+# hostname, port, username, database_name (export: true) become plain env vars.
+# password (export: {secret: true}) becomes a secret env var.
+# ---------------------------------------------------------------------------
+
+LINK_ID_VAL=$(echo "$CONTEXT" | jq -r '.link.id')
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+
+echo "Reading service ${SERVICE_ID} credentials for link ${LINK_ID_VAL}..."
+SERVICE_JSON=$(np service read --id "$SERVICE_ID" --format json)
+
+HOSTNAME=$(echo "$SERVICE_JSON" | jq -r '.attributes.hostname // ""')
+PORT=$(echo "$SERVICE_JSON" | jq -r '.attributes.port // "5432" | tostring')
+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 // ""')
+
+if [ -z "$USERNAME" ]; then
+ echo "WARNING: No username found in service attributes. Skipping link attribute update."
+ exit 0
+fi
+
+ATTRS=$(jq -n \
+ --arg hostname "$HOSTNAME" \
+ --arg port "$PORT" \
+ --arg username "$USERNAME" \
+ --arg password "$PASSWORD" \
+ --arg database_name "$DATABASE_NAME" \
+ --arg master_secret_arn "$MASTER_SECRET_ARN" \
+ '{
+ hostname: $hostname,
+ port: ($port | tonumber),
+ username: $username,
+ password: $password,
+ database_name: $database_name,
+ master_secret_arn: $master_secret_arn
+ }')
+
+echo "Updating link ${LINK_ID_VAL} attributes:"
+echo " hostname: $HOSTNAME"
+echo " port: $PORT"
+echo " username: $USERNAME"
+echo " database_name: $DATABASE_NAME"
+echo " password: ****"
+
+np link patch --id "$LINK_ID_VAL" --body "{\"attributes\": $ATTRS}"
+echo "Link attributes updated successfully."
diff --git a/rds-postgres-db/scripts/aws/write_service_outputs b/rds-postgres-db/scripts/aws/write_service_outputs
new file mode 100755
index 0000000..c7814c2
--- /dev/null
+++ b/rds-postgres-db/scripts/aws/write_service_outputs
@@ -0,0 +1,56 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# write_service_outputs — Reads Terraform outputs from the db_setup module
+# and writes them to the NP service attributes via the API.
+#
+# hostname, port, username, database_name, master_secret_arn are stored on
+# the service. password is stored as a plain attribute so that link/unlink
+# workflows can copy it to link attributes for app env var injection.
+# ---------------------------------------------------------------------------
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+
+cd "$OUTPUT_DIR"
+
+echo "Reading Terraform outputs for service ${SERVICE_ID}..."
+
+ALL_OUTPUTS=$(tofu output -json 2>/dev/null || echo "{}")
+HOSTNAME=$(echo "$ALL_OUTPUTS" | jq -r '.hostname.value // ""')
+PORT=$(echo "$ALL_OUTPUTS" | jq -r '.port.value // "" | tostring')
+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 // ""')
+
+if [ -z "$HOSTNAME" ]; then
+ echo "WARNING: No hostname output found. Skipping service attribute update."
+ exit 0
+fi
+
+ATTRS=$(jq -n \
+ --arg hostname "$HOSTNAME" \
+ --arg port "$PORT" \
+ --arg username "$USERNAME" \
+ --arg password "$PASSWORD" \
+ --arg database_name "$DATABASE_NAME" \
+ --arg master_secret_arn "$MASTER_SECRET_ARN" \
+ '{
+ hostname: $hostname,
+ port: ($port | tonumber),
+ username: $username,
+ password: $password,
+ database_name: $database_name,
+ master_secret_arn: $master_secret_arn
+ }')
+
+echo "Updating service ${SERVICE_ID} attributes:"
+echo " hostname: $HOSTNAME"
+echo " port: $PORT"
+echo " username: $USERNAME"
+echo " database_name: $DATABASE_NAME"
+echo " password: ****"
+
+np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}"
+echo "Service attributes updated successfully."
diff --git a/rds-postgres-db/specs/install/README.md b/rds-postgres-db/specs/install/README.md
new file mode 100644
index 0000000..67ec62a
--- /dev/null
+++ b/rds-postgres-db/specs/install/README.md
@@ -0,0 +1,56 @@
+# Install — registering the rds-postgres-db service
+
+This directory holds the reference OpenTofu/Terraform used to **install**
+rds-postgres-db on a nullplatform account: registering its service
+specification, link specification, and agent association (notification
+channel) so `np service create` starts routing actions to an agent.
+
+This is separate from `../requirements/aws`, which provisions the AWS
+AssumeRole IAM role/policies the *agent* needs to operate the service — see
+that module's README and the "AssumeRole Setup Guide" in the top-level
+[`README.md`](../../README.md) for that half of the setup.
+
+Unlike `rds-postgres-server`'s install, this one does **not** register the
+`aws-iam-configuration` provider (the AssumeRole target). That provider is
+account-scoped and only additive-by-recreation — a second, independent
+registration at the same account NRN would make
+`scripts/aws/assume_role_step`'s lookup nondeterministic between the two.
+Instead, pass this service's permissions role ARN (the `permissions_role_arn`
+output of `../requirements/aws`) as `rds_postgres_db_role_arn` to
+[`rds-postgres-server`'s install](../../rds-postgres-server/specs/install/README.md),
+which folds both selectors into a single provider.
+
+## Layout
+
+```
+install/
+├── README.md (this file)
+└── aws/ Working example
+ ├── main.tf
+ ├── variables.tf
+ ├── outputs.tf
+ └── terraform.tfvars.example
+```
+
+## Using the example
+
+```bash
+cp -r rds-postgres-db/specs/install/aws /path/to/your/infra/rds-postgres-db
+cd /path/to/your/infra/rds-postgres-db
+cp terraform.tfvars.example terraform.tfvars
+$EDITOR terraform.tfvars
+
+tofu init
+tofu apply
+```
+
+`tags_selectors` must match the tag selectors of the agent(s) that should
+pick up rds-postgres-db actions (the same selectors passed as
+`tags_selectors` to the `nullplatform/agent` tofu-module).
+
+Run this once per nullplatform namespace, alongside the matching
+rds-postgres-server install (see that service's
+[`specs/install/README.md`](../../rds-postgres-server/specs/install/README.md)).
+It only registers the service with the platform — it does not create any
+AWS infrastructure by itself (that happens per-instance, at `create` time,
+via `deployment/` and the AssumeRole role from `requirements/aws`).
diff --git a/rds-postgres-db/specs/install/aws/.terraform.lock.hcl b/rds-postgres-db/specs/install/aws/.terraform.lock.hcl
new file mode 100644
index 0000000..95ac674
--- /dev/null
+++ b/rds-postgres-db/specs/install/aws/.terraform.lock.hcl
@@ -0,0 +1,47 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/http" {
+ version = "3.6.0"
+ constraints = "~> 3.0"
+ hashes = [
+ "h1:5oU7Zm+2gAVGmxqtJ9E8uTudUkYy/DEn/y3IWphdv4k=",
+ "zh:0f719fa5426bc883e9fa6abf7f6498e48025edafbc29015e2f5c028f1cca3b9d",
+ "zh:1b4d7dafefd6c61764b2f9ed6943ceb9a200dee3590d18747e3a5f6b20ce85e0",
+ "zh:1d23a712984866d29f7b07028a4e99c783c71f1a5dddf08bc3d4e7da9d91a1fa",
+ "zh:257d23d58c3bb024b6bc8eb88736eaf912e934ad47c639d0c3c742bddda849a1",
+ "zh:479860e1a5468f5e04013b9364c9496d7ed0804bf9a1acd8e07558d57609993d",
+ "zh:4cb5e681bf599b411b27c4a2c4066a5fb2ed79aaa3a1a3cb5a30002fec062ce9",
+ "zh:4fb35c3f643dae9f3670d719397a415f815a0b95f8ed7bd8a72f27a94ba78092",
+ "zh:59ba40825ab38db5b4a0989a2db0df35cc15d8984f898176011ba352f27d77b7",
+ "zh:61fc1252eb88088638f4c69ea4e2171cde2e5089fa632ac1e943b13787348f73",
+ "zh:7c5d6dd5f7cbc460e95d368be35c29b4e0402069b8912dbd5d1cd7fa9acef216",
+ "zh:7f76d756240d4284642f359ad470226e5378670239aadc366ef54d9d914d4d2e",
+ "zh:8133ad0814098177e0d067c816ccf1bf48bbadacd18f6f2c808c90447505723b",
+ "zh:c93be06269bb728f1968f8c50506de56c887017ac1d6e4be1f925651d8437eb6",
+ "zh:ef47b78a10a82e6cf53344a6a85a94041c28286c10a70541c564d762f1cfede0",
+ "zh:f5796a53a74999135bd9087aff50fddda59129d09b2f9b1902ff8c0c1e047e48",
+ ]
+}
+
+provider "registry.opentofu.org/nullplatform/nullplatform" {
+ version = "0.0.96"
+ constraints = ">= 0.0.86"
+ hashes = [
+ "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=",
+ "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960",
+ "zh:00c482219d99e53b6802f2f7a2b1581ddcae88911fcbf09a7a26b18e62cad846",
+ "zh:19114e9acae41bbb79f5308a754492ace94c2df3ee21e0c99de51c5dcab3f775",
+ "zh:2568f82d9e587f6253d5e8bd9b9cf20ad7cfb403488af0928ee6dfd116f6635f",
+ "zh:35c046dabe79ede321b6d5a4fd37564a2cfd00f8d49be01ff17dbdb86fd847b0",
+ "zh:46b4300356f11726fd6675b6a7fe9c7293094f38ec1be5d6d5d659863eee3e8a",
+ "zh:46d5029dcdb6febd2c10b19da7b565c14915909da0e7576e33b56c4697c5bfe3",
+ "zh:46ffdfa96722e184b64bd04198170c91bf59976a5171aad5371b655481da346a",
+ "zh:91bc64b2f7da208e1f0d5a059026b6fb78d284e2f03ab15d1e7eb16cf707eed0",
+ "zh:ad1b94910840d6662ef21dea503998618e6ad5b0a340c63044bb5e913e926eeb",
+ "zh:bf3ecf8a22fdfab30f7eaac8b56bd321e5fd4e3a0a57fda15a3078c84e769a71",
+ "zh:e9bd846753e194c8fc2f2f3f53f15fd33f9fd56467963fd2766d1f713865bcad",
+ "zh:ecf36a184c1a01f9297e331d0b1671a99e3fbc179ceb2653efe0a74c61ec97ea",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
diff --git a/rds-postgres-db/specs/install/aws/main.tf b/rds-postgres-db/specs/install/aws/main.tf
new file mode 100644
index 0000000..dfcb38d
--- /dev/null
+++ b/rds-postgres-db/specs/install/aws/main.tf
@@ -0,0 +1,40 @@
+################################################################################
+# Install — registers the rds-postgres-db service definition and its
+# agent association (notification channel) on a nullplatform account.
+#
+# This is the platform-registration half of adopting the service; the
+# AWS AssumeRole IAM role/policies live in ../../requirements/aws and are
+# applied separately (see that module's README and the top-level
+# "AssumeRole Setup Guide" in ../../../README.md).
+################################################################################
+
+locals {
+ service_path = "rds-postgres-db"
+ available_links = ["connect"]
+ available_actions = []
+}
+
+module "service_definition" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1"
+
+ nrn = var.nrn
+ repository_org = var.repository_org
+ repository_name = var.repository_name
+ repository_branch = var.repository_branch
+ repository_token = var.repository_token
+ service_path = local.service_path
+ service_name = var.service_name
+ available_links = local.available_links
+ available_actions = local.available_actions
+}
+
+module "service_definition_agent_association" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1"
+
+ nrn = var.nrn
+ repository_service_spec_repo = "${var.repository_org}/${var.repository_name}"
+ service_path = local.service_path
+ service_specification_slug = module.service_definition.service_specification_slug
+ api_key = var.np_api_key
+ tags_selectors = var.tags_selectors
+}
diff --git a/rds-postgres-db/specs/install/aws/outputs.tf b/rds-postgres-db/specs/install/aws/outputs.tf
new file mode 100644
index 0000000..4c340cd
--- /dev/null
+++ b/rds-postgres-db/specs/install/aws/outputs.tf
@@ -0,0 +1,9 @@
+output "service_specification_id" {
+ description = "ID of the registered rds-postgres-db service specification."
+ value = module.service_definition.service_specification_id
+}
+
+output "service_specification_slug" {
+ description = "Slug of the registered rds-postgres-db service specification."
+ value = module.service_definition.service_specification_slug
+}
diff --git a/rds-postgres-db/specs/install/aws/terraform.tfvars.example b/rds-postgres-db/specs/install/aws/terraform.tfvars.example
new file mode 100644
index 0000000..b60961a
--- /dev/null
+++ b/rds-postgres-db/specs/install/aws/terraform.tfvars.example
@@ -0,0 +1,10 @@
+nrn = "" # namespace-level NRN, e.g. organization=:account=:namespace=
+np_api_key = ""
+
+tags_selectors = {
+ "environment" = ""
+}
+
+# repository_org = "nullplatform"
+# repository_name = "services-postgresql-rds"
+# repository_branch = "main"
diff --git a/rds-postgres-db/specs/install/aws/variables.tf b/rds-postgres-db/specs/install/aws/variables.tf
new file mode 100644
index 0000000..86057ff
--- /dev/null
+++ b/rds-postgres-db/specs/install/aws/variables.tf
@@ -0,0 +1,46 @@
+variable "nrn" {
+ description = "NullPlatform Resource Name (namespace-level, e.g. organization=:account=:namespace=) where the service definition is registered."
+ type = string
+}
+
+variable "np_api_key" {
+ description = "nullplatform API key used by the agent association to authenticate against the nullplatform API."
+ type = string
+ sensitive = true
+}
+
+variable "tags_selectors" {
+ description = "Agent tag selectors for the notification channel (must match the tags the target agent registers with)."
+ type = map(string)
+}
+
+variable "service_name" {
+ description = "Display name for the rds-postgres-db service in nullplatform."
+ type = string
+ default = "RDS Postgres DB"
+}
+
+variable "repository_org" {
+ description = "GitHub organization owning the services repository."
+ type = string
+ default = "nullplatform"
+}
+
+variable "repository_name" {
+ description = "Repository name containing the rds-postgres-db service spec templates."
+ type = string
+ default = "services-postgresql-rds"
+}
+
+variable "repository_branch" {
+ description = "Branch of the services repository to register the service spec/links/entrypoint from."
+ type = string
+ default = "main"
+}
+
+variable "repository_token" {
+ description = "Access token for private repositories. Unnecessary for a public repository."
+ type = string
+ default = null
+ sensitive = true
+}
diff --git a/rds-postgres-db/specs/links/connect.json.tpl b/rds-postgres-db/specs/links/connect.json.tpl
new file mode 100644
index 0000000..8bab9c7
--- /dev/null
+++ b/rds-postgres-db/specs/links/connect.json.tpl
@@ -0,0 +1,84 @@
+{
+ "name": "Connect",
+ "slug": "connect",
+ "unique": false,
+ "assignable_to": "any",
+ "use_default_actions": true,
+ "selectors": {
+ "category": "Database",
+ "imported": false,
+ "provider": "AWS",
+ "sub_category": "Relational Database"
+ },
+ "attributes": {
+ "schema": {
+ "type": "object",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "required": [],
+ "properties": {
+ "access_level": {
+ "enum": ["read", "write", "read-write"],
+ "type": "string",
+ "title": "Access Level",
+ "default": "read-write",
+ "editableOn": ["create", "update"],
+ "description": "Permission level: read (SELECT), write (INSERT/UPDATE/DELETE), read-write (both)",
+ "order": 1
+ },
+ "hostname": {
+ "type": "string",
+ "title": "Hostname",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS endpoint hostname",
+ "order": 2
+ },
+ "port": {
+ "type": "number",
+ "title": "Port",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS port",
+ "order": 3
+ },
+ "username": {
+ "type": "string",
+ "title": "DB Username",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database username",
+ "order": 4
+ },
+ "password": {
+ "type": "string",
+ "title": "DB Password",
+ "export": {"type": "environment_variable", "secret": true},
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database password (auto-generated at service create)",
+ "order": 5
+ },
+ "database_name": {
+ "type": "string",
+ "title": "Database",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database name",
+ "order": 6
+ },
+ "master_secret_arn": {
+ "type": "string",
+ "export": false,
+ "visibleOn": [],
+ "editableOn": [],
+ "description": "ARN of the Secrets Manager secret for master credentials (internal use)"
+ }
+ }
+ },
+ "values": {}
+ }
+}
diff --git a/rds-postgres-db/specs/requirements/aws/.terraform.lock.hcl b/rds-postgres-db/specs/requirements/aws/.terraform.lock.hcl
new file mode 100644
index 0000000..f8d1ed5
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/.terraform.lock.hcl
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.53.0"
+ constraints = ">= 5.0.0"
+ hashes = [
+ "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=",
+ "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c",
+ "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8",
+ "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69",
+ "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87",
+ "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f",
+ "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc",
+ "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760",
+ "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147",
+ "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b",
+ "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b",
+ "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be",
+ "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46",
+ "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138",
+ "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc",
+ "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6",
+ ]
+}
diff --git a/rds-postgres-db/specs/requirements/aws/data.tf b/rds-postgres-db/specs/requirements/aws/data.tf
new file mode 100644
index 0000000..8fc4b38
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/data.tf
@@ -0,0 +1 @@
+data "aws_caller_identity" "current" {}
diff --git a/rds-postgres-db/specs/requirements/aws/locals.tf b/rds-postgres-db/specs/requirements/aws/locals.tf
new file mode 100644
index 0000000..3a3e39e
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/locals.tf
@@ -0,0 +1,13 @@
+locals {
+ iam_module_name = "requirements-rds-postgres-db"
+ iam_create = var.iam_create_role
+
+ role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role"
+ policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform-${var.cluster_name}"
+ agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role"
+
+ iam_default_tags = merge(var.iam_resource_tags_json, {
+ ManagedBy = "rds-postgres-db"
+ Module = local.iam_module_name
+ })
+}
diff --git a/rds-postgres-db/specs/requirements/aws/main.tf b/rds-postgres-db/specs/requirements/aws/main.tf
new file mode 100644
index 0000000..879a052
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/main.tf
@@ -0,0 +1,93 @@
+################################################################################
+# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole)
+################################################################################
+
+resource "aws_iam_role" "nullplatform_rds_postgres_db" {
+ count = local.iam_create ? 1 : 0
+
+ name = local.role_name
+ description = "Permissions role assumed by the nullplatform agent role for rds-postgres-db in cluster ${var.cluster_name}"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) }
+ Action = "sts:AssumeRole"
+ }]
+ })
+
+ tags = local.iam_default_tags
+}
+
+################################################################################
+# Secrets Manager IAM policy — read-only access to the RDS master password
+################################################################################
+
+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"
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Action = "secretsmanager:GetSecretValue"
+ Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*"
+ }]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_db[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn
+}
+
+################################################################################
+# S3 IAM policy (per-service tfstate buckets: np-service-)
+################################################################################
+
+# Grant permissions to manage the per-link S3 bucket used to store tofu state.
+# build_context creates its own np-service- bucket, same as
+# rds-postgres-server — confirmed missing via a live test (AccessDenied on
+# s3:CreateBucket while assuming this role).
+resource "aws_iam_policy" "nullplatform_rds_postgres_db_s3_policy" {
+ count = local.iam_create ? 1 : 0
+
+ name = "${local.policies_name_prefix}-rds-postgres-db-s3-policy"
+ description = "Policy for managing per-service S3 tfstate buckets (np-service-*)"
+
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "s3:CreateBucket",
+ "s3:HeadBucket",
+ "s3:PutBucketVersioning",
+ "s3:ListBucket",
+ "s3:ListBucketVersions",
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject",
+ "s3:DeleteObjectVersion",
+ "s3:DeleteBucket"
+ ],
+ "Resource" : [
+ "arn:aws:s3:::np-service-*",
+ "arn:aws:s3:::np-service-*/*"
+ ]
+ }
+ ]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "rds_postgres_db_s3" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_db[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_s3_policy[0].arn
+}
diff --git a/rds-postgres-db/specs/requirements/aws/output.tf b/rds-postgres-db/specs/requirements/aws/output.tf
new file mode 100644
index 0000000..3c192b9
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/output.tf
@@ -0,0 +1,24 @@
+output "permissions_role_arn" {
+ description = "ARN of the rds-postgres-db permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)."
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].arn : ""
+}
+
+output "permissions_role_name" {
+ description = "Name of the rds-postgres-db permissions role"
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].name : ""
+}
+
+output "permissions_role_id" {
+ description = "ID of the rds-postgres-db permissions role"
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].id : ""
+}
+
+output "secretsmanager_policy_arn" {
+ description = "ARN of the Secrets Manager read policy"
+ value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : ""
+}
+
+output "s3_policy_arn" {
+ description = "ARN of the per-service tfstate S3 policy"
+ value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_s3_policy[0].arn : ""
+}
diff --git a/rds-postgres-db/specs/requirements/aws/variables.tf b/rds-postgres-db/specs/requirements/aws/variables.tf
new file mode 100644
index 0000000..54b9784
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/variables.tf
@@ -0,0 +1,50 @@
+variable "cluster_name" {
+ description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy name, and the default agent role ARN."
+ type = string
+}
+
+variable "agent_role_arn" {
+ description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn))
+ error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/"
+ }
+}
+
+variable "additional_agent_role_arns" {
+ description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))])
+ error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/"
+ }
+}
+
+variable "role_name" {
+ description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-db-role."
+ type = string
+ default = ""
+}
+
+variable "policies_name_prefix" {
+ description = "Override for the IAM policy name prefix. Defaults to nullplatform-{cluster_name}."
+ type = string
+ default = ""
+}
+
+variable "iam_create_role" {
+ description = "Whether to create the permissions role and its policy. When false, the module produces no resources."
+ type = bool
+ default = true
+}
+
+variable "iam_resource_tags_json" {
+ description = "Tags to apply to IAM resources created by this module."
+ type = map(string)
+ default = {}
+}
diff --git a/rds-postgres-db/specs/requirements/aws/versions.tf b/rds-postgres-db/specs/requirements/aws/versions.tf
new file mode 100644
index 0000000..da078fc
--- /dev/null
+++ b/rds-postgres-db/specs/requirements/aws/versions.tf
@@ -0,0 +1,8 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ }
+ }
+}
diff --git a/rds-postgres-db/specs/service-spec.json.tpl b/rds-postgres-db/specs/service-spec.json.tpl
new file mode 100644
index 0000000..09aca5e
--- /dev/null
+++ b/rds-postgres-db/specs/service-spec.json.tpl
@@ -0,0 +1,72 @@
+{
+ "name": "RDS PostgreSQL DB",
+ "slug": "rds-postgres-db",
+ "type": "dependency",
+ "unique": false,
+ "assignable_to": "any",
+ "use_default_actions": true,
+ "available_links": ["connect"],
+ "selectors": {
+ "category": "Database",
+ "imported": false,
+ "provider": "AWS",
+ "sub_category": "Relational Database"
+ },
+ "attributes": {
+ "schema": {
+ "type": "object",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "required": [],
+ "properties": {
+ "hostname": {
+ "type": "string",
+ "title": "Hostname",
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS endpoint hostname",
+ "order": 1
+ },
+ "port": {
+ "type": "number",
+ "title": "Port",
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS port",
+ "order": 2
+ },
+ "username": {
+ "type": "string",
+ "title": "DB Username",
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database username",
+ "order": 3
+ },
+ "password": {
+ "type": "string",
+ "title": "DB Password",
+ "visibleOn": [],
+ "editableOn": [],
+ "description": "Database password (internal use — exposed to apps via link)",
+ "order": 4
+ },
+ "database_name": {
+ "type": "string",
+ "title": "Database",
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database name",
+ "order": 5
+ },
+ "master_secret_arn": {
+ "type": "string",
+ "visibleOn": [],
+ "editableOn": [],
+ "description": "ARN of the Secrets Manager secret for master credentials (internal use)",
+ "order": 6
+ }
+ }
+ },
+ "values": {}
+ }
+}
diff --git a/rds-postgres-db/values.yaml b/rds-postgres-db/values.yaml
new file mode 100644
index 0000000..74e41d1
--- /dev/null
+++ b/rds-postgres-db/values.yaml
@@ -0,0 +1,24 @@
+# RDS PostgreSQL Service — Static Configuration
+# These values are not exposed in the NP UI. They configure the execution
+# environment for the agent running this service.
+#
+# NOTE: In scripts, $VALUES is a FILE PATH (set by np service workflow exec --values).
+# It is NOT JSON content. Read values with yaml_value() from build_context.
+
+# AWS region where RDS instances are created
+region: us-east-1
+
+# VPC ID where RDS instances will be deployed
+vpc_id: "vpc-0a5dfe8e463dee15d"
+
+# Named AWS profile for local testing (e.g. SSO profile with RDS access)
+# If set and AWS_PROFILE is not already in the environment, build_context
+# will export it so Terraform and AWS CLI use the correct credentials.
+# Run "aws sso login --profile " before starting np-agent locally.
+aws_profile: ""
+
+# Provider categories the platform must resolve into CONTEXT.providers before
+# running workflow steps. identity-access-control is required by
+# scripts/aws/assume_role_step to look up the AssumeRole target ARN.
+provider_categories:
+ - identity-access-control
diff --git a/rds-postgres-db/workflows/aws/create.yaml b/rds-postgres-db/workflows/aws/create.yaml
new file mode 100644
index 0000000..3c41373
--- /dev/null
+++ b/rds-postgres-db/workflows/aws/create.yaml
@@ -0,0 +1,59 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: OUTPUT_DIR
+ type: environment
+ - name: SERVER_HOSTNAME
+ type: environment
+ - name: SERVER_PORT
+ type: environment
+ - name: SERVER_MASTER_SECRET_ARN
+ type: environment
+ - name: DB_NAME
+ type: environment
+ - name: DB_USERNAME
+ type: environment
+
+ - name: build db setup context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_db_setup_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+ - name: TOFU_IMPORT_DB_NAME
+ type: environment
+ - name: SETUP_SKIPPED
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: apply
+
+ - name: write service outputs
+ type: script
+ file: $SERVICE_PATH/scripts/aws/write_service_outputs
diff --git a/rds-postgres-db/workflows/aws/delete.yaml b/rds-postgres-db/workflows/aws/delete.yaml
new file mode 100644
index 0000000..9c8b2a8
--- /dev/null
+++ b/rds-postgres-db/workflows/aws/delete.yaml
@@ -0,0 +1,64 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: OUTPUT_DIR
+ type: environment
+ - name: SERVER_HOSTNAME
+ type: environment
+ - name: SERVER_PORT
+ type: environment
+ - name: SERVER_MASTER_SECRET_ARN
+ type: environment
+ - name: DB_NAME
+ type: environment
+ - name: DB_USERNAME
+ type: environment
+
+ - name: build db setup context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_db_setup_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+ - name: SETUP_SKIPPED
+ type: environment
+
+ - name: reassign owned objects
+ type: script
+ file: $SERVICE_PATH/scripts/aws/reassign_owned
+
+ - name: tofu destroy role
+ type: script
+ 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"
+
+ - name: cleanup tfstate bucket
+ type: script
+ file: $SERVICE_PATH/scripts/aws/delete_tfstate_bucket
diff --git a/rds-postgres-db/workflows/aws/link.yaml b/rds-postgres-db/workflows/aws/link.yaml
new file mode 100644
index 0000000..e062d5c
--- /dev/null
+++ b/rds-postgres-db/workflows/aws/link.yaml
@@ -0,0 +1,65 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: LINK_ID
+ type: environment
+ - name: LINK_NAME
+ type: environment
+ - name: SCOPE_ID
+ type: environment
+ - name: SCOPE_NRN
+ type: environment
+ - name: LINK_ACCESS_LEVEL
+ type: environment
+ - name: SERVER_HOSTNAME
+ type: environment
+ - name: SERVER_PORT
+ type: environment
+ - name: SERVER_MASTER_SECRET_ARN
+ type: environment
+ - name: DB_NAME
+ type: environment
+ - name: DB_USERNAME
+ type: environment
+
+ - name: build permissions context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_permissions_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: apply
+
+ - name: write link outputs
+ type: script
+ file: $SERVICE_PATH/scripts/aws/write_link_outputs
diff --git a/rds-postgres-db/workflows/aws/unlink.yaml b/rds-postgres-db/workflows/aws/unlink.yaml
new file mode 100644
index 0000000..8220074
--- /dev/null
+++ b/rds-postgres-db/workflows/aws/unlink.yaml
@@ -0,0 +1,66 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: LINK_ID
+ type: environment
+ - name: LINK_NAME
+ type: environment
+ - name: SCOPE_ID
+ type: environment
+ - name: SCOPE_NRN
+ type: environment
+ - name: LINK_ACCESS_LEVEL
+ type: environment
+ - name: SERVER_HOSTNAME
+ type: environment
+ - name: SERVER_PORT
+ type: environment
+ - name: SERVER_MASTER_SECRET_ARN
+ type: environment
+ - name: DB_NAME
+ type: environment
+ - name: DB_USERNAME
+ type: environment
+ - name: LINK_NEVER_CREATED
+ type: environment
+
+ - name: build permissions context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_permissions_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: destroy
+ # Destroy only grant resources. The database and user are preserved at
+ # service level — data survives unlink and access can be re-granted later.
+ TOFU_TARGETS: "postgresql_default_privileges.sequences,postgresql_grant.sequences,postgresql_default_privileges.tables,postgresql_grant.tables,postgresql_grant.schema_usage,postgresql_grant.connect"
diff --git a/rds-postgres-db/workflows/aws/update.yaml b/rds-postgres-db/workflows/aws/update.yaml
new file mode 100644
index 0000000..f6a5f6e
--- /dev/null
+++ b/rds-postgres-db/workflows/aws/update.yaml
@@ -0,0 +1,20 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: TFSTATE_BUCKET
+ type: environment
diff --git a/rds-postgres-server/README.md b/rds-postgres-server/README.md
new file mode 100644
index 0000000..a2124c7
--- /dev/null
+++ b/rds-postgres-server/README.md
@@ -0,0 +1,287 @@
+# rds-postgres-server
+
+A nullplatform dependency service that provisions and manages a shared **Amazon RDS PostgreSQL instance** on AWS. It acts as the infrastructure layer in a two-tier database architecture, creating the actual RDS instance that one or more [`rds-postgres-db`](../rds-postgres-db) services consume.
+
+## What It Does
+
+- Provisions an RDS PostgreSQL instance inside a VPC using Terraform (via OpenTofu)
+- Stores the master password in AWS Secrets Manager
+- Creates a dedicated security group allowing port 5432 within the VPC
+- Manages per-link databases and users: each link to an application creates a dedicated PostgreSQL database and user with scoped grants
+- Stores connection metadata in nullplatform service attributes so linked services can discover the endpoint
+
+## Architecture
+
+```
+nullplatform Application
+ │
+ │ link (creates DB + user)
+ ▼
+rds-postgres-server ──────► AWS RDS PostgreSQL Instance
+ (this service) │ └─ Security Group (port 5432, VPC-scoped)
+ │ │ └─ Secrets Manager (master password)
+ │ │ └─ S3 Bucket (Terraform state)
+ └─ per link:
+ postgresql_database.
+ postgresql_role.
+ postgresql_grant.*
+```
+
+## Nullplatform Integration
+
+This service integrates with nullplatform through:
+
+- **Dependency service type**: registered as a `dependency` service in nullplatform
+- **Provider resolution**: reads `account.region` and `vpc.id` from account-level nullplatform providers at creation time
+- **Service attributes**: writes RDS connection metadata back to nullplatform via `np service patch` after provisioning
+- **Link attributes**: writes per-link DB credentials to link attributes via `np link patch` so applications can consume them as environment variables
+- **Dimension matching**: supports nullplatform dimensions so multiple environments (e.g., `cluster: prod`, `cluster: staging`) can have isolated RDS instances
+
+### Service Attributes (written after create)
+
+| Attribute | Visibility | Description |
+|---|---|---|
+| `hostname` | exported | RDS endpoint hostname |
+| `port` | exported | RDS port (5432) |
+| `db_instance_identifier` | internal | AWS RDS resource identifier |
+| `master_secret_arn` | internal | Secrets Manager ARN for master credentials |
+
+### Link Attributes (written per link)
+
+| Attribute | Description |
+|---|---|
+| `username` | PostgreSQL user for this link |
+| `password` | PostgreSQL password (injected as secret) |
+| `database_name` | PostgreSQL database name for this link |
+| `hostname` | RDS endpoint hostname |
+| `port` | RDS port |
+
+## Configuration Parameters
+
+Exposed in the nullplatform UI when creating or updating the service:
+
+| Parameter | Type | Default | Allowed Values | Editable After Create |
+|---|---|---|---|---|
+| `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 |
+
+> `postgres_version` cannot be changed after creation because PostgreSQL major version upgrades require manual intervention and are not managed by this service.
+
+## Workflows
+
+| Workflow | Trigger | What It Does |
+|---|---|---|
+| `create` | Service created | Provisions RDS instance, security group, Secrets Manager secret, S3 tfstate bucket |
+| `update` | Service updated | Applies Terraform changes (instance class, storage) |
+| `delete` | Service deleted | Destroys RDS instance and all associated resources; **no final snapshot is taken** |
+| `link` | Application linked | Creates a PostgreSQL database + user with `CONNECT`, `USAGE`, and DML grants |
+| `unlink` | Application unlinked | Revokes grants only; database and user are **preserved** for data retention |
+
+## Infrastructure Resources Created
+
+| Resource | Description |
+|---|---|
+| `aws_db_instance` | The RDS PostgreSQL instance (gp3 storage, encrypted, no public access) |
+| `aws_db_subnet_group` | Subnet group using VPC subnets tagged `nullplatform/subnet-type=private` |
+| `aws_security_group` | Allows port 5432 ingress from within the VPC |
+| `aws_secretsmanager_secret` | Stores the master PostgreSQL password |
+| `aws_s3_bucket` | `np-service-` — versioned bucket for Terraform state |
+| `postgresql_database` | One per link — isolated database per application link |
+| `postgresql_role` | One per link — isolated PostgreSQL user per application link |
+
+## Requirements
+
+### nullplatform Prerequisites
+
+- The service itself must be registered on the nullplatform account first — see
+ [`specs/install/README.md`](specs/install/README.md) for the Terraform that
+ registers the service specification and agent association.
+- An active nullplatform account with the following providers configured for the target namespace/dimensions:
+ - **`aws-configuration`** (from `tofu-modules//nullplatform/cloud/aws/cloud`) — exposes `account.region`. `build_context` resolves this via `np provider list --nrn ` filtered by `stored_keys` containing `account.region`.
+ - **`aws-networking-configuration`** (from `tofu-modules//nullplatform/cloud/aws/vpc`) — exposes `vpc.id`, `vpc.subnets`, `vpc.security_groups`. Same lookup mechanism, filtered by `vpc.id`.
+- The VPC must have private subnets tagged with `nullplatform/subnet-type=private`.
+- For AssumeRole to work (not just fail open to agent credentials — see below): an **`aws-iam-configuration`** provider (from `tofu-modules//nullplatform/identity-access-control`) registered at the **account-level NRN** (or any ascendant of the service's NRN — resolution walks up the hierarchy).
+
+Example registering the `aws-configuration` and `aws-networking-configuration`
+providers (typically applied once per cluster/account, at the account-level
+NRN — no `:namespace=...`):
+
+```hcl
+module "aws_cloud_provider" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/cloud?ref="
+
+ nrn = "organization=:account="
+ domain_name = ""
+ hosted_private_zone_id = ""
+}
+
+module "vpc_provider" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/vpc?ref="
+
+ nrn = "organization=:account="
+ vpc_id = ""
+ vpc_subnets = ["", "", "..."]
+ vpc_security_groups = ["", "..."]
+}
+```
+
+`vpc_subnets`/`vpc_security_groups` don't need to be scoped down to only
+what this service uses — pass whatever the cluster's VPC provider already
+uses for other scopes/services (e.g. all node/pod subnets and the cluster
+security group). This service only reads `vpc.id` from this provider; the
+actual subnets it deploys into come separately from
+`data.aws_subnets.private` (filtered by the `nullplatform/subnet-type=private`
+tag, not from this provider's `vpc_subnets` list).
+
+### AWS IAM Permissions
+
+The agent executing this service needs the following IAM permissions (see `specs/requirements/aws/main.tf`):
+
+- **RDS**: `CreateDBInstance`, `DeleteDBInstance`, `ModifyDBInstance`, `DescribeDBInstances`, subnet group management, tagging
+- **EC2**: Security group management, `DescribeVpcs`, `DescribeSubnets`
+- **Secrets Manager**: Full lifecycle (`CreateSecret`, `DeleteSecret`, `GetSecretValue`, `PutSecretValue`, etc.)
+- **S3**: Full lifecycle on the `np-service-` bucket
+- **IAM**: `CreateServiceLinkedRole` (for RDS)
+
+The `requirements/` Terraform module creates a dedicated IAM role
+(`nullplatform--rds-postgres-server-role`) holding these
+policies, with a trust policy allowing the nullplatform agent role to
+`sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally
+`agent_role_arn` (defaults to `nullplatform--agent-role`),
+`role_name` (defaults to `nullplatform--rds-postgres-server-role`)
+and `policies_name_prefix` (defaults to `nullplatform-`) when
+applying it. Granting the agent itself permission to assume this role is
+handled separately, outside this module.
+
+### AssumeRole Setup Guide
+
+Three separate pieces must all be in place for the agent to actually assume
+`nullplatform--rds-postgres-server-role` at runtime — applying
+`requirements/` alone is not enough:
+
+1. **Apply `requirements/`** with `cluster_name` (and optionally
+ `agent_role_arn`) — creates the role and its trust policy (see above):
+ ```hcl
+ module "service_requirements_rds_postgres_server" {
+ source = "git::https://github.com/nullplatform/services-postgresql-rds.git//rds-postgres-server/specs/requirements/aws?ref="
+
+ cluster_name = ""
+ # agent_role_arn = "" # optional override; defaults to
+ # arn:aws:iam:::role/nullplatform--agent-role
+ }
+ ```
+ Read `module.service_requirements_rds_postgres_server.permissions_role_arn`
+ for the ARN needed in steps 2 and 3 below.
+2. **Grant the agent permission to assume it.** Not managed by
+ `requirements/` — add an inline (or managed) policy to the **agent's own**
+ IAM role:
+ ```json
+ {
+ "Effect": "Allow",
+ "Action": "sts:AssumeRole",
+ "Resource": "arn:aws:iam:::role/nullplatform--rds-postgres-server-role"
+ }
+ ```
+3. **Register the role as an `identity-access-control` provider** in
+ nullplatform, at the **account-level NRN**
+ (`organization=...:account=...` — no `:namespace=...`, matching where
+ `aws-configuration`/`aws-networking-configuration` are registered above),
+ with selector `rds-postgres-server`:
+ ```hcl
+ module "identity_access_control" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref="
+ nrn = "organization=:account="
+ attributes = {
+ iam_role_arns = {
+ arns = [{ selector = "rds-postgres-server", arn = "" }]
+ }
+ }
+ }
+ ```
+
+`scripts/aws/assume_role_step` resolves the role by querying
+`np provider list --categories identity-access-control` with the service's
+**full NRN as-is** (not stripped) — not by reading `CONTEXT.providers[...]`.
+`--categories` resolves up the NRN hierarchy, so it finds the provider
+registered at the account level above regardless of how deep the service's
+own NRN is. This was confirmed live: an earlier version of this script
+stripped the NRN to namespace level and did a plain `np provider list`
+filtered by `stored_keys`, which does **not** inherit providers from
+ascendant NRN levels — the account-level provider was never found and the
+step silently fell back to agent credentials. Separately, `CONTEXT.providers[...]`
+itself was also confirmed live to never populate on this platform version
+regardless of the `provider_categories` declared in `values.yaml` or the
+workflow YAMLs, so the lookup goes through the `np` CLI directly either way.
+
+The lookup also passes `--dimensions` (derived from `.service.dimensions` in
+`CONTEXT`, e.g. `cluster:prod`) so that if more than one
+`identity-access-control` provider is ever registered at the same NRN
+for different dimensions, `np` resolves the most-specific match instead
+of an arbitrary one being picked client-side. Today the setup above
+registers a single, dimension-less provider per account, so this is a
+no-op — it only matters if per-dimension AssumeRole roles are introduced
+later.
+
+**If any of the 3 steps is missing**, `assume_role_step` logs
+`assume_role=skipped (using agent credentials)` and the workflow proceeds
+under the **agent's own role** — which fails with `AccessDenied` on
+RDS/EC2/Secrets Manager/S3 calls unless the agent happens to have those
+permissions directly attached (the old, pre-AssumeRole model). This
+fail-open behavior is intentional (mirrors `nullplatform/scopes-static-files`),
+but it means a misconfigured AssumeRole setup fails *silently* as what looks
+like a permissions problem rather than a missing-provider problem — check
+the `assume role` step's log line first when debugging `AccessDenied`
+errors from later steps.
+
+### Networking Requirements
+
+`deployment/main.tf`'s RDS security group allows ingress on 5432 from
+**every CIDR block associated with the VPC**
+(`data.aws_vpc.main.cidr_block_associations`), not just the primary one.
+This matters because EKS clusters commonly add a **secondary CIDR block**
+for pod networking (e.g. primary `10.x.x.x` for nodes, secondary
+`100.x.x.x` for pods via the AWS VPC CNI's custom networking/prefix
+delegation) — agent pods get IPs from the secondary range, not the
+primary one. If the VPC has more than one CIDR association, all of them
+are allowed automatically; no extra configuration is needed here. Symptom
+if this were ever restricted to a single CIDR: any step that touches the
+`postgresql` Terraform provider (this service's `db_setup`, or
+`rds-postgres-db`'s workflows) **hangs indefinitely** — the TCP connection
+attempt to the RDS endpoint never completes or times out quickly, it just
+stalls — rather than failing fast with a clear error.
+
+### Runtime Dependencies
+
+These tools are required inside the agent container:
+
+- **OpenTofu 1.9.0** — auto-downloaded to `/tmp/np-tofu-bin/` if not available in `PATH`
+- **AWS CLI** — for Secrets Manager and S3 operations
+- **jq** — for JSON parsing
+- **PostgreSQL client (`psql`)** — installed via `apk add postgresql-client` when needed for link operations
+
+## Important Considerations
+
+### Data Loss on Delete
+
+The service uses `skip_final_snapshot = true`. **Deleting the service permanently destroys all data** in the RDS instance with no automated backup. Ensure manual snapshots are taken before deletion if data recovery is needed.
+
+### Unlink Preserves Data
+
+When an application is unlinked, only the PostgreSQL **grants are revoked** — the database and user are preserved. This prevents accidental data loss when relinking or migrating applications.
+
+### Dimension Alignment
+
+For `rds-postgres-db` services to auto-discover this server, both services must share the same nullplatform dimensions (e.g., `cluster: prod`). Mismatched dimensions will cause discovery to fail.
+
+### Secrets Manager Deletion
+
+The master password secret is deleted immediately on service destroy (`recovery_window_in_days = 0`). There is no recovery window.
+
+### Storage Encryption
+
+All RDS instances are created with `storage_encrypted = true` using the default AWS-managed key.
+
+### Terraform State
+
+Terraform state is stored in an S3 bucket named `np-service-` with versioning enabled. This bucket is created before provisioning and deleted (including all versions) after the RDS instance is destroyed.
diff --git a/rds-postgres-server/deployment/backend.tf b/rds-postgres-server/deployment/backend.tf
new file mode 100644
index 0000000..12c0dbe
--- /dev/null
+++ b/rds-postgres-server/deployment/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "s3" {}
+}
diff --git a/rds-postgres-server/deployment/data.tf b/rds-postgres-server/deployment/data.tf
new file mode 100644
index 0000000..9854479
--- /dev/null
+++ b/rds-postgres-server/deployment/data.tf
@@ -0,0 +1,15 @@
+# Discover shared RDS infrastructure (tagged by nullplatform during setup)
+
+data "aws_vpc" "main" {
+ id = var.vpc_id
+}
+
+data "aws_subnets" "private" {
+ filter {
+ name = "vpc-id"
+ values = [var.vpc_id]
+ }
+ tags = {
+ "nullplatform/subnet-type" = "private"
+ }
+}
diff --git a/rds-postgres-server/deployment/main.tf b/rds-postgres-server/deployment/main.tf
new file mode 100644
index 0000000..fea42ff
--- /dev/null
+++ b/rds-postgres-server/deployment/main.tf
@@ -0,0 +1,109 @@
+# ---------------------------------------------------------------------------
+# Security group for RDS (allows PostgreSQL traffic from within the VPC)
+# ---------------------------------------------------------------------------
+
+resource "aws_security_group" "rds" {
+ name = "np-rds-${var.instance_name}"
+ description = "Allow PostgreSQL access from within the VPC"
+ vpc_id = var.vpc_id
+
+ ingress {
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ # Use every CIDR block associated with the VPC, not just the primary one.
+ # EKS clusters commonly add a secondary CIDR for pod networking (e.g. a
+ # 100.x.x.x block alongside the primary 10.x.x.x one) — pods get IPs from
+ # the secondary block, so restricting to the primary CIDR silently blocks
+ # agent-pod-to-RDS connectivity. Confirmed live: pod IP 100.17.11.188 vs
+ # RDS SG only allowing 10.16.0.0/16.
+ cidr_blocks = [for c in data.aws_vpc.main.cidr_block_associations : c.cidr_block]
+ }
+
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Master password (stored in Secrets Manager, used by link permissions)
+# ---------------------------------------------------------------------------
+
+resource "random_password" "master" {
+ length = 32
+ special = false
+}
+
+resource "aws_secretsmanager_secret" "master" {
+ name = "nullplatform/rds/${var.instance_name}/master"
+ recovery_window_in_days = 0
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "rds-instance" = var.instance_name
+ "service-id" = var.service_id
+ }
+}
+
+resource "aws_secretsmanager_secret_version" "master" {
+ secret_id = aws_secretsmanager_secret.master.id
+ secret_string = jsonencode({
+ username = "master"
+ password = random_password.master.result
+ })
+}
+
+# ---------------------------------------------------------------------------
+# RDS instance
+# ---------------------------------------------------------------------------
+
+resource "aws_db_subnet_group" "main" {
+ name = var.instance_name
+ subnet_ids = data.aws_subnets.private.ids
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+}
+
+resource "aws_db_instance" "main" {
+ identifier = var.instance_name
+ engine = "postgres"
+ engine_version = var.postgres_version
+ instance_class = var.instance_class
+ allocated_storage = var.allocated_storage
+ storage_type = "gp3"
+ storage_encrypted = true
+
+ db_name = "postgres"
+ username = "master"
+ password = random_password.master.result
+
+ db_subnet_group_name = aws_db_subnet_group.main.name
+ vpc_security_group_ids = [aws_security_group.rds.id]
+
+ multi_az = var.multi_az
+ publicly_accessible = false
+ skip_final_snapshot = true
+ deletion_protection = false
+
+ backup_retention_period = var.backup_retention_period
+ backup_window = var.backup_window
+ maintenance_window = var.maintenance_window
+
+ tags = {
+ "managed-by" = "nullplatform"
+ "service-id" = var.service_id
+ }
+
+ depends_on = [aws_secretsmanager_secret_version.master]
+}
diff --git a/rds-postgres-server/deployment/outputs.tf b/rds-postgres-server/deployment/outputs.tf
new file mode 100644
index 0000000..31bd6ae
--- /dev/null
+++ b/rds-postgres-server/deployment/outputs.tf
@@ -0,0 +1,19 @@
+output "hostname" {
+ value = aws_db_instance.main.address
+ description = "RDS endpoint hostname"
+}
+
+output "port" {
+ value = aws_db_instance.main.port
+ description = "RDS port"
+}
+
+output "db_instance_identifier" {
+ value = aws_db_instance.main.identifier
+ description = "AWS RDS instance identifier"
+}
+
+output "master_secret_arn" {
+ value = aws_secretsmanager_secret.master.arn
+ description = "ARN of the Secrets Manager secret for master credentials"
+}
diff --git a/rds-postgres-server/deployment/providers.tf b/rds-postgres-server/deployment/providers.tf
new file mode 100644
index 0000000..4988945
--- /dev/null
+++ b/rds-postgres-server/deployment/providers.tf
@@ -0,0 +1,16 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.region
+}
diff --git a/rds-postgres-server/deployment/variables.tf b/rds-postgres-server/deployment/variables.tf
new file mode 100644
index 0000000..673ba42
--- /dev/null
+++ b/rds-postgres-server/deployment/variables.tf
@@ -0,0 +1,62 @@
+variable "service_id" {
+ type = string
+ description = "Nullplatform service ID"
+}
+
+variable "instance_name" {
+ type = string
+ description = "Unique instance name for AWS resource naming (format: np-)"
+}
+
+variable "region" {
+ type = string
+ default = "us-east-1"
+ description = "AWS region"
+}
+
+variable "vpc_id" {
+ type = string
+ description = "VPC ID where the RDS instance will be deployed"
+}
+
+variable "instance_class" {
+ type = string
+ default = "db.t3.micro"
+ description = "RDS instance class"
+}
+
+variable "allocated_storage" {
+ type = number
+ default = 20
+ description = "Allocated storage in GB"
+}
+
+variable "postgres_version" {
+ type = string
+ default = "16"
+ description = "PostgreSQL major version"
+}
+
+variable "multi_az" {
+ type = bool
+ default = false
+ description = "Enable Multi-AZ deployment for high availability"
+}
+
+variable "backup_retention_period" {
+ type = number
+ default = 7
+ description = "Number of days to retain automated backups. 0 disables backups."
+}
+
+variable "backup_window" {
+ type = string
+ default = "03:00-04:00"
+ description = "Daily time range for automated backups (UTC, hh:mm-hh:mm)"
+}
+
+variable "maintenance_window" {
+ type = string
+ default = "Mon:04:00-Mon:05:00"
+ description = "Weekly time range for maintenance operations (UTC, ddd:hh:mm-ddd:hh:mm)"
+}
diff --git a/rds-postgres-server/entrypoint/entrypoint b/rds-postgres-server/entrypoint/entrypoint
new file mode 100755
index 0000000..f8b84ad
--- /dev/null
+++ b/rds-postgres-server/entrypoint/entrypoint
@@ -0,0 +1,83 @@
+#!/bin/bash
+set -euo pipefail
+
+if [ -z "${NP_ACTION_CONTEXT:-}" ]; then
+ echo "NP_ACTION_CONTEXT is not set. Exiting."
+ exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# Bridge agent API key to the np CLI.
+# np-agent passes NP_API_KEY; the np CLI expects NULLPLATFORM_API_KEY.
+# Without this bridge, commands like "np service patch" fail with:
+# "please login first, refresh token or organization id not found"
+# ---------------------------------------------------------------------------
+if [ -n "${NP_API_KEY:-}" ] && [ -z "${NULLPLATFORM_API_KEY:-}" ]; then
+ export NULLPLATFORM_API_KEY="$NP_API_KEY"
+fi
+
+# Strip surrounding single quotes that np-agent may wrap the context in
+CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//")
+export NP_ACTION_CONTEXT="$CLEAN_CONTEXT"
+
+export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification')
+export SERVICE_ACTION=$(echo "$CONTEXT" | jq -r '.slug')
+export SERVICE_ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type')
+export NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action')
+export LINK=$(echo "$CONTEXT" | jq '.link')
+
+ACTION_SOURCE=service
+IS_LINK_ACTION=$(echo "$CONTEXT" | jq '.link != null')
+
+if [ "$IS_LINK_ACTION" = "true" ]; then
+ ACTION_SOURCE=link
+fi
+
+export WORKING_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+SERVICE_PATH=""
+OVERRIDES_PATH=""
+
+for arg in "$@"; do
+ case $arg in
+ --service-path=*)
+ SERVICE_PATH="${arg#*=}"
+ ;;
+ --overrides-path=*)
+ OVERRIDES_PATH="${arg#*=}"
+ ;;
+ *)
+ echo "Unknown argument: $arg"
+ exit 1
+ ;;
+ esac
+done
+
+SERVICE_PATH="${SERVICE_PATH:-$(dirname "$WORKING_DIRECTORY")}"
+
+# ---------------------------------------------------------------------------
+# Resolve relative SERVICE_PATH to absolute.
+# The agent child process inherits the CWD of where np-agent was started,
+# which is NOT necessarily ~/.np/ (the basepath). Try the current CWD first,
+# then fall back to ~/.np/ as the basepath.
+# Without this fallback, "cd " fails silently (exit 1, no output)
+# when the CWD doesn't contain the service path.
+# ---------------------------------------------------------------------------
+if [[ "$SERVICE_PATH" != /* ]]; then
+ if [ -d "$SERVICE_PATH" ]; then
+ SERVICE_PATH="$(cd "$SERVICE_PATH" && pwd)"
+ elif [ -d "$HOME/.np/$SERVICE_PATH" ]; then
+ SERVICE_PATH="$(cd "$HOME/.np/$SERVICE_PATH" && pwd)"
+ else
+ echo "ERROR: Cannot resolve SERVICE_PATH='$SERVICE_PATH' from CWD=$(pwd) or $HOME/.np/"
+ exit 1
+ fi
+fi
+
+OVERRIDES_PATH="${OVERRIDES_PATH:-$SERVICE_PATH/overrides}"
+
+export SERVICE_PATH
+export OVERRIDES_PATH
+export ACTION_SOURCE
+
+np service-action exec --live-output --live-report --script="$WORKING_DIRECTORY/$ACTION_SOURCE"
diff --git a/rds-postgres-server/entrypoint/link b/rds-postgres-server/entrypoint/link
new file mode 100755
index 0000000..8014267
--- /dev/null
+++ b/rds-postgres-server/entrypoint/link
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+echo "Executing link action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE"
+
+ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE"
+
+case "$SERVICE_ACTION_TYPE" in
+ "custom")
+ ACTION_TO_EXECUTE="$SERVICE_ACTION"
+ ;;
+ "create")
+ ACTION_TO_EXECUTE="link"
+ ;;
+ "delete")
+ ACTION_TO_EXECUTE="unlink"
+ ;;
+esac
+
+WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+VALUES_PATH="$SERVICE_PATH/values.yaml"
+
+CMD="np service workflow exec --workflow $WORKFLOW_PATH"
+
+if [[ -f "$VALUES_PATH" ]]; then
+ CMD="$CMD --values $VALUES_PATH"
+fi
+
+if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then
+ CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH"
+fi
+
+echo "Executing command: $CMD"
+eval "$CMD"
diff --git a/rds-postgres-server/entrypoint/service b/rds-postgres-server/entrypoint/service
new file mode 100755
index 0000000..e876d23
--- /dev/null
+++ b/rds-postgres-server/entrypoint/service
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+echo "Executing service action=$SERVICE_ACTION type=$SERVICE_ACTION_TYPE"
+
+ACTION_TO_EXECUTE="$SERVICE_ACTION_TYPE"
+
+case "$SERVICE_ACTION_TYPE" in
+ "custom")
+ ACTION_TO_EXECUTE="$SERVICE_ACTION"
+ ;;
+esac
+
+WORKFLOW_PATH="$SERVICE_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+OVERRIDES_WORKFLOW_PATH="$OVERRIDES_PATH/workflows/aws/$ACTION_TO_EXECUTE.yaml"
+VALUES_PATH="$SERVICE_PATH/values.yaml"
+
+CMD="np service workflow exec --workflow $WORKFLOW_PATH"
+
+if [[ -f "$VALUES_PATH" ]]; then
+ CMD="$CMD --values $VALUES_PATH"
+fi
+
+if [[ -f "$OVERRIDES_WORKFLOW_PATH" ]]; then
+ CMD="$CMD --overrides $OVERRIDES_WORKFLOW_PATH"
+fi
+
+echo "Executing command: $CMD"
+eval "$CMD"
diff --git a/rds-postgres-server/permissions/backend.tf b/rds-postgres-server/permissions/backend.tf
new file mode 100644
index 0000000..12c0dbe
--- /dev/null
+++ b/rds-postgres-server/permissions/backend.tf
@@ -0,0 +1,3 @@
+terraform {
+ backend "s3" {}
+}
diff --git a/rds-postgres-server/permissions/locals.tf b/rds-postgres-server/permissions/locals.tf
new file mode 100644
index 0000000..b0aa1e6
--- /dev/null
+++ b/rds-postgres-server/permissions/locals.tf
@@ -0,0 +1,12 @@
+locals {
+ table_privileges = {
+ # read: SELECT only
+ "read" = ["SELECT"]
+
+ # write: INSERT/UPDATE/DELETE without SELECT (write-only)
+ "write" = ["INSERT", "UPDATE", "DELETE"]
+
+ # read-write: full DML access
+ "read-write" = ["SELECT", "INSERT", "UPDATE", "DELETE"]
+ }
+}
diff --git a/rds-postgres-server/permissions/main.tf b/rds-postgres-server/permissions/main.tf
new file mode 100644
index 0000000..1ade962
--- /dev/null
+++ b/rds-postgres-server/permissions/main.tf
@@ -0,0 +1,66 @@
+# ---------------------------------------------------------------------------
+# Database (idempotent: creates if not exists, owned by master)
+# ---------------------------------------------------------------------------
+
+resource "postgresql_database" "app" {
+ name = var.db_name
+ owner = var.master_username
+}
+
+# ---------------------------------------------------------------------------
+# App user (password generated by Terraform, stable across re-applies)
+# The keepers block ensures the password is only regenerated if link_id
+# changes (which it won't), preserving credentials on update workflows.
+# ---------------------------------------------------------------------------
+
+resource "random_password" "user" {
+ length = 32
+ special = false
+ keepers = {
+ link_id = var.link_id
+ }
+}
+
+resource "postgresql_role" "app_user" {
+ name = var.db_username
+ password = random_password.user.result
+ login = true
+}
+
+# ---------------------------------------------------------------------------
+# Permissions
+# ---------------------------------------------------------------------------
+
+resource "postgresql_grant" "connect" {
+ database = postgresql_database.app.name
+ role = postgresql_role.app_user.name
+ object_type = "database"
+ privileges = ["CONNECT"]
+}
+
+resource "postgresql_grant" "schema_usage" {
+ database = postgresql_database.app.name
+ role = postgresql_role.app_user.name
+ schema = "public"
+ object_type = "schema"
+ privileges = ["USAGE"]
+}
+
+# Grant on existing tables
+resource "postgresql_grant" "tables" {
+ database = postgresql_database.app.name
+ role = postgresql_role.app_user.name
+ schema = "public"
+ object_type = "table"
+ privileges = local.table_privileges[var.access_level]
+}
+
+# Grant on future tables (default privileges applied by master user)
+resource "postgresql_default_privileges" "tables" {
+ role = postgresql_role.app_user.name
+ database = postgresql_database.app.name
+ schema = "public"
+ owner = var.master_username
+ object_type = "table"
+ privileges = local.table_privileges[var.access_level]
+}
diff --git a/rds-postgres-server/permissions/outputs.tf b/rds-postgres-server/permissions/outputs.tf
new file mode 100644
index 0000000..c0ff22f
--- /dev/null
+++ b/rds-postgres-server/permissions/outputs.tf
@@ -0,0 +1,15 @@
+output "db_username" {
+ value = postgresql_role.app_user.name
+ description = "Created database username"
+}
+
+output "db_password" {
+ value = random_password.user.result
+ sensitive = true
+ description = "Database user password"
+}
+
+output "database_name" {
+ value = postgresql_database.app.name
+ description = "Database name"
+}
diff --git a/rds-postgres-server/permissions/providers.tf b/rds-postgres-server/permissions/providers.tf
new file mode 100644
index 0000000..976a2bf
--- /dev/null
+++ b/rds-postgres-server/permissions/providers.tf
@@ -0,0 +1,22 @@
+terraform {
+ required_providers {
+ postgresql = {
+ source = "cyrilgdn/postgresql"
+ version = "~> 1.21"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.0"
+ }
+ }
+}
+
+provider "postgresql" {
+ host = var.db_host
+ port = var.db_port
+ database = "postgres"
+ username = var.master_username
+ password = var.master_password
+ sslmode = "require"
+ superuser = false
+}
diff --git a/rds-postgres-server/permissions/variables.tf b/rds-postgres-server/permissions/variables.tf
new file mode 100644
index 0000000..b2ee1d2
--- /dev/null
+++ b/rds-postgres-server/permissions/variables.tf
@@ -0,0 +1,47 @@
+variable "link_id" {
+ type = string
+ description = "Nullplatform link ID (used as keeper to stabilize password across re-applies)"
+}
+
+variable "db_host" {
+ type = string
+ description = "RDS endpoint hostname"
+}
+
+variable "db_port" {
+ type = number
+ default = 5432
+ description = "RDS port"
+}
+
+variable "db_name" {
+ type = string
+ description = "Database name to create inside the RDS instance"
+}
+
+variable "db_username" {
+ type = string
+ description = "Username for the new database user (derived from link ID)"
+}
+
+variable "master_username" {
+ type = string
+ description = "Master username for connecting to PostgreSQL"
+}
+
+variable "master_password" {
+ type = string
+ sensitive = true
+ description = "Master password for connecting to PostgreSQL (passed via auto.tfvars)"
+}
+
+variable "access_level" {
+ type = string
+ default = "read-write"
+ description = "Permission level: read, write, or read-write"
+
+ validation {
+ condition = contains(["read", "write", "read-write"], var.access_level)
+ error_message = "access_level must be one of: read, write, read-write"
+ }
+}
diff --git a/rds-postgres-server/scripts/aws/assume_role b/rds-postgres-server/scripts/aws/assume_role
new file mode 100755
index 0000000..7162634
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/assume_role
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Sourceable helper — do NOT execute directly.
+# Reads RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN from the environment. If set, calls
+# sts:AssumeRole and exports temporary credentials so all subsequent AWS calls
+# (including tofu) use that role. If empty, does nothing — the agent's
+# credentials (pod IRSA) handle auth.
+#
+# Requires: aws CLI, jq.
+# Expects: RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN (set by scripts/aws/assume_role_step),
+# SERVICE_ID (optional, used for the session name).
+
+if [ -n "${RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN:-}" ]; then
+ echo " 🔑 Assuming role: $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN"
+
+ _ar_sts_error=$(mktemp)
+ if ! ASSUMED_CREDS=$(aws sts assume-role \
+ --role-arn "$RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" \
+ --role-session-name "np-rds-postgres-server-${SERVICE_ID:-workflow}" \
+ --output json 2>"$_ar_sts_error"); then
+ echo " ❌ sts:AssumeRole failed for $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2
+ cat "$_ar_sts_error" >&2
+ rm -f "$_ar_sts_error"
+ return 1
+ fi
+ rm -f "$_ar_sts_error"
+
+ _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""')
+ _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""')
+ _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""')
+
+ if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then
+ echo " ❌ sts:AssumeRole returned incomplete credentials for $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2
+ return 1
+ fi
+
+ export AWS_ACCESS_KEY_ID="$_ar_access_key"
+ export AWS_SECRET_ACCESS_KEY="$_ar_secret_key"
+ export AWS_SESSION_TOKEN="$_ar_session_token"
+
+ echo " ✅ Role assumed successfully"
+else
+ echo " ✅ assume_role=skipped (using agent credentials)"
+fi
diff --git a/rds-postgres-server/scripts/aws/assume_role_lib b/rds-postgres-server/scripts/aws/assume_role_lib
new file mode 100755
index 0000000..38dc5aa
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/assume_role_lib
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Sourceable library of PURE helpers for assume-role resolution.
+# Makes NO np/aws calls and has no source side effects — fully unit-testable.
+# Identical across rds-postgres-server and rds-postgres-db so it can be
+# copied as-is into any new AWS service module.
+#
+# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion).
+
+# arn_for_selector
+# Given the IAM provider attributes (.iam_role_arns.arns[...]), echoes the ARN
+# whose entry matches , or "" if none. First match wins. Never
+# crashes on empty/malformed input.
+arn_for_selector() {
+ local json="$1" selector="$2"
+ [ -n "$json" ] || return 0
+ [ -n "$selector" ] || return 0
+ printf '%s' "$json" | jq -r --arg sel "$selector" '
+ [ .iam_role_arns.arns[]?
+ | select(.selector == $sel)
+ | .arn ]
+ | first // ""' 2>/dev/null || true
+}
+
+# resolve_assume_role_arn
+# Echoes the ARN to assume ("" = use agent credentials), in precedence order:
+# 1. ${!override_env_name} — explicit per-run override
+# 2. iam_attributes_json entry matching (caller pre-resolved the
+# provider via `np provider list` for the service's NRN + dimensions)
+# 3. ${!default_env_name} — per-account agent default
+# Empty override/default env names are treated as unset (chain continues).
+resolve_assume_role_arn() {
+ local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn=""
+
+ if [ -n "$override_env" ]; then
+ arn="${!override_env:-}"
+ fi
+
+ if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then
+ arn=$(arn_for_selector "$iam_json" "$selector")
+ fi
+
+ if [ -z "$arn" ] && [ -n "$default_env" ]; then
+ arn="${!default_env:-}"
+ fi
+
+ printf '%s' "$arn"
+}
diff --git a/rds-postgres-server/scripts/aws/assume_role_step b/rds-postgres-server/scripts/aws/assume_role_step
new file mode 100755
index 0000000..43f710f
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/assume_role_step
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Dedicated workflow step: resolve the target IAM role and assume it, exporting
+# temporary credentials so every subsequent step (including tofu) inherits them.
+#
+# Runs FIRST in each AWS-touching workflow. The workflow YAML must declare
+# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN as
+# output:environment so the engine propagates them to the following steps.
+#
+# The AWS IAM provider (type "aws-iam-configuration", stored key
+# "iam_role_arns.arns") is looked up directly via the np CLI, using the
+# service's full NRN (resolution walks up the hierarchy to the account level,
+# where this provider is actually registered — see below). CONTEXT.providers[...]
+# is NOT used here:
+# a live end-to-end test against a real agent confirmed it is never populated
+# by this platform version regardless of provider_categories declarations
+# (values.yaml and/or the workflow) — provider_categories is still declared
+# for forward-compatibility, but this script does not rely on it.
+#
+# The lookup also passes --dimensions (from .service.dimensions in CONTEXT,
+# e.g. cluster:prod) so that if more than one identity-access-control provider
+# is ever registered at the same NRN for different dimensions, np
+# resolves the most-specific match instead of us picking an arbitrary one
+# client-side. Today only one such provider is documented/registered per
+# account (no dimensions of its own), so this is a no-op in practice.
+#
+# Resolution precedence (see resolve_assume_role_arn in assume_role_lib):
+# $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN -> IAM provider by selector
+# -> $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials
+#
+# Requires: aws CLI, np CLI, jq. Expects: CONTEXT (engine-injected), SERVICE_ID (optional).
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/assume_role_lib"
+
+RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR="${RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR:-rds-postgres-server}"
+
+# NRN of the service from CONTEXT (falls back to scope / generic event). Use the
+# full NRN as-is — do NOT strip it. Resolution walks UP the NRN hierarchy, so the
+# identity-access-control provider (registered at account level) is found via the
+# --categories query below regardless of the depth the service runs at.
+NRN=$(echo "${CONTEXT:-}" | jq -r '.service.nrn // .scope.nrn // .entity_nrn // ""' 2>/dev/null)
+
+# Dimensions (if any) as key:value,key:value — lets np resolve the
+# most-specific IAM provider the same way it would for a k8s scope.
+DIMENSIONS=$(echo "${CONTEXT:-}" | jq -r '
+ if (.service.dimensions | type) == "object" and ((.service.dimensions | length) > 0)
+ then [ .service.dimensions | to_entries[] | "\(.key):\(.value)" ] | join(",")
+ else empty end' 2>/dev/null)
+
+# Resolve the IAM provider for this NRN + dimensions via the category query, which
+# resolves up the NRN hierarchy and returns the effective provider .attributes.
+# NOTE: --limit is incompatible with --categories (np rejects it), so it is NOT
+# passed here.
+IAM_PROVIDER=$(np provider list \
+ --nrn "$NRN" \
+ --categories identity-access-control \
+ ${DIMENSIONS:+--dimensions "$DIMENSIONS"} \
+ --format json 2>/dev/null \
+ | jq -c '(.results // [])[0].attributes // {}')
+
+RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn \
+ "$IAM_PROVIDER" \
+ "$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR" \
+ "RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" \
+ "RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN_DEFAULT")
+export RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN
+
+# scripts/aws/assume_role performs sts:AssumeRole and exports AWS_* when an ARN is set,
+# or no-ops (leaving agent credentials in place) when empty. Non-zero only when
+# sts:AssumeRole itself fails.
+if ! source "$SCRIPT_DIR/assume_role"; then
+ echo " ❌ assume_role step failed: could not assume $RDS_POSTGRES_SERVER_ASSUME_ROLE_ARN" >&2
+ echo "" >&2
+ echo "💡 Possible causes:" >&2
+ echo " • The agent's role is not allowed to sts:AssumeRole the target role" >&2
+ echo " • The target role does not exist or does not trust the agent role" >&2
+ echo " • There is no role ARN configured for selector=$RDS_POSTGRES_SERVER_ASSUME_ROLE_SELECTOR at NRN=$NRN${DIMENSIONS:+ dimensions=$DIMENSIONS}" >&2
+ echo "" >&2
+ exit 1
+fi
diff --git a/rds-postgres-server/scripts/aws/build_context b/rds-postgres-server/scripts/aws/build_context
new file mode 100755
index 0000000..8c6b288
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/build_context
@@ -0,0 +1,149 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# build_context — Extracts variables from the NP notification context and
+# prepares the Terraform execution environment for service actions.
+#
+# For link actions, also extracts LINK_* variables consumed by
+# build_permissions_context in the next workflow step.
+# ---------------------------------------------------------------------------
+
+# --- Parse service context --------------------------------------------------
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+SERVICE_NAME=$(echo "$CONTEXT" | jq -r '.service.name // ""')
+SERVICE_NAME="${SERVICE_NAME:-svc-${SERVICE_ID}}"
+
+# Sanitize name for use in AWS resource identifiers (lowercase, alphanumeric + hyphens, max 55 chars)
+INSTANCE_NAME="np-$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//' | cut -c1-55)"
+
+# Merge .service.attributes with action .parameters.
+# CRITICAL: On the first "create" action, .service.attributes is empty or incomplete.
+# The user-provided values are in .parameters. The jq * operator merges objects
+# with the right-hand side taking precedence, so parameters always win.
+SERVICE_ATTRS=$(echo "$CONTEXT" | jq -r '(.service.attributes // {}) * (.parameters // {})')
+
+INSTANCE_CLASS=$(echo "$SERVICE_ATTRS" | jq -r '.instance_class // "db.t3.micro"')
+ALLOCATED_STORAGE=$(echo "$SERVICE_ATTRS" | jq -r '.allocated_storage // 20')
+POSTGRES_VERSION=$(echo "$SERVICE_ATTRS" | jq -r '.postgres_version // "16"')
+
+# --- Read static config from values.yaml ------------------------------------
+# CRITICAL: $VALUES is a FILE PATH set by "np service workflow exec --values ",
+# NOT JSON content. Use yaml_value() to read individual keys.
+yaml_value() {
+ local key="$1" default="$2" file="$3"
+ local val
+ val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES")
+
+if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then
+ export AWS_PROFILE="${AWS_PROFILE_VAL}"
+fi
+
+# --- Resolve AWS context from nullplatform provider -------------------------
+# Both region and VPC are stored in runtime_configuration providers scoped to
+# the account. We derive the account NRN from the service NRN by stripping
+# everything from :namespace= onward, then query each provider by stored_keys.
+
+ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:namespace=.*$//')
+
+if [ -z "$ACCOUNT_NRN" ]; then
+ echo "ERROR: could not derive account NRN from .service.nrn in context" >&2
+ exit 1
+fi
+
+NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json --limit 100)
+
+echo "Resolving region and VPC for account: ${ACCOUNT_NRN}"
+
+# Resolve region from the account provider (stored key: account.region)
+ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \
+ | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["account.region"]))] | first | .id // ""')
+
+if [ -z "$ACCOUNT_PROVIDER_ID" ] || [ "$ACCOUNT_PROVIDER_ID" = "null" ]; then
+ echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" >&2
+ exit 1
+fi
+
+ACCOUNT_PROVIDER_DATA=$(np provider read --id "$ACCOUNT_PROVIDER_ID" --format json)
+REGION=$(echo "$ACCOUNT_PROVIDER_DATA" | jq -r '.attributes.account.region // ""')
+
+if [ -z "$REGION" ]; then
+ echo "ERROR: account.region not found in provider ${ACCOUNT_PROVIDER_ID}" >&2
+ exit 1
+fi
+
+echo "Using region: ${REGION}"
+export REGION
+
+# Resolve VPC ID from the VPC provider (stored key: vpc.id)
+VPC_PROVIDER_ID=$(echo "$NP_PROVIDERS" \
+ | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["vpc.id"]))] | first | .id // ""')
+
+if [ -z "$VPC_PROVIDER_ID" ] || [ "$VPC_PROVIDER_ID" = "null" ]; then
+ echo "ERROR: no VPC provider found for account ${ACCOUNT_NRN}" >&2
+ exit 1
+fi
+
+VPC_PROVIDER_DATA=$(np provider read --id "$VPC_PROVIDER_ID" --format json)
+VPC_ID=$(echo "$VPC_PROVIDER_DATA" | jq -r '.attributes.vpc.id // ""')
+
+if [ -z "$VPC_ID" ]; then
+ echo "ERROR: vpc.id not found in provider ${VPC_PROVIDER_ID}" >&2
+ exit 1
+fi
+
+echo "Using VPC: ${VPC_ID}"
+
+# --- Ensure per-instance tfstate bucket exists ------------------------------
+# Each service instance gets its own S3 bucket so state is isolated and the
+# bucket name is deterministic (reconstructable from SERVICE_ID alone).
+# The agent IAM role must allow s3:CreateBucket + s3:PutBucketVersioning
+# on arn:aws:s3:::np-service-* and the usual Get/Put/Delete/List actions.
+
+TFSTATE_BUCKET="np-service-${SERVICE_ID}"
+
+if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then
+ echo "Creating tfstate bucket: ${TFSTATE_BUCKET}"
+ if [ "$REGION" = "us-east-1" ]; then
+ aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION"
+ else
+ aws s3api create-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" \
+ --create-bucket-configuration LocationConstraint="$REGION"
+ fi
+ aws s3api put-bucket-versioning --bucket "$TFSTATE_BUCKET" \
+ --versioning-configuration Status=Enabled
+ echo "Bucket ${TFSTATE_BUCKET} created with versioning enabled."
+else
+ echo "Using existing tfstate bucket: ${TFSTATE_BUCKET}"
+fi
+
+export TFSTATE_BUCKET
+
+# --- Set Terraform execution variables --------------------------------------
+
+export OUTPUT_DIR="/tmp/np-service-${SERVICE_ID}"
+mkdir -p "$OUTPUT_DIR"
+
+export TOFU_MODULE_DIR="$SERVICE_PATH/deployment"
+
+export TOFU_INIT_VARIABLES="-backend-config=bucket=${TFSTATE_BUCKET} -backend-config=key=terraform.tfstate -backend-config=region=${REGION}"
+
+export TOFU_VARIABLES="-var=service_id=${SERVICE_ID} -var=instance_name=${INSTANCE_NAME} -var=region=${REGION} -var=vpc_id=${VPC_ID} -var=instance_class=${INSTANCE_CLASS} -var=allocated_storage=${ALLOCATED_STORAGE} -var=postgres_version=${POSTGRES_VERSION}"
+
+# --- Extract link context (link workflows only) -----------------------------
+# These are consumed by build_permissions_context in the next workflow step.
+
+if [ "${ACTION_SOURCE:-}" = "link" ]; then
+ export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""')
+ export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""')
+ export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""')
+ export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""')
+
+ LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})')
+ export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"')
+fi
diff --git a/rds-postgres-server/scripts/aws/build_permissions_context b/rds-postgres-server/scripts/aws/build_permissions_context
new file mode 100755
index 0000000..a50e079
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/build_permissions_context
@@ -0,0 +1,111 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# build_permissions_context — Prepares the Terraform execution environment
+# for link actions (link/unlink). Runs AFTER build_context, which has set:
+# $CONTEXT, $VALUES, $SERVICE_PATH, $LINK_ID, $LINK_ACCESS_LEVEL
+#
+# This script creates a DB user per link. It reads master credentials from
+# Secrets Manager and passes them to Terraform via a sensitive.auto.tfvars
+# file (avoids exposing the password in the OS process list).
+# ---------------------------------------------------------------------------
+
+# IMPORTANT: This script has its own yaml_value() function.
+# Shell functions do NOT carry over between scripts; each script is isolated.
+yaml_value() {
+ local key="$1" default="$2" file="$3"
+ local val
+ val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
+ echo "${val:-$default}"
+}
+
+# REGION is exported by build_context (resolved from the nullplatform account provider).
+if [ -z "${REGION:-}" ]; then
+ echo "ERROR: REGION is not set. Expected to be exported by build_context." >&2
+ exit 1
+fi
+
+AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES")
+
+if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then
+ export AWS_PROFILE="${AWS_PROFILE_VAL}"
+fi
+
+if [ -z "${TFSTATE_BUCKET:-}" ]; then
+ echo "ERROR: TFSTATE_BUCKET is not set. Expected to be exported by build_context." >&2
+ exit 1
+fi
+
+# --- Read service outputs (set by write_service_outputs after RDS creation) -
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+ACTION_TYPE=$(echo "$CONTEXT" | jq -r '.type // ""')
+SERVICE_ATTRS=$(echo "$CONTEXT" | jq -r '.service.attributes // {}')
+
+DB_HOST=$(echo "$SERVICE_ATTRS" | jq -r '.hostname // ""')
+DB_PORT=$(echo "$SERVICE_ATTRS" | jq -r '.port // "5432"')
+MASTER_SECRET_ARN=$(echo "$SERVICE_ATTRS" | jq -r '.master_secret_arn // ""')
+
+if [ -z "$DB_HOST" ]; then
+ echo "ERROR: Service hostname not found in service attributes." >&2
+ echo " Has the RDS instance been created and write_service_outputs completed?" >&2
+ exit 1
+fi
+
+if [ -z "$MASTER_SECRET_ARN" ]; then
+ echo "ERROR: master_secret_arn not found in service attributes." >&2
+ exit 1
+fi
+
+# --- Retrieve master credentials from Secrets Manager ----------------------
+
+echo "Retrieving master credentials from Secrets Manager..."
+MASTER_CREDS=$(aws secretsmanager get-secret-value \
+ --secret-id "$MASTER_SECRET_ARN" \
+ --query 'SecretString' \
+ --output text)
+
+MASTER_USER=$(echo "$MASTER_CREDS" | jq -r '.username')
+MASTER_PASS=$(echo "$MASTER_CREDS" | jq -r '.password')
+
+# --- Derive link-specific DB username from link ID -------------------------
+# Deterministic: always produces the same username for the same link.
+# Format: np_ (~20 chars, safe for Postgres)
+
+DB_USERNAME="np_$(echo "${LINK_ID}" | tr -d '-' | cut -c1-16 | tr '[:upper:]' '[:lower:]')"
+
+# --- Extract DB name from link parameters ----------------------------------
+# Merge .link.attributes with .parameters (parameters take precedence on create)
+
+LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})')
+DB_NAME=$(echo "$LINK_ATTRS" | jq -r '.db_name // ""')
+
+if [ -z "$DB_NAME" ]; then
+ if [ "$ACTION_TYPE" = "delete" ]; then
+ echo "WARNING: unlink without db_name — link likely never fully created. Exiting cleanly."
+ exit 0
+ fi
+ echo "ERROR: db_name is required to create a link" >&2
+ exit 1
+fi
+
+# --- Prepare link-specific working directory --------------------------------
+# Override OUTPUT_DIR so each link has its own isolated workspace and tfstate.
+
+export OUTPUT_DIR="/tmp/np-link-${LINK_ID}"
+mkdir -p "$OUTPUT_DIR"
+
+# Write sensitive master password to auto.tfvars instead of -var= flag.
+# This keeps it out of the OS process list while Terraform is running.
+cat > "$OUTPUT_DIR/sensitive.auto.tfvars" <)
+# ---------------------------------------------------------------------------
+
+# REGION is exported by build_context (resolved from the nullplatform account provider).
+if [ -z "${REGION:-}" ]; then
+ echo "ERROR: REGION is not set. Expected to be exported by build_context." >&2
+ exit 1
+fi
+
+if [ -z "${TFSTATE_BUCKET:-}" ]; then
+ echo "TFSTATE_BUCKET is not set, skipping bucket cleanup."
+ exit 0
+fi
+
+if ! aws s3api head-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION" 2>/dev/null; then
+ echo "Bucket ${TFSTATE_BUCKET} does not exist, nothing to delete."
+ exit 0
+fi
+
+delete_objects() {
+ local query="$1"
+ local label="$2"
+ local objects
+ local rc=0
+
+ objects=$(aws s3api list-object-versions \
+ --bucket "$TFSTATE_BUCKET" \
+ --region "$REGION" \
+ --query "${query}" \
+ --output json) || rc=$?
+
+ if [ "$rc" -ne 0 ]; then
+ echo "WARNING: list-object-versions failed (exit $rc) for ${label}, skipping."
+ return 0
+ fi
+
+ if [ "$objects" = "null" ] || [ -z "$objects" ] || [ "$objects" = "[]" ]; then
+ echo "No ${label} to delete."
+ return 0
+ fi
+
+ echo "Deleting ${label}..."
+ local tmpfile
+ tmpfile=$(mktemp)
+ printf '{"Objects": %s, "Quiet": true}' "$objects" > "$tmpfile"
+ aws s3api delete-objects \
+ --bucket "$TFSTATE_BUCKET" \
+ --region "$REGION" \
+ --delete "file://${tmpfile}"
+ rm -f "$tmpfile"
+}
+
+delete_objects 'Versions[].{Key:Key,VersionId:VersionId}' "object versions"
+delete_objects 'DeleteMarkers[].{Key:Key,VersionId:VersionId}' "delete markers"
+
+echo "Deleting bucket ${TFSTATE_BUCKET}..."
+aws s3api delete-bucket --bucket "$TFSTATE_BUCKET" --region "$REGION"
+echo "Bucket ${TFSTATE_BUCKET} deleted."
diff --git a/rds-postgres-server/scripts/aws/do_tofu b/rds-postgres-server/scripts/aws/do_tofu
new file mode 100755
index 0000000..c25adc4
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/do_tofu
@@ -0,0 +1,59 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# do_tofu — Copies the Terraform module to OUTPUT_DIR and executes tofu.
+#
+# Expected environment variables (set by build_context or build_permissions_context):
+# OUTPUT_DIR — working directory for this execution
+# TOFU_MODULE_DIR — path to the Terraform module (deployment/ or permissions/)
+# TOFU_INIT_VARIABLES — -backend-config= flags for tofu init
+# TOFU_VARIABLES — -var= flags for tofu apply/destroy
+#
+# Set by the workflow YAML configuration block:
+# TOFU_ACTION — "apply" or "destroy"
+# ---------------------------------------------------------------------------
+
+TOFU_ACTION="${TOFU_ACTION:-apply}"
+
+# ---------------------------------------------------------------------------
+# Ensure tofu is available. On agents where tofu is not pre-installed, download
+# and cache the binary in /tmp/np-tofu-bin so it is only fetched once per pod
+# lifetime (the directory survives across actions but is cleared on pod restart).
+# ---------------------------------------------------------------------------
+if ! command -v tofu &>/dev/null; then
+ TOFU_VERSION="1.9.0"
+ TOFU_BIN_DIR="/tmp/np-tofu-bin"
+ TOFU_BIN="$TOFU_BIN_DIR/tofu"
+
+ if [ ! -f "$TOFU_BIN" ]; then
+ echo "tofu not found in PATH, installing v${TOFU_VERSION} to ${TOFU_BIN_DIR}..."
+ mkdir -p "$TOFU_BIN_DIR"
+ curl -fsSL \
+ "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip" \
+ -o /tmp/tofu.zip
+ unzip -qo /tmp/tofu.zip tofu -d "$TOFU_BIN_DIR"
+ chmod +x "$TOFU_BIN"
+ rm -f /tmp/tofu.zip
+ echo "tofu installed: $("$TOFU_BIN" version | head -1)"
+ else
+ echo "Using cached tofu at ${TOFU_BIN}"
+ fi
+
+ export PATH="$TOFU_BIN_DIR:$PATH"
+fi
+
+cd "$OUTPUT_DIR"
+
+# Copy module files into the working directory.
+# Note: sensitive.auto.tfvars (if written by build_permissions_context) only
+# exists in OUTPUT_DIR, not in TOFU_MODULE_DIR, so cp will not overwrite it.
+cp -r "$TOFU_MODULE_DIR"/* .
+
+echo "Running: tofu init"
+# shellcheck disable=SC2086
+tofu init $TOFU_INIT_VARIABLES
+
+echo "Running: tofu $TOFU_ACTION"
+# shellcheck disable=SC2086
+tofu "$TOFU_ACTION" -auto-approve $TOFU_VARIABLES
diff --git a/rds-postgres-server/scripts/aws/write_link_outputs b/rds-postgres-server/scripts/aws/write_link_outputs
new file mode 100755
index 0000000..755fb9d
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/write_link_outputs
@@ -0,0 +1,45 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# write_link_outputs — Reads Terraform outputs from the permissions module
+# and writes them to the NP link attributes via the API.
+#
+# Fields with "export: true" (username, database_name) become plain env vars.
+# Fields with "export: {secret: true}" (password) become secret env vars
+# (value is stored encrypted and hidden in API responses, but injected
+# into the app at runtime).
+# ---------------------------------------------------------------------------
+
+cd "$OUTPUT_DIR"
+
+LINK_ID_VAL=$(echo "$CONTEXT" | jq -r '.link.id')
+
+echo "Reading Terraform outputs for link $LINK_ID_VAL..."
+
+DB_USERNAME=$(tofu output -raw db_username 2>/dev/null || echo "")
+DB_PASSWORD=$(tofu output -raw db_password 2>/dev/null || echo "")
+DATABASE_NAME=$(tofu output -raw database_name 2>/dev/null || echo "")
+
+if [ -z "$DB_USERNAME" ]; then
+ echo "WARNING: No db_username output found. Skipping link attribute update."
+ exit 0
+fi
+
+ATTRS=$(jq -n \
+ --arg username "$DB_USERNAME" \
+ --arg password "$DB_PASSWORD" \
+ --arg database_name "$DATABASE_NAME" \
+ '{
+ username: $username,
+ password: $password,
+ database_name: $database_name
+ }')
+
+echo "Updating link $LINK_ID_VAL attributes:"
+echo " username: $DB_USERNAME"
+echo " database_name: $DATABASE_NAME"
+echo " password: ****"
+
+np link patch --id "$LINK_ID_VAL" --body "{\"attributes\": $ATTRS}"
+echo "Link attributes updated successfully."
diff --git a/rds-postgres-server/scripts/aws/write_service_outputs b/rds-postgres-server/scripts/aws/write_service_outputs
new file mode 100755
index 0000000..48b7886
--- /dev/null
+++ b/rds-postgres-server/scripts/aws/write_service_outputs
@@ -0,0 +1,50 @@
+#!/bin/bash
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# write_service_outputs — Reads Terraform outputs from the deployment module
+# and writes them to the NP service attributes via the API.
+#
+# Fields with "export: true" in the service spec (hostname, port) become
+# env vars in apps when a link is activated.
+# Fields with "export: false" (db_instance_identifier, master_secret_arn) are
+# stored internally and used by build_permissions_context during link actions.
+# ---------------------------------------------------------------------------
+
+cd "$OUTPUT_DIR"
+
+SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id')
+
+echo "Reading Terraform outputs for service $SERVICE_ID..."
+
+HOSTNAME=$(tofu output -raw hostname 2>/dev/null || echo "")
+PORT=$(tofu output -raw port 2>/dev/null || echo "")
+DB_INSTANCE_IDENTIFIER=$(tofu output -raw db_instance_identifier 2>/dev/null || echo "")
+MASTER_SECRET_ARN=$(tofu output -raw master_secret_arn 2>/dev/null || echo "")
+
+if [ -z "$HOSTNAME" ]; then
+ echo "WARNING: No hostname output found. Skipping attribute update."
+ exit 0
+fi
+
+ATTRS=$(jq -n \
+ --arg hostname "$HOSTNAME" \
+ --arg port "$PORT" \
+ --arg db_instance_id "$DB_INSTANCE_IDENTIFIER" \
+ --arg master_secret_arn "${MASTER_SECRET_ARN:-}" \
+ '{
+ hostname: $hostname,
+ port: ($port | tonumber),
+ db_instance_identifier: $db_instance_id
+ } + (if $master_secret_arn != "" then {master_secret_arn: $master_secret_arn} else {} end)')
+
+echo "Updating service $SERVICE_ID attributes:"
+echo " hostname: $HOSTNAME"
+echo " port: $PORT"
+echo " db_instance_identifier: $DB_INSTANCE_IDENTIFIER"
+
+np service patch --id "$SERVICE_ID" --body "{\"attributes\": $ATTRS}"
+echo "Service attributes updated successfully."
+
+#np service patch --id "$SERVICE_ID" --body '{"linkable_to": []}'
+#echo "Service linkable_to set to []."
diff --git a/rds-postgres-server/specs/install/README.md b/rds-postgres-server/specs/install/README.md
new file mode 100644
index 0000000..76be2be
--- /dev/null
+++ b/rds-postgres-server/specs/install/README.md
@@ -0,0 +1,59 @@
+# Install — registering the rds-postgres-server service
+
+This directory holds the reference OpenTofu/Terraform used to **install**
+rds-postgres-server on a nullplatform account:
+
+- Registers its service specification, link specification, and agent
+ association (notification channel) so `np service create` starts routing
+ actions to an agent.
+- Optionally registers the `aws-configuration` / `aws-networking-configuration`
+ providers (account.region, vpc.id) that `build_context` needs — toggle with
+ `create_account_providers`, off if another stack already registers them.
+- Optionally registers the `aws-iam-configuration` provider (the AssumeRole
+ target for `scripts/aws/assume_role_step`) — toggle with
+ `create_identity_access_control`. **This is the only place that should
+ create it for the account.** If `rds-postgres-db` is also installed,
+ pass its permissions role ARN via `rds_postgres_db_role_arn` so both
+ selectors land in the same provider — see the comment at the top of
+ `aws/main.tf` for why registering it a second time (from rds-postgres-db's
+ own install) would break `assume_role_step`'s lookup.
+
+This is separate from `../requirements/aws`, which provisions the AWS
+AssumeRole IAM role/policies the *agent* needs to operate the service — the
+`rds_postgres_server_role_arn` / `rds_postgres_db_role_arn` variables here
+are that module's `permissions_role_arn` output. See that module's README
+and the "AssumeRole Setup Guide" in the top-level [`README.md`](../../README.md)
+for the full picture.
+
+## Layout
+
+```
+install/
+├── README.md (this file)
+└── aws/ Working example
+ ├── main.tf
+ ├── variables.tf
+ ├── outputs.tf
+ └── terraform.tfvars.example
+```
+
+## Using the example
+
+```bash
+cp -r rds-postgres-server/specs/install/aws /path/to/your/infra/rds-postgres-server
+cd /path/to/your/infra/rds-postgres-server
+cp terraform.tfvars.example terraform.tfvars
+$EDITOR terraform.tfvars
+
+tofu init
+tofu apply
+```
+
+`tags_selectors` must match the tag selectors of the agent(s) that should
+pick up rds-postgres-server actions (the same selectors passed as
+`tags_selectors` to the `nullplatform/agent` tofu-module).
+
+Run this once per nullplatform namespace. It only registers the service
+with the platform — it does not create any AWS infrastructure by itself
+(that happens per-instance, at `create` time, via `deployment/` and the
+AssumeRole role from `requirements/aws`).
diff --git a/rds-postgres-server/specs/install/aws/.terraform.lock.hcl b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl
new file mode 100644
index 0000000..266f917
--- /dev/null
+++ b/rds-postgres-server/specs/install/aws/.terraform.lock.hcl
@@ -0,0 +1,69 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.53.0"
+ hashes = [
+ "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=",
+ "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c",
+ "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8",
+ "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69",
+ "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87",
+ "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f",
+ "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc",
+ "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760",
+ "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147",
+ "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b",
+ "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b",
+ "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be",
+ "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46",
+ "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138",
+ "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc",
+ "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6",
+ ]
+}
+
+provider "registry.opentofu.org/hashicorp/http" {
+ version = "3.6.0"
+ constraints = "~> 3.0"
+ hashes = [
+ "h1:5oU7Zm+2gAVGmxqtJ9E8uTudUkYy/DEn/y3IWphdv4k=",
+ "zh:0f719fa5426bc883e9fa6abf7f6498e48025edafbc29015e2f5c028f1cca3b9d",
+ "zh:1b4d7dafefd6c61764b2f9ed6943ceb9a200dee3590d18747e3a5f6b20ce85e0",
+ "zh:1d23a712984866d29f7b07028a4e99c783c71f1a5dddf08bc3d4e7da9d91a1fa",
+ "zh:257d23d58c3bb024b6bc8eb88736eaf912e934ad47c639d0c3c742bddda849a1",
+ "zh:479860e1a5468f5e04013b9364c9496d7ed0804bf9a1acd8e07558d57609993d",
+ "zh:4cb5e681bf599b411b27c4a2c4066a5fb2ed79aaa3a1a3cb5a30002fec062ce9",
+ "zh:4fb35c3f643dae9f3670d719397a415f815a0b95f8ed7bd8a72f27a94ba78092",
+ "zh:59ba40825ab38db5b4a0989a2db0df35cc15d8984f898176011ba352f27d77b7",
+ "zh:61fc1252eb88088638f4c69ea4e2171cde2e5089fa632ac1e943b13787348f73",
+ "zh:7c5d6dd5f7cbc460e95d368be35c29b4e0402069b8912dbd5d1cd7fa9acef216",
+ "zh:7f76d756240d4284642f359ad470226e5378670239aadc366ef54d9d914d4d2e",
+ "zh:8133ad0814098177e0d067c816ccf1bf48bbadacd18f6f2c808c90447505723b",
+ "zh:c93be06269bb728f1968f8c50506de56c887017ac1d6e4be1f925651d8437eb6",
+ "zh:ef47b78a10a82e6cf53344a6a85a94041c28286c10a70541c564d762f1cfede0",
+ "zh:f5796a53a74999135bd9087aff50fddda59129d09b2f9b1902ff8c0c1e047e48",
+ ]
+}
+
+provider "registry.opentofu.org/nullplatform/nullplatform" {
+ version = "0.0.96"
+ constraints = ">= 0.0.86, ~> 0.0.86"
+ hashes = [
+ "h1:3qYPvX16dlnA2Bh2Z5xBQDeWWkKLikWsPGf5wr28MWQ=",
+ "zh:0024f491c4d8eabb19f58e05cf3217bbbdac73469c3cb8dee8e2646d1636e960",
+ "zh:00c482219d99e53b6802f2f7a2b1581ddcae88911fcbf09a7a26b18e62cad846",
+ "zh:19114e9acae41bbb79f5308a754492ace94c2df3ee21e0c99de51c5dcab3f775",
+ "zh:2568f82d9e587f6253d5e8bd9b9cf20ad7cfb403488af0928ee6dfd116f6635f",
+ "zh:35c046dabe79ede321b6d5a4fd37564a2cfd00f8d49be01ff17dbdb86fd847b0",
+ "zh:46b4300356f11726fd6675b6a7fe9c7293094f38ec1be5d6d5d659863eee3e8a",
+ "zh:46d5029dcdb6febd2c10b19da7b565c14915909da0e7576e33b56c4697c5bfe3",
+ "zh:46ffdfa96722e184b64bd04198170c91bf59976a5171aad5371b655481da346a",
+ "zh:91bc64b2f7da208e1f0d5a059026b6fb78d284e2f03ab15d1e7eb16cf707eed0",
+ "zh:ad1b94910840d6662ef21dea503998618e6ad5b0a340c63044bb5e913e926eeb",
+ "zh:bf3ecf8a22fdfab30f7eaac8b56bd321e5fd4e3a0a57fda15a3078c84e769a71",
+ "zh:e9bd846753e194c8fc2f2f3f53f15fd33f9fd56467963fd2766d1f713865bcad",
+ "zh:ecf36a184c1a01f9297e331d0b1671a99e3fbc179ceb2653efe0a74c61ec97ea",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ ]
+}
diff --git a/rds-postgres-server/specs/install/aws/main.tf b/rds-postgres-server/specs/install/aws/main.tf
new file mode 100644
index 0000000..2e073b3
--- /dev/null
+++ b/rds-postgres-server/specs/install/aws/main.tf
@@ -0,0 +1,114 @@
+################################################################################
+# Install — registers the rds-postgres-server service definition and its
+# agent association (notification channel) on a nullplatform account, and
+# (optionally) the account-level providers the "nullplatform Prerequisites"
+# section of ../../../README.md documents as required:
+#
+# - aws-configuration / aws-networking-configuration (account-level NRN):
+# build_context resolves account.region and vpc.id from these. Toggle
+# with create_account_providers — skip if another stack in this account
+# already registers them (they are shared, not specific to this service).
+# - aws-iam-configuration (account-level NRN): the AssumeRole target for
+# scripts/aws/assume_role_step, which resolves it via `np provider list
+# --categories identity-access-control` using the service's full NRN —
+# that call walks UP the NRN hierarchy, so registering this provider at
+# the account level (rather than namespace/application) is what makes it
+# resolve for every service/namespace under the account. This module is
+# the ONLY place that should create it for the account — the underlying
+# nullplatform_provider_config resource is a plain create with no
+# merge/upsert semantics, so a second, independent identity_access_control
+# resource (e.g. one created by rds-postgres-db's own install) would
+# register a SECOND aws-iam-configuration provider at the same NRN, and
+# assume_role_step's lookup would then nondeterministically pick one of
+# the two. rds-postgres-db does NOT create its own — pass its role ARN
+# via rds_postgres_db_role_arn to fold its selector into this single
+# provider instead.
+#
+# The AWS AssumeRole IAM role/policies themselves (the permissions role this
+# provider points at) live in ../../requirements/aws and are applied
+# separately — see that module's README and the "AssumeRole Setup Guide" in
+# ../../../README.md.
+################################################################################
+
+locals {
+ service_path = "rds-postgres-server"
+ available_links = ["connect"]
+ available_actions = []
+
+ account_nrn = replace(var.nrn, "/:namespace=[^:]*/", "")
+
+ iam_role_arns = concat(
+ [{ selector = "rds-postgres-server", arn = var.rds_postgres_server_role_arn }],
+ var.rds_postgres_db_role_arn != "" ? [{ selector = "rds-postgres-db", arn = var.rds_postgres_db_role_arn }] : []
+ )
+}
+
+module "service_definition" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition?ref=v4.5.1"
+
+ nrn = var.nrn
+ repository_org = var.repository_org
+ repository_name = var.repository_name
+ repository_branch = var.repository_branch
+ repository_token = var.repository_token
+ service_path = local.service_path
+ service_name = var.service_name
+ available_links = local.available_links
+ available_actions = local.available_actions
+}
+
+module "service_definition_agent_association" {
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/service_definition_agent_association?ref=v4.5.1"
+
+ nrn = var.nrn
+ repository_service_spec_repo = "${var.repository_org}/${var.repository_name}"
+ service_path = local.service_path
+ service_specification_slug = module.service_definition.service_specification_slug
+ api_key = var.np_api_key
+ tags_selectors = var.tags_selectors
+}
+
+# --- account-level providers: account.region + vpc.id ------------------------
+# Registered at the account NRN (var.nrn with :namespace=... stripped), same
+# as the manual examples in ../../../README.md. Typically applied once per
+# cluster/account — set create_account_providers = false if another stack
+# already registers these (e.g. shared with non-RDS services).
+
+module "aws_cloud_provider" {
+ count = var.create_account_providers ? 1 : 0
+
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/cloud?ref=v5.3.1"
+
+ nrn = local.account_nrn
+ domain_name = var.domain_name
+ hosted_private_zone_id = var.hosted_private_zone_id
+}
+
+module "vpc_provider" {
+ count = var.create_account_providers ? 1 : 0
+
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/cloud/aws/vpc?ref=v5.3.1"
+
+ nrn = local.account_nrn
+ vpc_id = var.vpc_id
+ vpc_subnets = var.vpc_subnets
+ vpc_security_groups = var.vpc_security_groups
+}
+
+# --- account-level provider: AssumeRole target -------------------------------
+# See the comment at the top of this file — this is the single source of the
+# aws-iam-configuration provider for the account; fold rds-postgres-db's role
+# in via rds_postgres_db_role_arn instead of registering it separately.
+
+module "identity_access_control" {
+ count = var.create_identity_access_control ? 1 : 0
+
+ source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/identity-access-control?ref=v5.3.1"
+
+ nrn = local.account_nrn
+ attributes = {
+ iam_role_arns = {
+ arns = local.iam_role_arns
+ }
+ }
+}
diff --git a/rds-postgres-server/specs/install/aws/outputs.tf b/rds-postgres-server/specs/install/aws/outputs.tf
new file mode 100644
index 0000000..e9e66d4
--- /dev/null
+++ b/rds-postgres-server/specs/install/aws/outputs.tf
@@ -0,0 +1,9 @@
+output "service_specification_id" {
+ description = "ID of the registered rds-postgres-server service specification."
+ value = module.service_definition.service_specification_id
+}
+
+output "service_specification_slug" {
+ description = "Slug of the registered rds-postgres-server service specification."
+ value = module.service_definition.service_specification_slug
+}
diff --git a/rds-postgres-server/specs/install/aws/terraform.tfvars.example b/rds-postgres-server/specs/install/aws/terraform.tfvars.example
new file mode 100644
index 0000000..c12526b
--- /dev/null
+++ b/rds-postgres-server/specs/install/aws/terraform.tfvars.example
@@ -0,0 +1,29 @@
+nrn = "" # namespace-level NRN, e.g. organization=:account=:namespace=
+np_api_key = ""
+
+tags_selectors = {
+ "environment" = ""
+}
+
+# repository_org = "nullplatform"
+# repository_name = "services-postgresql-rds"
+# repository_branch = "main"
+
+# --- account-level providers (account.region + vpc.id) -----------------------
+# Set create_account_providers = false if another stack in this account
+# already registers aws-configuration/aws-networking-configuration.
+create_account_providers = true
+domain_name = ""
+hosted_private_zone_id = ""
+vpc_id = ""
+vpc_subnets = ["", ""]
+vpc_security_groups = [""]
+
+# --- account-level provider (AssumeRole target) -------------------------------
+# rds_postgres_server_role_arn: output of ../../requirements/aws (apply that
+# module first). rds_postgres_db_role_arn: only set if rds-postgres-db is also
+# installed in this account — leave "" otherwise. See main.tf for why
+# rds-postgres-db should NOT register its own aws-iam-configuration provider.
+create_identity_access_control = true
+rds_postgres_server_role_arn = ""
+# rds_postgres_db_role_arn = ""
diff --git a/rds-postgres-server/specs/install/aws/variables.tf b/rds-postgres-server/specs/install/aws/variables.tf
new file mode 100644
index 0000000..982270f
--- /dev/null
+++ b/rds-postgres-server/specs/install/aws/variables.tf
@@ -0,0 +1,104 @@
+variable "nrn" {
+ description = "NullPlatform Resource Name (namespace-level, e.g. organization=:account=:namespace=) where the service definition is registered."
+ type = string
+}
+
+variable "np_api_key" {
+ description = "nullplatform API key used by the agent association to authenticate against the nullplatform API."
+ type = string
+ sensitive = true
+}
+
+variable "tags_selectors" {
+ description = "Agent tag selectors for the notification channel (must match the tags the target agent registers with)."
+ type = map(string)
+}
+
+variable "service_name" {
+ description = "Display name for the rds-postgres-server service in nullplatform."
+ type = string
+ default = "RDS Postgres Server"
+}
+
+variable "repository_org" {
+ description = "GitHub organization owning the services repository."
+ type = string
+ default = "nullplatform"
+}
+
+variable "repository_name" {
+ description = "Repository name containing the rds-postgres-server service spec templates."
+ type = string
+ default = "services-postgresql-rds"
+}
+
+variable "repository_branch" {
+ description = "Branch of the services repository to register the service spec/links/entrypoint from."
+ type = string
+ default = "main"
+}
+
+variable "repository_token" {
+ description = "Access token for private repositories. Unnecessary for a public repository."
+ type = string
+ default = null
+ sensitive = true
+}
+
+# --- account-level providers (account.region + vpc.id) -----------------------
+
+variable "create_account_providers" {
+ description = "Whether to register the aws-configuration/aws-networking-configuration providers at the account-level NRN. Set to false if another stack in this account already registers them (they are shared account-wide, not specific to rds-postgres-server)."
+ type = bool
+ default = true
+}
+
+variable "domain_name" {
+ description = "Domain name for the aws-configuration provider. Required when create_account_providers is true."
+ type = string
+ default = ""
+}
+
+variable "hosted_private_zone_id" {
+ description = "Private Route53 hosted zone ID for the aws-configuration provider. Required when create_account_providers is true."
+ type = string
+ default = ""
+}
+
+variable "vpc_id" {
+ description = "VPC ID for the aws-networking-configuration provider. Required when create_account_providers is true."
+ type = string
+ default = ""
+}
+
+variable "vpc_subnets" {
+ description = "Subnet IDs for the aws-networking-configuration provider. Pass whatever the cluster's VPC provider already uses for other scopes/services — this service only reads vpc.id from it, not this list. Required when create_account_providers is true."
+ type = list(string)
+ default = []
+}
+
+variable "vpc_security_groups" {
+ description = "Security group IDs for the aws-networking-configuration provider (e.g. the node/cluster security group). Required when create_account_providers is true."
+ type = list(string)
+ default = []
+}
+
+# --- account-level provider (AssumeRole target) -------------------------------
+
+variable "create_identity_access_control" {
+ description = "Whether to register the aws-iam-configuration provider at the account-level NRN (derived from var.nrn). This is the only place that should create it for the account — see the comment in main.tf. Set to false if it is already managed elsewhere."
+ type = bool
+ default = true
+}
+
+variable "rds_postgres_server_role_arn" {
+ description = "ARN of the rds-postgres-server permissions role (the permissions_role_arn output of ../../requirements/aws). Required when create_identity_access_control is true."
+ type = string
+ default = ""
+}
+
+variable "rds_postgres_db_role_arn" {
+ description = "ARN of the rds-postgres-db permissions role (the permissions_role_arn output of the rds-postgres-db requirements/aws module), if that service is also installed in this account. When set, its selector is folded into this same aws-iam-configuration provider instead of rds-postgres-db registering its own — see the comment in main.tf for why."
+ type = string
+ default = ""
+}
diff --git a/rds-postgres-server/specs/links/connect.json.tpl b/rds-postgres-server/specs/links/connect.json.tpl
new file mode 100644
index 0000000..329dcde
--- /dev/null
+++ b/rds-postgres-server/specs/links/connect.json.tpl
@@ -0,0 +1,66 @@
+{
+ "name": "Connect",
+ "slug": "connect",
+ "unique": false,
+ "assignable_to": "any",
+ "use_default_actions": true,
+ "selectors": {
+ "category": "Database",
+ "imported": false,
+ "provider": "AWS",
+ "sub_category": "Relational Database"
+ },
+ "attributes": {
+ "schema": {
+ "type": "object",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "required": ["db_name", "access_level"],
+ "properties": {
+ "db_name": {
+ "type": "string",
+ "title": "Database Name",
+ "description": "Name of the database to create inside the RDS instance",
+ "editableOn": ["create"],
+ "order": 1
+ },
+ "access_level": {
+ "enum": ["read", "write", "read-write"],
+ "type": "string",
+ "title": "Access Level",
+ "default": "read-write",
+ "editableOn": ["create", "update"],
+ "description": "Permission level: read (SELECT), write (INSERT/UPDATE/DELETE), read-write (both)",
+ "order": 2
+ },
+ "username": {
+ "type": "string",
+ "title": "DB Username",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database username (auto-populated after link creation)",
+ "order": 3
+ },
+ "password": {
+ "type": "string",
+ "title": "DB Password",
+ "export": {"type": "environment_variable", "secret": true},
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database password (auto-populated, delivered as secret env var)",
+ "order": 4
+ },
+ "database_name": {
+ "type": "string",
+ "title": "Database",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "Database name (auto-populated after link creation)",
+ "order": 5
+ }
+ }
+ },
+ "values": {}
+ }
+}
diff --git a/rds-postgres-server/specs/requirements/aws/.terraform.lock.hcl b/rds-postgres-server/specs/requirements/aws/.terraform.lock.hcl
new file mode 100644
index 0000000..f8d1ed5
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/.terraform.lock.hcl
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/hashicorp/aws" {
+ version = "6.53.0"
+ constraints = ">= 5.0.0"
+ hashes = [
+ "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=",
+ "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c",
+ "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8",
+ "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69",
+ "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87",
+ "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f",
+ "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc",
+ "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760",
+ "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147",
+ "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b",
+ "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b",
+ "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be",
+ "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46",
+ "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138",
+ "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc",
+ "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6",
+ ]
+}
diff --git a/rds-postgres-server/specs/requirements/aws/data.tf b/rds-postgres-server/specs/requirements/aws/data.tf
new file mode 100644
index 0000000..8fc4b38
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/data.tf
@@ -0,0 +1 @@
+data "aws_caller_identity" "current" {}
diff --git a/rds-postgres-server/specs/requirements/aws/locals.tf b/rds-postgres-server/specs/requirements/aws/locals.tf
new file mode 100644
index 0000000..01328b1
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/locals.tf
@@ -0,0 +1,13 @@
+locals {
+ iam_module_name = "requirements-rds-postgres-server"
+ iam_create = var.iam_create_role
+
+ role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role"
+ policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform-${var.cluster_name}"
+ agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role"
+
+ iam_default_tags = merge(var.iam_resource_tags_json, {
+ ManagedBy = "rds-postgres-server"
+ Module = local.iam_module_name
+ })
+}
diff --git a/rds-postgres-server/specs/requirements/aws/main.tf b/rds-postgres-server/specs/requirements/aws/main.tf
new file mode 100644
index 0000000..b068cf2
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/main.tf
@@ -0,0 +1,198 @@
+################################################################################
+# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole)
+################################################################################
+
+resource "aws_iam_role" "nullplatform_rds_postgres_server" {
+ count = local.iam_create ? 1 : 0
+
+ name = local.role_name
+ description = "Permissions role assumed by the nullplatform agent role for rds-postgres-server in cluster ${var.cluster_name}"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) }
+ Action = "sts:AssumeRole"
+ }]
+ })
+
+ tags = local.iam_default_tags
+}
+
+################################################################################
+# Policy attachments
+################################################################################
+
+resource "aws_iam_role_policy_attachment" "rds" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_server[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_policy[0].arn
+}
+
+resource "aws_iam_role_policy_attachment" "rds_sg" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_server[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_sg_policy[0].arn
+}
+
+resource "aws_iam_role_policy_attachment" "rds_secretsmanager" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_server[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn
+}
+
+resource "aws_iam_role_policy_attachment" "rds_s3" {
+ count = local.iam_create ? 1 : 0
+ role = aws_iam_role.nullplatform_rds_postgres_server[0].name
+ policy_arn = aws_iam_policy.nullplatform_rds_s3_policy[0].arn
+}
+
+################################################################################
+# RDS IAM policy
+################################################################################
+
+# Grant permissions to manage RDS instances and subnet groups
+resource "aws_iam_policy" "nullplatform_rds_policy" {
+ count = local.iam_create ? 1 : 0
+
+ name = "${local.policies_name_prefix}-rds-policy"
+ description = "Policy for managing RDS instances and subnet groups"
+
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "rds:CreateDBInstance",
+ "rds:DeleteDBInstance",
+ "rds:ModifyDBInstance",
+ "rds:DescribeDBInstances",
+ "rds:CreateDBSubnetGroup",
+ "rds:DeleteDBSubnetGroup",
+ "rds:DescribeDBSubnetGroups",
+ "rds:ModifyDBSubnetGroup",
+ "rds:AddTagsToResource",
+ "rds:ListTagsForResource",
+ "rds:RemoveTagsFromResource",
+ "rds:DescribeDBParameterGroups",
+ "rds:DescribeDBParameters",
+ "rds:DescribeDBEngineVersions",
+ "rds:DescribeOrderableDBInstanceOptions",
+ "rds:DescribeOptionGroups",
+ "iam:CreateServiceLinkedRole"
+ ],
+ "Resource" : "*"
+ }
+ ]
+ })
+}
+
+################################################################################
+# EC2 Security Group IAM policy
+################################################################################
+
+# Grant permissions to manage EC2 security groups for RDS
+resource "aws_iam_policy" "nullplatform_rds_sg_policy" {
+ count = local.iam_create ? 1 : 0
+
+ name = "${local.policies_name_prefix}-rds-sg-policy"
+ description = "Policy for managing EC2 security groups for RDS"
+
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "ec2:CreateSecurityGroup",
+ "ec2:DeleteSecurityGroup",
+ "ec2:DescribeSecurityGroups",
+ "ec2:AuthorizeSecurityGroupIngress",
+ "ec2:RevokeSecurityGroupIngress",
+ "ec2:AuthorizeSecurityGroupEgress",
+ "ec2:RevokeSecurityGroupEgress",
+ "ec2:DescribeVpcs",
+ "ec2:DescribeVpcAttribute",
+ "ec2:DescribeSubnets",
+ "ec2:CreateTags",
+ "ec2:DescribeNetworkInterfaces",
+ "ec2:DescribeSecurityGroupRules"
+ ],
+ "Resource" : "*"
+ }
+ ]
+ })
+}
+
+################################################################################
+# S3 IAM policy (per-service tfstate buckets: np-service-)
+################################################################################
+
+# Grant permissions to manage the per-link S3 bucket used to store tofu state
+resource "aws_iam_policy" "nullplatform_rds_s3_policy" {
+ count = local.iam_create ? 1 : 0
+
+ name = "${local.policies_name_prefix}-rds-s3-policy"
+ description = "Policy for managing per-service S3 tfstate buckets (np-service-*)"
+
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "s3:CreateBucket",
+ "s3:HeadBucket",
+ "s3:PutBucketVersioning",
+ "s3:ListBucket",
+ "s3:ListBucketVersions",
+ "s3:GetObject",
+ "s3:PutObject",
+ "s3:DeleteObject",
+ "s3:DeleteObjectVersion",
+ "s3:DeleteBucket"
+ ],
+ "Resource" : [
+ "arn:aws:s3:::np-service-*",
+ "arn:aws:s3:::np-service-*/*"
+ ]
+ }
+ ]
+ })
+}
+
+################################################################################
+# Secrets Manager IAM policy
+################################################################################
+
+# Grant permissions to manage Secrets Manager secrets for RDS master password
+resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" {
+ count = local.iam_create ? 1 : 0
+
+ name = "${local.policies_name_prefix}-rds-secretsmanager-policy"
+ description = "Policy for managing Secrets Manager secrets for RDS master password"
+
+ policy = jsonencode({
+ "Version" : "2012-10-17",
+ "Statement" : [
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "secretsmanager:CreateSecret",
+ "secretsmanager:DeleteSecret",
+ "secretsmanager:DescribeSecret",
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:PutSecretValue",
+ "secretsmanager:UpdateSecret",
+ "secretsmanager:TagResource",
+ "secretsmanager:UntagResource",
+ "secretsmanager:GetResourcePolicy",
+ "secretsmanager:ListSecretVersionIds"
+ ],
+ "Resource" : "*"
+ }
+ ]
+ })
+}
diff --git a/rds-postgres-server/specs/requirements/aws/output.tf b/rds-postgres-server/specs/requirements/aws/output.tf
new file mode 100644
index 0000000..041efb6
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/output.tf
@@ -0,0 +1,29 @@
+output "rds_policy_arn" {
+ description = "ARN of the RDS management policy"
+ value = local.iam_create ? aws_iam_policy.nullplatform_rds_policy[0].arn : ""
+}
+
+output "rds_sg_policy_arn" {
+ description = "ARN of the EC2 security group policy"
+ value = local.iam_create ? aws_iam_policy.nullplatform_rds_sg_policy[0].arn : ""
+}
+
+output "rds_secretsmanager_policy_arn" {
+ description = "ARN of the Secrets Manager policy"
+ value = local.iam_create ? aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn : ""
+}
+
+output "permissions_role_arn" {
+ description = "ARN of the rds-postgres-server permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)."
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].arn : ""
+}
+
+output "permissions_role_name" {
+ description = "Name of the rds-postgres-server permissions role"
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].name : ""
+}
+
+output "permissions_role_id" {
+ description = "ID of the rds-postgres-server permissions role"
+ value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].id : ""
+}
diff --git a/rds-postgres-server/specs/requirements/aws/variables.tf b/rds-postgres-server/specs/requirements/aws/variables.tf
new file mode 100644
index 0000000..d20b293
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/variables.tf
@@ -0,0 +1,50 @@
+variable "cluster_name" {
+ description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy names, and the default agent role ARN. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"."
+ type = string
+}
+
+variable "agent_role_arn" {
+ description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role."
+ type = string
+ default = ""
+
+ validation {
+ condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn))
+ error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/"
+ }
+}
+
+variable "additional_agent_role_arns" {
+ description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))])
+ error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/"
+ }
+}
+
+variable "role_name" {
+ description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-server-role."
+ type = string
+ default = ""
+}
+
+variable "policies_name_prefix" {
+ description = "Override for the IAM policy name prefix. Defaults to nullplatform-{cluster_name}."
+ type = string
+ default = ""
+}
+
+variable "iam_create_role" {
+ description = "Whether to create the permissions role and its policies. When false, the module produces no resources."
+ type = bool
+ default = true
+}
+
+variable "iam_resource_tags_json" {
+ description = "Tags to apply to IAM resources created by this module."
+ type = map(string)
+ default = {}
+}
diff --git a/rds-postgres-server/specs/requirements/aws/versions.tf b/rds-postgres-server/specs/requirements/aws/versions.tf
new file mode 100644
index 0000000..da078fc
--- /dev/null
+++ b/rds-postgres-server/specs/requirements/aws/versions.tf
@@ -0,0 +1,8 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ }
+ }
+}
diff --git a/rds-postgres-server/specs/service-spec.json.tpl b/rds-postgres-server/specs/service-spec.json.tpl
new file mode 100644
index 0000000..d60120d
--- /dev/null
+++ b/rds-postgres-server/specs/service-spec.json.tpl
@@ -0,0 +1,85 @@
+{
+ "name": "RDS PostgreSQL",
+ "slug": "rds-postgres",
+ "type": "dependency",
+ "unique": false,
+ "assignable_to": "any",
+ "use_default_actions": true,
+ "available_links": ["connect"],
+ "selectors": {
+ "category": "Database",
+ "imported": false,
+ "provider": "AWS",
+ "sub_category": "Relational Database"
+ },
+ "attributes": {
+ "schema": {
+ "type": "object",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "required": ["instance_class"],
+ "properties": {
+ "instance_class": {
+ "type": "string",
+ "title": "Instance Class",
+ "default": "db.t3.micro",
+ "enum": ["db.t3.micro", "db.t3.small", "db.t3.medium", "db.m5.large"],
+ "description": "RDS instance type (affects CPU and RAM)",
+ "editableOn": ["create", "update"],
+ "order": 1
+ },
+ "allocated_storage": {
+ "type": "number",
+ "title": "Storage (GB)",
+ "default": 20,
+ "minimum": 20,
+ "maximum": 1000,
+ "description": "Allocated storage size in GB",
+ "editableOn": ["create", "update"],
+ "order": 2
+ },
+ "postgres_version": {
+ "type": "string",
+ "title": "PostgreSQL Version",
+ "default": "16",
+ "enum": ["14", "15", "16"],
+ "description": "PostgreSQL major version (cannot be changed after creation)",
+ "editableOn": ["create"],
+ "order": 3
+ },
+ "hostname": {
+ "type": "string",
+ "title": "Hostname",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS endpoint hostname (auto-populated after creation)",
+ "order": 4
+ },
+ "port": {
+ "type": "number",
+ "title": "Port",
+ "export": true,
+ "visibleOn": ["read"],
+ "editableOn": [],
+ "description": "RDS port (auto-populated after creation)",
+ "order": 5
+ },
+ "db_instance_identifier": {
+ "type": "string",
+ "export": false,
+ "visibleOn": [],
+ "editableOn": [],
+ "description": "Internal AWS RDS instance identifier"
+ },
+ "master_secret_arn": {
+ "type": "string",
+ "export": false,
+ "visibleOn": [],
+ "editableOn": [],
+ "description": "ARN of the Secrets Manager secret holding master credentials (internal use)"
+ }
+ }
+ },
+ "values": {}
+ }
+}
diff --git a/rds-postgres-server/values.yaml b/rds-postgres-server/values.yaml
new file mode 100644
index 0000000..7237d93
--- /dev/null
+++ b/rds-postgres-server/values.yaml
@@ -0,0 +1,18 @@
+# RDS PostgreSQL Service — Static Configuration
+# These values are not exposed in the NP UI. They configure the execution
+# environment for the agent running this service.
+#
+# NOTE: In scripts, $VALUES is a FILE PATH (set by np service workflow exec --values).
+# It is NOT JSON content. Read values with yaml_value() from build_context.
+
+# Named AWS profile for local testing (e.g. SSO profile with RDS access)
+# If set and AWS_PROFILE is not already in the environment, build_context
+# will export it so Terraform and AWS CLI use the correct credentials.
+# Run "aws sso login --profile " before starting np-agent locally.
+aws_profile: ""
+
+# Provider categories the platform must resolve into CONTEXT.providers before
+# running workflow steps. identity-access-control is required by
+# scripts/aws/assume_role_step to look up the AssumeRole target ARN.
+provider_categories:
+ - identity-access-control
diff --git a/rds-postgres-server/workflows/aws/create.yaml b/rds-postgres-server/workflows/aws/create.yaml
new file mode 100644
index 0000000..ebe9a3a
--- /dev/null
+++ b/rds-postgres-server/workflows/aws/create.yaml
@@ -0,0 +1,38 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: REGION
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: apply
+
+ - name: write service outputs
+ type: script
+ file: $SERVICE_PATH/scripts/aws/write_service_outputs
diff --git a/rds-postgres-server/workflows/aws/delete.yaml b/rds-postgres-server/workflows/aws/delete.yaml
new file mode 100644
index 0000000..1f0e83c
--- /dev/null
+++ b/rds-postgres-server/workflows/aws/delete.yaml
@@ -0,0 +1,38 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: REGION
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: destroy
+
+ - name: cleanup tfstate bucket
+ type: script
+ file: $SERVICE_PATH/scripts/aws/delete_tfstate_bucket
diff --git a/rds-postgres-server/workflows/aws/link.yaml b/rds-postgres-server/workflows/aws/link.yaml
new file mode 100644
index 0000000..3378d47
--- /dev/null
+++ b/rds-postgres-server/workflows/aws/link.yaml
@@ -0,0 +1,57 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: REGION
+ type: environment
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: LINK_ID
+ type: environment
+ - name: LINK_NAME
+ type: environment
+ - name: SCOPE_ID
+ type: environment
+ - name: SCOPE_NRN
+ type: environment
+ - name: LINK_ACCESS_LEVEL
+ type: environment
+
+ - name: build permissions context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_permissions_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: apply
+
+ - name: write link outputs
+ type: script
+ file: $SERVICE_PATH/scripts/aws/write_link_outputs
diff --git a/rds-postgres-server/workflows/aws/unlink.yaml b/rds-postgres-server/workflows/aws/unlink.yaml
new file mode 100644
index 0000000..02f9e42
--- /dev/null
+++ b/rds-postgres-server/workflows/aws/unlink.yaml
@@ -0,0 +1,53 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: REGION
+ type: environment
+ - name: TFSTATE_BUCKET
+ type: environment
+ - name: LINK_ID
+ type: environment
+ - name: LINK_NAME
+ type: environment
+ - name: SCOPE_ID
+ type: environment
+ - name: SCOPE_NRN
+ type: environment
+ - name: LINK_ACCESS_LEVEL
+ type: environment
+
+ - name: build permissions context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_permissions_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: destroy
diff --git a/rds-postgres-server/workflows/aws/update.yaml b/rds-postgres-server/workflows/aws/update.yaml
new file mode 100644
index 0000000..ebe9a3a
--- /dev/null
+++ b/rds-postgres-server/workflows/aws/update.yaml
@@ -0,0 +1,38 @@
+provider_categories:
+ - identity-access-control
+steps:
+ - name: assume role
+ type: script
+ file: $SERVICE_PATH/scripts/aws/assume_role_step
+ output:
+ - name: AWS_ACCESS_KEY_ID
+ type: environment
+ - name: AWS_SECRET_ACCESS_KEY
+ type: environment
+ - name: AWS_SESSION_TOKEN
+ type: environment
+
+ - name: build context
+ type: script
+ file: $SERVICE_PATH/scripts/aws/build_context
+ output:
+ - name: OUTPUT_DIR
+ type: environment
+ - name: REGION
+ type: environment
+ - name: TOFU_MODULE_DIR
+ type: environment
+ - name: TOFU_INIT_VARIABLES
+ type: environment
+ - name: TOFU_VARIABLES
+ type: environment
+
+ - name: tofu
+ type: script
+ file: $SERVICE_PATH/scripts/aws/do_tofu
+ configuration:
+ TOFU_ACTION: apply
+
+ - name: write service outputs
+ type: script
+ file: $SERVICE_PATH/scripts/aws/write_service_outputs