This document explains the role management system in Constructive, covering the unified dynamic implementation with configurable role names.
Constructive uses PostgreSQL roles for authorization, following a membership-based model where users (LOGIN roles) are granted membership in base roles (NOLOGIN group roles).
These are group roles that define permission levels. They cannot log in directly but are granted to users:
| Role | Purpose | RLS Behavior |
|---|---|---|
anonymous |
Unauthenticated access | Subject to RLS |
authenticated |
Authenticated user access | Subject to RLS |
administrator |
Admin access | Bypasses RLS |
Users are roles with LOGIN capability that can connect to the database. They are granted membership in base roles to inherit permissions.
All role management is now handled by a single shared module in @pgpmjs/core/src/roles/index.ts. This provides:
- Configurable role names - defaults to canonical names (
anonymous,authenticated,administrator) but can be customized - Single source of truth - both
PgpmInit(CLI) andDbAdmin(test harness) use the same SQL generators - Concurrency safety - all operations include IF NOT EXISTS pre-checks and exception handling for TOCTOU safety
- Optional advisory locking - when
useLocks: true, wraps CREATE ROLE operations withpg_advisory_xact_lock
The shared module exports these SQL generators:
| Function | Purpose |
|---|---|
generateCreateBaseRolesSQL(roles?) |
Create base roles (anonymous, authenticated, administrator) |
generateCreateUserSQL(username, password, roles?, options?) |
Create a user with grants to base roles |
generateCreateTestUsersSQL(roles?) |
Create test users (app_user, app_admin) |
generateCreateUserWithGrantsSQL(username, password, rolesToGrant, options?) |
Create a user with grants to specified roles |
generateGrantRoleSQL(role, user) |
Grant a single role to a user |
generateRemoveUserSQL(username, roles?, options?) |
Remove a user and revoke grants |
Role names are configurable via the RoleMapping interface from @pgpmjs/types:
interface RoleMapping {
anonymous?: string; // default: 'anonymous'
authenticated?: string; // default: 'authenticated'
administrator?: string; // default: 'administrator'
default?: string; // default: 'anonymous'
}Command: pgpm admin-users bootstrap
Call Path:
pgpm admin-users bootstrap
-> pgpm/pgpm/src/commands/admin-users/bootstrap.ts
-> new PgpmInit(pgEnv)
-> init.bootstrapRoles(roles?)
-> generateCreateBaseRolesSQL(roles?)
What it does:
- Creates the three base roles:
anonymous,authenticated,administrator(or custom names viarolesparameter) - Sets role attributes (NOLOGIN, NOCREATEDB, etc.)
- Administrator gets BYPASSRLS, others do not
When to use:
- One-time setup when initializing a new database
- Must be run before adding any users
Command: pgpm admin-users add --test
Call Path:
pgpm admin-users add --test
-> pgpm/pgpm/src/commands/admin-users/add.ts
-> new PgpmInit(pgEnv)
-> init.bootstrapTestRoles(roles?, testUsers?)
-> generateCreateTestUsersSQL(roles?, testUsers?)
What it does:
- Creates
app_userandapp_adminwith credentials frompgpmDefaults.db.connection(or custom viatestUsersparameter) - Grants
anonymousandauthenticatedtoapp_user - Grants
anonymous,authenticated, andadministratortoapp_admin
When to use:
- Local development only
- Never on production (default passwords)
Prerequisites:
- Base roles must exist (run
pgpm admin-users bootstrapfirst)
Command: pgpm admin-users add --username X --password Y
Call Path:
pgpm admin-users add --username myuser --password mypass
-> pgpm/pgpm/src/commands/admin-users/add.ts
-> new PgpmInit(pgEnv)
-> init.bootstrapDbRoles(username, password, options?)
-> Dynamic SQL with parameterized username/password
What it does:
- Creates a LOGIN role with the provided username and password
- Grants
anonymousandauthenticatedto the new user
When to use:
- Production user creation
- Any time you need a user with custom credentials
Prerequisites:
- Base roles must exist (run
pgpm admin-users bootstrapfirst)
Command: pgpm admin-users remove --username X or pgpm admin-users remove --test
Call Path:
pgpm admin-users remove --username myuser
-> pgpm/pgpm/src/commands/admin-users/remove.ts
-> new PgpmInit(pgEnv)
-> init.removeDbRoles(username, options?)
-> Dynamic SQL with parameterized username
What it does:
- Revokes
anonymousandauthenticatedmemberships - Drops the role
Entry Point: getConnections() in postgres/pgsql-test/src/connect.ts
Call Path:
getConnections(options, seedAdapters)
-> getPgRootAdmin(config, connOpts)
-> new DbAdmin(opts, false, connOpts)
-> root.createUserRole(user, password, dbName, options?)
-> Dynamic SQL with configurable role names via getRoleName()
What it does:
- Creates a test user for connecting to the test database
- Grants
anonymous,authenticated, andadministratorroles - Uses
getRoleName()to support configurable role names viaPgTestConnectionOptions.roles
When to use:
- Automated test setup (Jest, etc.)
- CI/CD pipelines
- Any programmatic test database creation
Key Difference from CLI:
- Role names are configurable via
PgTestConnectionOptions.roles - Grants administrator role (for testing purposes only)
Entry Point: DbAdmin.grantRole() in postgres/pgsql-test/src/admin.ts
Call Path:
admin.grantRole(role, user, dbName, options?)
-> Dynamic SQL with pre-check and exception handling
What it does:
- Grants a single role to a user
- Pre-checks membership to avoid unnecessary operations
- Handles concurrent grant attempts gracefully
When to use:
- Flexible role granting in tests
- When you need to grant a specific role outside the standard patterns
When running tests via getConnections(), base roles must already exist. They are typically created by:
- PGPM deployment via seed adapter: The default
seed.pgpm()adapter deploys the PGPM module which may include role creation in migrations - Container initialization: Docker images may have roles pre-created
- Manual bootstrap: Running
pgpm admin-users bootstrapbefore tests
The getConnections() function assumes base roles exist when it calls createUserRole() to grant them.
The SQL files use hardcoded role names:
anonymousauthenticatedadministrator
The pgsql-test package supports configurable role names via PgTestConnectionOptions.roles:
const connections = await getConnections({
db: {
roles: {
anonymous: 'custom_anon',
authenticated: 'custom_auth',
administrator: 'custom_admin',
default: 'custom_anon'
}
}
});This is useful when:
- Testing with non-standard role names
- Running tests against databases with different role configurations
All role management operations are designed to be idempotent and safe under concurrent execution:
- IF NOT EXISTS pre-checks: Avoid unnecessary CREATE/GRANT attempts
- Exception handling: Catch and ignore race conditions (duplicate_object, unique_violation)
- Optional advisory locking: When
useLocks: true, wraps CREATE ROLE operations withpg_advisory_xact_lock(42, hashtext(username))
| Operation | Ignored Errors | Surfaced Errors |
|---|---|---|
| CREATE ROLE | 42710 (duplicate_object), 23505 (unique_violation) | 42501 (insufficient_privilege) |
| GRANT | 23505 (unique_violation), 42704 (undefined_object) | 42501 (insufficient_privilege), 0LP01 (invalid_grant_operation) |
| REVOKE/DROP | 42704 (undefined_object) | 2BP01 (dependent_objects_still_exist), 55006 (object_in_use), 42501 (insufficient_privilege) |
| Scenario | Mechanism | Entry Point |
|---|---|---|
| Initialize base roles | Static SQL | pgpm admin-users bootstrap |
| Add test users (dev only) | Static SQL | pgpm admin-users add --test |
| Add production user | Dynamic TS | pgpm admin-users add --username X --password Y |
| Remove user | Dynamic TS | pgpm admin-users remove |
| Automated test setup | Dynamic TS | getConnections() -> createUserRole() |
| Grant specific role | Dynamic TS | admin.grantRole() |
| File | Purpose |
|---|---|
pgpm/core/src/roles/index.ts |
Shared SQL generators (single source of truth) |
pgpm/core/src/init/client.ts |
PgpmInit class (CLI operations, uses shared module) |
postgres/pgsql-test/src/admin.ts |
DbAdmin class (test operations, uses shared module) |
postgres/pgsql-test/src/roles.ts |
Role name configuration helpers |
postgres/pgsql-test/src/connect.ts |
Test connection setup |
pgpm/types/src/pgpm.ts |
RoleMapping interface and defaults |
All role and test user defaults are sourced from pgpmDefaults in @pgpmjs/types:
| Default | Source |
|---|---|
| Role names | pgpmDefaults.db.roles (anonymous, authenticated, administrator) |
| Test user credentials | pgpmDefaults.db.connection (app_user/app_password) |
| Test admin credentials | Hardcoded (app_admin/admin_password) |