Skip to content

Commit 0e6e33f

Browse files
committed
BREAKING CHANGE: create superuser
The action used to create a superuser that hasn't been exposed to this action consumers. The superuser has been named after GitHub Action's system user (i.e. runner), had no password set and could have been only used via psql/createuser and other standard tools. I couldn't explain why I did this in the first place, probably I got confused a number of iterations troubleshooting Windows builds where things a bit different. That's why I decided to change this and create and expose the superuser instead of unprivileged user. It's quite convenient to have a superuser since it might be used in tests to dynamically create unprivileged users, create and remove databases, change some settings, etc. Another reason is that it's impossible to guess what privileges consumers expect from their unprivileged users. It would be better to let them craft unprivileged users the way they want it.
1 parent 3973215 commit 0e6e33f

File tree

4 files changed

+141
-39
lines changed

4 files changed

+141
-39
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626

2727
- name: Run tests
2828
run: |
29-
python3 -m pip install --upgrade pip pytest psycopg
29+
python3 -m pip install --upgrade pip pytest psycopg furl
3030
python3 -m pytest -vv test_action.py
3131
env:
3232
CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }}
@@ -55,7 +55,7 @@ jobs:
5555

5656
- name: Run tests
5757
run: |
58-
python3 -m pip install --upgrade pip pytest psycopg
58+
python3 -m pip install --upgrade pip pytest psycopg furl
5959
python3 -m pytest -vv test_action.py
6060
env:
6161
CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }}

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@
33
This action sets up a PostgreSQL server for the rest of the job. Here are some
44
key features:
55

6-
* Runs on Linux, macOS and Windows runners.
7-
* Adds PostgreSQL [binaries][1] (e.g. `psql`) to `PATH`.
8-
* Uses PostgreSQL installed in [GitHub Actions Virtual Environments][2].
9-
* [Easy to check][3] that IT DOES NOT contain malicious code.
6+
* Runs on Linux, macOS and Windows action runners.
7+
* Adds PostgreSQL [client applications][1] to `PATH`.
8+
* Uses PostgreSQL binaries baked into [GitHub Actions Runner Images][2].
9+
* Easy [to prove][3] that it DOES NOT contain malicious code.
1010

1111
[1]: https://www.postgresql.org/docs/current/reference-client.html
12-
[2]: https://github.com/actions/virtual-environments
12+
[2]: https://github.com/actions/runner-images
1313
[3]: action.yml
1414

1515
## Usage
1616

17+
Connection parameters:
18+
1719
| Key | Value |
1820
|----------|-----------------------------------------------------|
1921
| URI | `postgresql://postgres:postgres@localhost/postgres` |
@@ -23,6 +25,13 @@ key features:
2325
| Password | `postgres` |
2426
| Database | `postgres` |
2527

28+
User permissions:
29+
30+
| Key | Value |
31+
|-------------|-------|
32+
| usesuper | true |
33+
| usecreatedb | true |
34+
2635
#### Basic
2736

2837
```yaml
@@ -47,6 +56,13 @@ steps:
4756
DATABASE_URI: ${{ steps.postgres.outputs.connection-uri }}
4857
```
4958
59+
## Rationale
60+
61+
At the time of developing there were no GitHub Actions on the marketplace to
62+
setup a PostgreSQL server on Linux, Windows and macOS action runners. Most
63+
solutions suggest using Docker which is not available on macOS and Windows [^1]
64+
runners.
65+
5066
## License
5167
5268
The scripts and documentation in this project are released under the

action.yml

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,42 +38,52 @@ runs:
3838
fi
3939
shell: bash
4040

