Amazon Redshift Database Security as Code (Versioned)
Managing warehouse access through ad-hoc GRANT and REVOKE commands leads to drift, and privileges
scattered across migration files make it hard to answer "who can read this column?" Atlas lets you define
Redshift roles, groups, users, and permissions as code. With the versioned workflow, each change is captured
in a migration file, reviewed by your team, and applied through CI/CD.
This guide covers the versioned workflow. For the declarative approach, see the declarative security guide.
Redshift support, including roles, groups, users, and permissions, is available only to Atlas Pro users. To use this feature, run:
atlas login
Prerequisites
- Atlas installed on your machine (installation guide)
- An Atlas Pro account (run
atlas loginto authenticate) - An Amazon Redshift provisioned cluster or Serverless workgroup, and a second one to use as a dev database
Project Setup
Atlas connects to Redshift over the PostgreSQL wire protocol. Point it at the cluster endpoint, and leave
search_path out of the URL: roles, groups, and users are cluster-wide, so the connection has to be scoped to
the database rather than to a single schema.
export REDSHIFT_URL="redshift://admin:$PROD_PASSWORD@atlas-prod.abc123.us-east-1.redshift.amazonaws.com:5439/analytics"
export REDSHIFT_DEV_URL="redshift://admin:$DEV_PASSWORD@atlas-dev.abc123.us-east-1.redshift.amazonaws.com:5439/analytics"
Roles and permissions are excluded from inspection and schema management by default. Enable them
per-environment with a schema.mode block, and point the environment at a migration directory:
- Atlas DDL (HCL)
- SQL
env "redshift" {
url = getenv("REDSHIFT_URL")
dev = getenv("REDSHIFT_DEV_URL")
schema {
src = "file://schema.rs.hcl"
mode {
roles = true // Inspect and manage roles, groups, and users
permissions = true // Inspect and manage GRANT / REVOKE
}
}
migration {
dir = "file://migrations"
}
}
env "redshift" {
url = getenv("REDSHIFT_URL")
dev = getenv("REDSHIFT_DEV_URL")
schema {
src = "file://schema.sql"
mode {
roles = true
permissions = true
}
}
migration {
dir = "file://migrations"
}
}
Note that sensitive = ALLOW is not set here. In the versioned workflow, passwords are not written into
migration files, so there is nothing to allow. See Passwords below.
Isolating the Dev Database
Redshift roles, groups, and users are cluster-wide rather than scoped to a database, and migrate diff
replays your migration directory and desired state on the dev database, creating and
dropping roles and users as it goes. A second database on the production cluster shares that cluster's roles
and users, so its teardown would collide with the live ones.
Give the dev environment a cluster of its own instead. A dedicated Serverless workgroup is the cheapest option, since it bills per query and can sit idle between diffs:
aws redshift-serverless create-namespace --namespace-name atlas-dev
aws redshift-serverless create-workgroup \
--workgroup-name atlas-dev --namespace-name atlas-dev --base-capacity 8
A Serverless workgroup exposes an endpoint just like a provisioned cluster, so the URL keeps the same shape:
export REDSHIFT_DEV_URL="redshift://admin:$DEV_PASSWORD@atlas-dev.123456789012.us-east-1.redshift-serverless.amazonaws.com:5439/analytics"
Either way, keep the dev environment out of your production account's access paths.
The user Atlas connects with is one of them, as are the admin account, IAM and IdP federated identities, and
other teams' roles. A role or user that exists on the cluster but is absent from your desired state is one
migrate diff plans to drop, including the caller's own access. Declare each one with external = true (see
External Roles and Users).
Defining the Desired State
The schema file is the target state Atlas diffs against the migration directory. Let's model a reporting warehouse:
| Name | Kind | Purpose |
|---|---|---|
rpt_readonly | RBAC role | Read-only access to the reporting schema |
rpt_writer | RBAC role | Read-write access for the ELT job, inherits from rpt_readonly |
rpt_analysts | Group | Human analysts, granted a narrower slice of the data |
rpt_etl | User | The ELT service account, a member of rpt_writer |
- Atlas DDL (HCL)
- SQL
schema "reporting" {
}
role "rpt_readonly" {
}
role "rpt_writer" {
member_of = [role.rpt_readonly]
}
// A group is a role carrying the group marker: Redshift keeps
// CREATE GROUP apart from CREATE ROLE, and only users can be
// members of a group.
role "rpt_analysts" {
group = true
}
user "rpt_etl" {
conn_limit = 10
member_of = [role.rpt_writer]
}
table "customers" {
schema = schema.reporting
column "id" {
type = int
null = false
}
column "email" {
type = varchar(255)
null = false
}
column "region" {
type = varchar(64)
null = false
}
}
table "orders" {
schema = schema.reporting
column "id" {
type = int
null = false
}
column "customer_id" {
type = int
null = false
}
column "total" {
type = int
null = false
}
}
// Reporting reads need access to the schema itself.
permission {
to = role.rpt_readonly
for = schema.reporting
privileges = [USAGE]
}
// Read-only: SELECT on every table.
permission {
for_each = [table.customers, table.orders]
for = each.value
to = role.rpt_readonly
privileges = [SELECT]
}
// The ELT job writes orders.
permission {
to = role.rpt_writer
for = table.orders
privileges = [INSERT, UPDATE]
}
// Analysts see only one column of customers, so the PII stays hidden.
permission {
to = role.rpt_analysts
for = table.customers.column.region
privileges = [SELECT]
}
CREATE SCHEMA "reporting";
CREATE ROLE "rpt_readonly";
CREATE ROLE "rpt_writer";
GRANT ROLE "rpt_readonly" TO ROLE "rpt_writer";
CREATE GROUP "rpt_analysts";
CREATE USER "rpt_etl" PASSWORD DISABLE CONNECTION LIMIT 10;
GRANT ROLE "rpt_writer" TO "rpt_etl";
CREATE TABLE "reporting"."customers" (
"id" integer NOT NULL,
"email" character varying(255) NOT NULL,
"region" character varying(64) NOT NULL
);
CREATE TABLE "reporting"."orders" (
"id" integer NOT NULL,
"customer_id" integer NOT NULL,
"total" integer NOT NULL
);
-- Reporting reads need access to the schema itself.
GRANT USAGE ON SCHEMA "reporting" TO ROLE "rpt_readonly";
-- Read-only: SELECT on every table.
GRANT SELECT ON TABLE "reporting"."customers" TO ROLE "rpt_readonly";
GRANT SELECT ON TABLE "reporting"."orders" TO ROLE "rpt_readonly";
-- The ELT job writes orders.
GRANT INSERT, UPDATE ON TABLE "reporting"."orders" TO ROLE "rpt_writer";
-- Analysts see only one column of customers.
GRANT SELECT ("region") ON TABLE "reporting"."customers" TO GROUP "rpt_analysts";
- Roles, groups, and users - A
roleblock is an RBAC role, the same block withgroup = trueis a group, and auserblock is a login user.member_ofbuilds the hierarchy across all three. - Grantees -
totakes a reference to arole(RBAC role or group), auser, or thePUBLICkeyword. - Targets -
fortakes a schema, table, view, materialized view, function, procedure, or a single column. - Grant option -
grantable = trueaddsWITH GRANT OPTION, which Redshift accepts only for a user grantee. for_eachkeeps permissions DRY: define the grant once, and Atlas expands it for every table at plan time.