Terraform provider for managing contained database users and role memberships via Entra ID (Azure AD) on Azure SQL and Azure PostgreSQL Flexible Server. SQL Server auth is not supported — Entra ID only.
graph TD
TF[Terraform Core]
subgraph Provider
PROV[provider.go]
RU[azsqlaccess_user]
RRM[azsqlaccess_database_role_member]
IFACE[DatabaseConnector interface]
MSSQL[database/mssql]
PG[database/postgres]
end
AZI[azidentity]
AZURESQL[(Azure SQL)]
PGFLEX[(PostgreSQL Flexible Server)]
TF --> PROV
PROV -->|engine=mssql| RU
PROV -->|engine=postgres| RU
PROV --> RRM
RU --> IFACE
RRM --> IFACE
IFACE --> MSSQL
IFACE --> PG
MSSQL --> AZI
PG --> AZI
MSSQL --> AZURESQL
PG --> PGFLEX
The engine is selected once in provider.Configure() and injected into all resources. Resources only ever import internal/database — never internal/database/mssql or internal/database/postgres. The SQL layer is invisible to the Terraform layer.
| Operation | MSSQL | PostgreSQL |
|---|---|---|
CreateUser (type=user) |
CREATE USER [upn] FROM EXTERNAL PROVIDER |
pgaadauth_create_principal(name, false, false) |
CreateUser (type=group) |
CREATE USER [name] FROM EXTERNAL PROVIDER WITH OBJECT_ID = 'oid' |
pgaadauth_create_principal_with_oid(name, oid, 'group', false, false) |
CreateUser (type=service_principal) |
CREATE USER [name] FROM EXTERNAL PROVIDER WITH OBJECT_ID = 'oid' |
pgaadauth_create_principal_with_oid(name, oid, 'service', false, false) |
GetUser |
SELECT ... FROM sys.database_principals WHERE name = @name |
SELECT oid::bigint FROM pg_roles WHERE rolname = $1 |
UpdateUser |
ALTER USER [name] WITH DEFAULT_SCHEMA = [schema] |
ALTER ROLE "name" SET search_path TO "schema" |
DeleteUser |
DROP USER IF EXISTS [name] |
DROP ROLE IF EXISTS "name" |
CreateRoleMember |
ALTER ROLE [role] ADD MEMBER [member] |
GRANT "role" TO "member" |
DeleteRoleMember |
ALTER ROLE [role] DROP MEMBER [member] |
REVOKE "role" FROM "member" |
GetRoleMember |
SELECT ... FROM sys.database_role_members |
SELECT ... FROM pg_auth_members |
terraform {
required_providers {
azsqlaccess = {
source = "MewsSystems/azsqlaccess"
}
}
}
# MSSQL provider instance
provider "azsqlaccess" {
engine = "mssql"
# Optional. See "Authentication" below for the full credential precedence.
# Omit entirely for `az login` / Managed Identity / Workload Identity (ambient chain).
tenant_id = "..." # or AZURE_TENANT_ID / ARM_TENANT_ID env var
client_id = "..." # or AZURE_CLIENT_ID / ARM_CLIENT_ID env var
client_secret = "..." # or AZURE_CLIENT_SECRET / ARM_CLIENT_SECRET env var
}
# PostgreSQL provider instance (alias required when using both engines)
provider "azsqlaccess" {
alias = "postgres"
engine = "postgres"
}| Attribute | Required | Description |
|---|---|---|
engine |
yes | "mssql" or "postgres" |
tenant_id |
no | Entra tenant ID. Falls back to AZURE_TENANT_ID then ARM_TENANT_ID |
client_id |
no | Service principal client ID. Falls back to AZURE_CLIENT_ID then ARM_CLIENT_ID |
client_secret |
no | Service principal secret. Falls back to AZURE_CLIENT_SECRET then ARM_CLIENT_SECRET. Sensitive |
Creates a contained Entra identity inside a database. The type attribute determines how the principal is resolved on both engines.
type |
name |
object_id |
Notes |
|---|---|---|---|
"user" |
UPN — "juan.perez@milanesa.com" |
must not be set | UPNs are globally unique in Entra |
"group" |
Display name — "db.reader" |
required | Multiple groups can share a display name |
"service_principal" |
Display name — "myapp-identity" |
required | Covers both app registrations and managed identities |
The object_id is the Object (principal) ID shown in the Azure portal — the same value for both engines.
| Attribute | Type | Description |
|---|---|---|
server |
string, required | Fully-qualified hostname, e.g. myserver.database.windows.net |
database |
string, required | Target database name |
type |
string, required | "user", "group", or "service_principal" |
name |
string, required | UPN (user) or display name (group, service_principal) |
object_id |
string, required for group/sp | Entra Object (principal) ID UUID. Forbidden for type=user |
default_schema |
string, optional/computed | Default schema. MSSQL: DEFAULT_SCHEMA (default dbo). PostgreSQL: search_path (default public) |
principal_id |
int64, computed | sys.database_principals.principal_id (MSSQL) or pg_roles.oid (PostgreSQL) |
id |
string, computed | See import format below |
# type = user
terraform import azsqlaccess_user.example \
"myserver.database.windows.net/mydb/user/juan.perez@milanesa.com"
# type = group (object_id is part of the ID — required field, taken from config/import, not read back)
terraform import azsqlaccess_user.example \
"myserver.database.windows.net/mydb/group/db.reader/00000000-0000-0000-0000-000000000000"
# type = service_principal
terraform import azsqlaccess_user.example \
"myserver.database.windows.net/mydb/service_principal/myapp-identity/00000000-0000-0000-0000-000000000000"Grants one database role to one member. No Update — all attributes force replacement.
| Attribute | Type | Description |
|---|---|---|
server |
string, required | Fully-qualified hostname |
database |
string, required | Target database name |
role |
string, required | Database role name, e.g. db_datareader or pg_read_all_data |
member |
string, required | Principal name — use azsqlaccess_user.<name>.name to declare the dependency |
id |
string, computed | server/database/role/member |
terraform import azsqlaccess_database_role_member.example \
"myserver.database.windows.net/mydb/db_datareader/juan.perez@milanesa.com"provider "azsqlaccess" {
engine = "mssql"
}
resource "azsqlaccess_user" "mssql_user" {
server = "myserver.database.windows.net"
database = "mydb"
type = "user"
name = "juan.perez@milanesa.com"
}
resource "azsqlaccess_user" "mssql_group" {
server = "myserver.database.windows.net"
database = "mydb"
type = "group"
name = "db.reader"
object_id = "00000000-0000-0000-0000-000000000000"
}
resource "azsqlaccess_user" "mssql_managed_identity" {
server = "myserver.database.windows.net"
database = "mydb"
type = "service_principal"
name = "myapp-identity"
object_id = "00000000-0000-0000-0000-000000000000"
}
resource "azsqlaccess_database_role_member" "mssql_user_reader" {
server = "myserver.database.windows.net"
database = "mydb"
role = "db_datareader"
member = azsqlaccess_user.mssql_user.name
}
resource "azsqlaccess_database_role_member" "mssql_group_reader" {
server = "myserver.database.windows.net"
database = "mydb"
role = "db_datareader"
member = azsqlaccess_user.mssql_group.name
}
resource "azsqlaccess_database_role_member" "mssql_mi_reader" {
server = "myserver.database.windows.net"
database = "mydb"
role = "db_datareader"
member = azsqlaccess_user.mssql_managed_identity.name
}provider "azsqlaccess" {
alias = "postgres"
engine = "postgres"
}
resource "azsqlaccess_user" "postgres_user" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
type = "user"
name = "juan.perez@milanesa.com"
}
resource "azsqlaccess_user" "postgres_group" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
type = "group"
name = "db.reader"
object_id = "00000000-0000-0000-0000-000000000000"
}
resource "azsqlaccess_user" "postgres_managed_identity" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
type = "service_principal"
name = "myapp-identity"
object_id = "00000000-0000-0000-0000-000000000000"
}
resource "azsqlaccess_database_role_member" "postgres_user_reader" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
role = "pg_read_all_data"
member = azsqlaccess_user.postgres_user.name
}
resource "azsqlaccess_database_role_member" "postgres_group_reader" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
role = "pg_read_all_data"
member = azsqlaccess_user.postgres_group.name
}
resource "azsqlaccess_database_role_member" "postgres_mi_reader" {
provider = azsqlaccess.postgres
server = "myserver.postgres.database.azure.com"
database = "mydb"
role = "pg_read_all_data"
member = azsqlaccess_user.postgres_managed_identity.name
}The provider resolves an Entra credential once at startup and shares it across both engines. The precedence mirrors azurerm/azuread/azapi so pipelines wired for those providers work without changes:
- Service principal —
tenant_id+client_id+client_secretall set (HCL orAZURE_*/ARM_*env vars). - GitHub Actions OIDC —
ARM_USE_OIDC=true. The provider performs the federation exchange against the runner-injectedACTIONS_ID_TOKEN_REQUEST_URL/_TOKENautomatically — no extra workflow step required. - Explicit OIDC token (non-GitHub CI) —
ARM_USE_OIDC=trueplusARM_OIDC_TOKEN(raw JWT) orARM_OIDC_TOKEN_FILE_PATH(path to a file containing the JWT). - Ambient chain — fallback. An explicit
ChainedTokenCredentialof Azure CLI → Workload Identity → Managed Identity (az login). Interactive browser / IDE / dev-CLI credentials are deliberately excluded.
export AZURE_TENANT_ID="..." # or ARM_TENANT_ID
export AZURE_CLIENT_ID="..." # or ARM_CLIENT_ID
export AZURE_CLIENT_SECRET="..." # or ARM_CLIENT_SECRETConfigure a federated credential on the SP for repo:<owner>/<repo>:ref:refs/heads/<branch>, then in the workflow:
permissions:
id-token: write
contents: read
env:
ARM_USE_OIDC: 'true'
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}az login
az account set --subscription "..."Both engines now share the same azcore.TokenCredential. MSSQL uses go-mssqldb's NewAccessTokenConnector to inject a token into each new connection (audience https://database.windows.net/.default). PostgreSQL acquires a token (audience https://ossrdbms-aad.database.windows.net/.default) and writes it as the connection password via pgx's BeforeConnect hook. Tokens are acquired per new pool connection, so they never go stale during long applies.
PostgreSQL prerequisite: Entra authentication must be enabled on the Flexible Server:
Azure Portal → PostgreSQL Flexible Server → Authentication → set method to "PostgreSQL and Microsoft Entra authentication" → set a Microsoft Entra admin → Save
- Go >= 1.25.11
- Terraform >= 1.5
go build ./... # compile
go test ./... # unit tests (no Azure required)
go test ./... -cover # tests with statement coverage
go mod tidy # sync dependencies
make fmt # gofmt -s -w -e .
make generate # regenerate docs/ from schema MarkdownDescription + examples/This repo has two Go modules:
- the root module (
./go.mod) — the provider runtime - the tools module (
./tools/go.mod) —tfplugindocs, recorded via thetools/tools.goblank-import pattern under a//go:build generatebuild tag
The tools module is the tricky one. tfplugindocs is a binary (main package), so Go's package resolver refuses to traverse it from tools/tools.go. That means go get -u ./... inside tools/ updates none of the direct deps — you have to ask for them by module path.
# Root module — direct + indirect
go get -u ./...
go mod tidy
# Tools module — name each tool binary explicitly
cd tools
go get -u github.com/hashicorp/terraform-plugin-docs
go mod tidy
cd ..
# Verify everything still works
go build ./...
go test ./... -count=1 -race
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run
make generateTo check what's pinned vs. available before running anything:
go list -u -m all # root
go list -u -m -C tools all # toolsgo get -u upgrades to the latest compatible minor/patch. Major bumps (e.g. pgx/v5 → pgx/v6) require an explicit go get github.com/jackc/pgx/v6 because the module path changes.
GitHub Actions versions in .github/workflows/*.yml are pinned by SHA and not managed by Go modules — those are bumped by the Dependabot github_actions updater (or by hand-editing the @<sha> strings).
CI runs golangci-lint against the v2 config in .golangci.yml. The same lint suite can be run locally — pick whichever way fits your setup:
# Option 1 — Makefile (requires golangci-lint already installed and built
# against Go ≥ 1.25; install via `brew install golangci-lint` or
# https://golangci-lint.run/welcome/install/).
make lint
# Option 2 — go run, no install required. Uses the project's Go toolchain
# automatically, so it always matches the version targeted by CI.
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest runBoth fail the build on any reported issue. Run make fmt first to auto-format, then re-run lint; the most common failures are formatting, unchecked type assertions (forcetypeassert), and unused identifiers (unused).
The acceptance suite hits real Azure (long-lived test SQL + Postgres servers, real Entra principals). Prereqs, repo Secrets/Variables, and the local .env flow are all documented in tests/acceptance/README.md. TL;DR:
az login && az account set --subscription "<sub>"
source .env # gitignored; see tests/acceptance/README.md for keys
make testacc-only # acceptance tests only
# or: make testacc # full suite (unit + acceptance)CI runs the same suite via the manually-dispatched Acceptance Tests workflow; auth auto-selects GitHub Actions OIDC when AZURE_CLIENT_SECRET is unset, client-secret otherwise.
The provider is not published to the Terraform registry. To test against a real Azure environment:
1. Build and install
go install .
# produces ~/go/bin/terraform-provider-azsqlaccess2. Create a dev override config
# dev.tfrc
provider_installation {
dev_overrides {
"MewsSystems/azsqlaccess" = "/Users/<you>/go/bin" # output of: go env GOPATH + /bin
}
direct {}
}3. Point Terraform at it
export TF_CLI_CONFIG_FILE=/path/to/dev.tfrc4. Skip terraform init
With dev overrides, Terraform loads the binary from disk and skips the registry. terraform init will fail — that is expected. Go straight to terraform plan / terraform apply.
5. Rebuild after code changes
go install . && terraform apply# Users
import {
to = azsqlaccess_user.mssql_user
id = "${local.mssql_server}/mydb/user/juan.perez@milanesa.com"
}
import {
to = azsqlaccess_user.mssql_group
id = "${local.mssql_server}/mydb/group/db.reader/00000000-0000-0000-0000-000000000000"
}
import {
to = azsqlaccess_user.mssql_managed_identity
id = "${local.mssql_server}/mydb/service_principal/myapp-identity/00000000-0000-0000-0000-000000000000"
}
# Role members
import {
to = azsqlaccess_database_role_member.mssql_user_reader
id = "${local.mssql_server}/mydb/db_datareader/juan.perez@milanesa.com"
}
import {
to = azsqlaccess_database_role_member.mssql_group_reader
id = "${local.mssql_server}/mydb/db_datareader/db.reader"
}
import {
to = azsqlaccess_database_role_member.mssql_mi_reader
id = "${local.mssql_server}/mydb/db_datareader/myapp-identity"
}List all Entra users:
SELECT
name,
type_desc,
principal_id,
CONVERT(uniqueidentifier, sid) AS object_id -- canonical Entra object ID for contained users
FROM sys.database_principals
WHERE type IN ('E', 'X') -- E = external user, X = external group
ORDER BY nameList role memberships:
SELECT
r.name AS role,
m.name AS member
FROM sys.database_role_members rm
JOIN sys.database_principals r ON rm.role_principal_id = r.principal_id
JOIN sys.database_principals m ON rm.member_principal_id = m.principal_id
ORDER BY r.name, m.nameList all Entra roles:
SELECT
r.rolname AS name,
r.oid AS principal_id
FROM pg_roles r
WHERE pg_catalog.shobj_description(r.oid, 'pg_authid') IS NOT NULL
OR r.rolname NOT LIKE 'pg_%'
ORDER BY r.rolnameList role memberships:
SELECT
r.rolname AS role,
m.rolname AS member
FROM pg_auth_members am
JOIN pg_roles r ON am.roleid = r.oid
JOIN pg_roles m ON am.member = m.oid
ORDER BY r.rolname, m.rolnameCheck if pgaadauth is available:
SELECT COUNT(*) FROM pg_proc WHERE proname = 'pgaadauth_create_principal';
-- 0 = Entra auth not enabled on this server; 1 = ready