41-
4241
- name: Setup and start PostgreSQL
4342
run: |
4443
export PGDATA="$RUNNER_TEMP/pgdata"
45-
pg_ctl init --options="--encoding=UTF-8 --locale=en_US.UTF-8"
4644
47-
# Forbid creating unix sockets since they are created by default in the
48-
# directory we don't have permissions to.
45+
# There are couple of reasons why we need to create a new PostgreSQL
46+
# database cluster. First and foremost, we have to create a superuser
47+
# with provided credentials. Second, we want the PostgreSQL to be
48+
# executed from current user in order to ensure that PostgreSQL's
49+
# built-in tooling (e.g. psql) just works under any operating system
50+
# without "run from another user" magic.
51+
initdb \
52+
--username="${{ inputs.username }}" \
53+
--encoding="UTF-8" \
54+
--locale="en_US.UTF-8" \
55+
--no-instructions
56+
57+
# Do not create unix sockets since they are created by default in the
58+
# directory we have no permissions to (owned by system postgres user).
4959
echo "unix_socket_directories = ''" >> "$PGDATA/postgresql.conf"
5060
echo "port = ${{ inputs.port }}" >> "$PGDATA/postgresql.conf"
5161
pg_ctl start
5262
53-
# Both PGHOST and PGUSER are used by PostgreSQL tooling such as 'psql'
54-
# or 'createuser'. Since PostgreSQL data has been resetup, we cannot
55-
# rely on defaults anymore.
63+
# Set environment variables for PostgreSQL tools such as 'createuser'
64+
# or 'psql'. Since PostgreSQL has been reconfigured, we cannot rely
65+
# on defaults anymore.
5666
#
57-
# PGHOST is required for Linux and macOS since they default to unix
58-
# sockets, and we have turned them off.
67+
# PGHOST is required for Linux/macOS since we turned off unix sockets
68+
# and they use them by default.
5969
#
60-
# PGUSER is required for Windows since default the tooling's default
61-
# user is 'postgres', while 'pg_ctl init' creates one with the name of
62-
# the current user.
70+
# PGPORT, PGUSER and PGDATABASE are required because they could be
71+
# parametrized by action users.
6372
echo "PGHOST=localhost" >> $GITHUB_ENV
64-
echo "PGUSER=${USER:-$USERNAME}" >> $GITHUB_ENV
6573
echo "PGPORT=${{ inputs.port }}" >> $GITHUB_ENV
74+
echo "PGUSER=${{ inputs.username }}" >> $GITHUB_ENV
75+
echo "PGDATABASE=${{ inputs.database }}" >> $GITHUB_ENV
6676
shell: bash
6777

68-
- name: Setup PostgreSQL user and database
78+
- name: Setup PostgreSQL database
6979
run: |
70-
createuser --createdb ${{ inputs.username }}
71-
72-
if [ "${{ inputs.database}}" != "postgres" ]; then
73-
createdb -O ${{ inputs.username }} ${{ inputs.database }}
80+
# The 'postgres' database is a pre-created database meant for use by
81+
# users, utilities and third party applications. There's no way to
82+
# parametrize the name, so all we can do is to avoid creating a
83+
# database if provided name is 'postgres'.
84+
if [ "${{ inputs.database }}" != "postgres" ]; then
85+
createdb -O "${{ inputs.username }}" "${{ inputs.database }}"
7486
fi
75-
76-
psql -c "ALTER USER ${{ inputs.username }} PASSWORD '${{ inputs.password }}';" ${{ inputs.database }}
7787
shell: bash
7888

7989
- name: Expose connection URI

test_action.py

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
1-
import typing as t
1+
import locale
22
import os
3+
import subprocess
4+
import typing as t
35

46
import psycopg
7+
import furl
58
import pytest
69

710

11+
ConnectionFactory = t.Callable[[str], psycopg.Connection]
12+
13+
14+
@pytest.fixture(scope="function")
15+
def connection_uri() -> str:
16+
"""Read and return connection URI from environment."""
17+
18+
connection_uri = os.getenv("CONNECTION_URI")
19+
if connection_uri is None:
20+
pytest.fail("CONNECTION_URI: environment variable is not set")
21+
return connection_uri
22+
23+
824
@pytest.fixture(scope="function")
9-
def connection_factory() -> t.Callable[[str], psycopg.Connection]:
25+
def connection_factory() -> ConnectionFactory:
26+
"""Return 'psycopg.Connection' factory."""
27+
1028
def factory(connection_uri: str) -> psycopg.Connection:
1129
return psycopg.connect(connection_uri)
1230
return factory
1331

1432

1533
@pytest.fixture(scope="function")
16-
def connection(connection_factory) -> psycopg.Connection:
17-
return connection_factory(os.getenv("CONNECTION_URI"))
34+
def connection(connection_uri: str, connection_factory: ConnectionFactory) -> psycopg.Connection:
35+
"""Return 'psycopg.Connection' for connection URI set in environment."""
36+
37+
return connection_factory(connection_uri)
1838

