PG Phriday: Corralling the Hordes

Ah, users. They log in, query things, accidentally delete critical data, and drop tables for giggles. Bane or boon, user accounts are a necessary evil in all databases for obvious reasons. And what would any stash of data be if nobody had access? Someone needs to own the objects, at the very least. So how can we be responsible with access vectors while hosting a Postgres database? We already covered automating grants, so let’s progress to the next step: building a “best practice” access stack.

More experienced (and smarter) people than me have given this process a lot of thought, so why not learn from one of their implementations? In UNIX systems, file access is handled through up to nine grants based on the owner, an arbitrary group, or the unwashed masses, each with read, write, or execute permissions. In the context of Postgres, the shambling hordes can be associated with PUBLIC, USER with ownership, and GROUP for the usual buckets. Similarly, we have SELECT for read, INSERT, UPDATE, or DELETE for write, and EXECUTE as well.

This means we actually have a bit more control in certain areas than UNIX filesystems. In fact, since multiple users or groups have distinct privileges on each object, we have the opportunity to be really creative. If we start with the analogous case, but expand it by leveraging multiple groups, we have a set of standardized roles that resemble a filesystem’s privileges, but with greater granularity. We can even create something similar to a bitmask, so all new objects adhere to our desired grant structure.

So how do we start? The easy case would have the owner, a group for writing, a group for reading, and a group for function execution. Let’s set it up and include the default ACLs from the previous article:

CREATE USER myapp_owner WITH PASSWORD 'test';
CREATE SCHEMA myapp AUTHORIZATION myapp_owner;
ALTER USER myapp_owner SET search_path = myapp;

CREATE GROUP myapp_reader;
CREATE GROUP myapp_writer;
CREATE GROUP myapp_exec;

GRANT USAGE ON SCHEMA myapp TO myapp_reader;
GRANT USAGE ON SCHEMA myapp TO myapp_writer;
GRANT USAGE ON SCHEMA myapp TO myapp_exec;

ALTER DEFAULT PRIVILEGES
  FOR USER myapp_owner
   IN SCHEMA myapp
GRANT SELECT ON TABLES
   TO myapp_reader;

ALTER DEFAULT PRIVILEGES
  FOR USER myapp_owner
   IN SCHEMA myapp
GRANT INSERT, UPDATE, DELETE ON TABLES
   TO myapp_writer;

ALTER DEFAULT PRIVILEGES
  FOR USER myapp_owner
   IN SCHEMA myapp
GRANT USAGE ON SEQUENCES
   TO myapp_writer;

ALTER DEFAULT PRIVILEGES
  FOR USER myapp_owner
   IN SCHEMA myapp
GRANT EXECUTE ON FUNCTIONS
   TO myapp_exec;

ALTER DEFAULT PRIVILEGES
  FOR USER myapp_owner
   IN SCHEMA myapp
REVOKE EXECUTE ON FUNCTIONS
 FROM PUBLIC;

What we’ve done beyond creating the owning user and the groups themselves, is set defaults for new objects so everything is automatically enforced. The myapp_writer role has two of these because tables may rely on sequences for automatically generated IDs, so we need grants for both. We also neglected granting read access to the writer since writing can come from any number of vectors, some of which might not require reading the data they insert. The last thing we do is revoke execution of all stored procedures from PUBLIC, because that’s a silly default and smells of elderberries.

We should also note that, while these commands should be executed by a superuser initially, ownership itself does not require such elevated privileges. It’s unfortunately common for DBAs and operators to create objects as the postgres user itself. Since this user is often the primary superuser for the Postgres instance, deployment systems must be given access to it as well. That is an implicit risk to the entire database constellation that has only one alternative: the DBA deploys all database objects.

While certainly possible, this acts as an impediment to continuous integration, automated testing, and software deployment. Proprietary applications might also depend on database objects that must be created and maintained by the application itself. The DBA literally can’t inject themselves into every potential source.

But the myapp_owner user can. Let’s use it to build some simple object in the myapp schema:

CREATE TABLE test_tab (
  id          SERIAL PRIMARY KEY,
  somedata    VARCHAR NOT NULL,
  created_dt  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);

CREATE INDEX idx_test_tab_created_dt
    ON test_tab (created_dt);

CREATE VIEW v_test_data AS
SELECT * FROM test_tab
 WHERE somedata LIKE 'test%';

CREATE FUNCTION f_hello()
RETURNS VARCHAR AS
$$

  SELECT 'Hello ' || somedata
    FROM test_tab
   ORDER BY created_dt DESC
   LIMIT 1;

$$ LANGUAGE SQL;

Note that we still have no users beyond the initial owner. That comes later, once we know what username an application or user might desire or require for limited operation. We also strongly recommend that any database users for day-to-day operation should be distinct from the object owner. This prevents tool exploits from escalating to full data control. If a web user only has read access, a security bug would only expose the data, not allow the cracker the capability of changing data or dropping database structures.

Given these constraints, here’s a sample of how we might use our ACLs to constrict access. This is where a DBA could step in:

CREATE USER myapp_web WITH PASSWORD 'webthing';
ALTER USER myapp_web SET search_path = myapp;
GRANT myapp_reader TO myapp_web;

CREATE USER myapp_import WITH PASSWORD 'util';
ALTER USER myapp_import SET search_path = myapp;
GRANT myapp_reader TO myapp_import;
GRANT myapp_writer TO myapp_import;

CREATE USER myapp_report WITH PASSWORD 'calculate';
ALTER USER myapp_report SET search_path = myapp;
GRANT myapp_reader TO myapp_report;
GRANT myapp_exec TO myapp_report;

Now the web user can read all of the data it wants. The import jobs can shove data into the tables and read when reconciliation is necessary. Data reports can call functions to update fact tables or data cubes, and read the results.

This is a simple access model that might be ideal for a standard application deployment that doesn’t need to lock down individual tables. More complicated variations would have no default privilege definitions, and several more groups. For example, we might have created myapp_web as a group instead, and granted access to a limited subset of objects within the schema. Of course, provided we revoke the grants above, we still have that option.

And that’s where this kind of approach really wins out. While we defined a standardized series of default privileges that make it easy to distribute basic access, we can go much further while still maintaining those standards.

What about PUBLIC you ask? No. Stop asking that. They don’t get access. Barring functions, which we fixed, PUBLIC gets no default access to anything, ever. PUBLIC and all other users which don’t match our carefully organized roles, exist merely as objects of ridicule as they swarm fruitlessly at our impenetrable fortifications. The hordes are to be thwarted by any means available, which Postgres satisfies in spades.

After all, we all have our standards.