1939

2040
def test_connection_uri():
@@ -34,12 +54,15 @@ def test_server_encoding(connection: psycopg.Connection):
3454
def test_locale(connection: psycopg.Connection):
3555
"""Test that PostgreSQL's locale is 'en_US.UTF-8'."""
3656

37-
assert connection.execute("SHOW LC_COLLATE").fetchone()[0] == "en_US.UTF-8"
38-
assert connection.execute("SHOW LC_CTYPE").fetchone()[0] == "en_US.UTF-8"
57+
lc_collate = connection.execute("SHOW LC_COLLATE").fetchone()[0]
58+
lc_ctype = connection.execute("SHOW LC_CTYPE").fetchone()[0]
59+
60+
assert locale.normalize(lc_collate) == "en_US.UTF-8"
61+
assert locale.normalize(lc_ctype) == "en_US.UTF-8"
3962

4063

4164
def test_user_permissions(connection: psycopg.Connection):
42-
"""Test that a user can create databases but is not a superuser."""
65+
"""Test that a user has super/createdb permissions."""
4366

4467
with connection:
4568
record = connection \
@@ -49,7 +72,7 @@ def test_user_permissions(connection: psycopg.Connection):
4972

5073
usecreatedb, usesuper = record
5174
assert usecreatedb
52-
assert not usesuper
75+
assert usesuper
5376

5477

5578
def test_user_create_insert_select(connection: psycopg.Connection):
@@ -82,12 +105,65 @@ def test_user_create_insert_non_ascii(connection: psycopg.Connection):
82105

83106

84107
def test_user_create_drop_database(connection: psycopg.Connection):
85-
"""Test that a user has no permissions to create databases."""
108+
"""Test that a user has permissions to create databases."""
86109

87110
# CREATE/DROP DATABASE statements don't work within transactions, and with
88111
# autocommit disabled transactions are created by psycopg automatically.
89112
connection.autocommit = True
90113

91-
database_name = "foobar42"
92-
connection.execute(f"CREATE DATABASE {database_name}")
93-
connection.execute(f"DROP DATABASE {database_name}")
114+
database = "databas3"
115+
connection.execute(f"CREATE DATABASE {database}")
116+
connection.execute(f"DROP DATABASE {database}")
117+
118+
119+
def test_user_create_drop_user(
120+
connection: psycopg.Connection,
121+
connection_factory: ConnectionFactory,
122+
connection_uri: str
123+
):
124+
"""Test that a user has permissions to create users."""
125+
126+
# CREATE/DROP USER statements don't work within transactions, and with
127+
# autocommit disabled transactions are created by psycopg automatically.
128+
connection.autocommit = True
129+
130+
username = "us3rname"
131+
password = "passw0rd"
132+
database = "databas3"
133+
134+
connection.execute(f"CREATE USER {username} WITH PASSWORD '{password}'")
135+
connection.execute(f"CREATE DATABASE {database} WITH OWNER '{username}'")
136+
137+
try:
138+
# Smoke test that created user can successfully log-in and execute
139+
# queries for its own database.
140+
connection_uri = furl.furl(
141+
connection_uri, username=username, password=password, path=database).url
142+
test_user_create_insert_select(connection_factory(connection_uri))
143+
144+
finally:
145+
connection.execute(f"DROP DATABASE {database}")
146+
connection.execute(f"DROP USER {username}")
147+
148+
149+
def test_client_applications(connection_uri, connection_factory):
150+
"""Test that PostgreSQL client applications can be used."""
151+
152+
username = "us3rname"
153+
password = "passw0rd"
154+
database = "databas3"
155+
156+
subprocess.check_call(["createuser", username])
157+
subprocess.check_call(["createdb", "--owner", username, database])
158+
subprocess.check_call(["psql", "-c", f"ALTER USER {username} WITH PASSWORD '{password}'"])
159+
160+
try:
161+
# Smoke test that created user can successfully log-in and execute
162+
# queries for its own database.
163+
connection_uri = furl.furl(
164+
connection_uri, username=username, password=password, path=database).url
165+
test_user_create_insert_select(connection_factory(connection_uri))
166+
167+
finally:
168+
subprocess.check_call(["dropdb", database])
169+
subprocess.check_call(["dropuser", username])

0 commit comments

Comments
 (0)