Permissions #

Permissions control what users can do in a workspace. They are assigned to roles at varying levels of granularity.

The permission grid for a role: workspace-level rows above, then the database, its schema and its tables, with allow and deny against each action.

Permission levels #

Permissions are hierarchical. You can grant or deny access at five levels, from broad to specific:

Level Scope
App Workspace settings, user, role, and group management
Database All schemas, tables, and columns in a database
Schema All tables and columns in a schema
Table All columns in a table
Column A specific column

A permission set at the database level applies to everything inside it unless overridden at a more specific level.

Actions #

Permissions are defined per action:

Action Description
SELECT Read data from tables
INSERT Add new rows
UPDATE Modify existing rows
DELETE Remove rows
MANAGE Change the database itself: its structure, its access, its configuration
SEE Read the values in a column. Without it the column’s cells come back masked

App-level actions cover workspace administration:

Action Description
Workspace settings Edit workspace name, git remote, and settings
Workspace users Invite, remove, and manage members
Workspace roles Create, edit, and delete roles and permissions
Workspace groups Create groups and manage members; attaching a role to a group also requires Workspace roles
Workspace API keys Create, rotate, and revoke API keys

API keys let automated clients authenticate with the roles bound to the key, so every query they run passes through this same permission model.

Hiding a column #

SELECT says which columns a role may query; SEE says which values it may read. A role holding SELECT but not SEE on a column gets ***** where its cells would be, and may not use the column any other way.

A statement that tests a hidden column is refused rather than run. Answering “are there rows where email starts with a” is answering a question about the values, and enough answers are the value; the row count of an update filtered the same way answers it just as well, which is why the refusal does not wait for a statement to return rows. Hiding a column from the eye is not hiding it, so SEE hides it from the query. A subquery that selects the column to compare it against something is the same test written longer, and is refused too: write EXISTS (SELECT 1 FROM ...) where the column was only there to fill the list.

WHERE is not the only clause that asks. GROUP BY, ORDER BY, HAVING, a join condition and a window clause each read a column without returning it, and each answers a question a row at a time: which group a row falls in, which of two rows sorts first, whether a join matched. They are refused on the same terms as a WHERE, whether the join names its columns in an ON, in a USING list or in neither, as NATURAL does, and whether the statement reading them is a select or the FROM of an UPDATE. FILTER (WHERE ...) and the ORDER BY or PARTITION BY of an OVER (...) ask the same way. SELECT DISTINCT is refused too, since collapsing duplicate rows makes the row count the number of distinct values in the columns it names, and so is the predicate of an ON CONFLICT, which chooses which stored rows an upsert changes.

The whole statement is read, so a column hidden at the bottom stays hidden when a derived table or a CTE hands it up, and the rows a write returns through RETURNING are masked like any others. Renaming it on the way up does not unhide it: a statement testing s.e where s is (SELECT email AS e FROM users) is refused like one testing users.email. A subquery reading a column of the statement around it is reading that statement’s column, so a correlated WHERE c.email = u.email is refused too, and quoting a name or writing it in another case does not make it a different column.

A set operator refuses on the same terms as SELECT DISTINCT. UNION, INTERSECT and EXCEPT collapse duplicate rows unless written with ALL, so the row count reports whether a value the caller supplies is one of the hidden column’s.

A write is refused where it reads a hidden column, rather than masked. Masking works on the rows a statement hands back, and what a write reads it stores: INSERT INTO other (c) SELECT email FROM users would leave the caller a table it may select from, holding the values SEE refused it. Writing to a hidden column is not reading it, and stays allowed.

Where a result column cannot be traced back to a column of a table, and a hidden column in the statement has no result column of its own, the statement is refused rather than run: an expression over a hidden column, a subquery returning it under a name of its own, and a column added since the metadata was last read all land here. A literal, a count or a window function beside a hidden column is not one of these, since the hidden column still has its own position to mask.

Where two scopes spell a column the same way, the statement’s own result columns say which one comes out.

Allow and deny #

Each permission has an effect: allow or deny. When a user has multiple roles, all permissions are combined. Deny always wins: if one role allows an action and another denies it, the action is denied.

Evaluation order:

  1. SELECT checks all deny rules first across all of the user’s roles
  2. If any deny matches, access is refused (regardless of allow rules)
  3. If no deny matches, SELECT checks allow rules
  4. If an allow matches, access is granted
  5. If neither matches, access is refused (default deny)

This lets you create broad access with targeted restrictions. For example, a “Developer” role can allow all operations, while an “Intern” role adds a deny on MANAGE for production databases. A user with both roles cannot change the schema on production.

The first four actions are about the rows in a table. MANAGE is about the database itself: creating, altering and dropping tables, granting access, loading and exporting in bulk, and running procedures. It is granted on a connection, not on a schema or a table, so a rule scoped below the connection never matches.

Anything that is not plainly one of the four row actions needs MANAGE. That is deliberate: granting the four never quietly grants more than reading and writing rows, so an unusual statement is refused rather than let through.

A statement that does two things needs the permission for both. CREATE TABLE new AS SELECT * FROM old builds a table and reads another one, so it needs MANAGE and SELECT on old. The same holds for a view over a table, an UPDATE that reads a second table to fill the first, and a statement whose result is built by a query inside it.

Every table a statement names counts, including one no column is selected from. SELECT t1.c2 FROM t1, t2 reads t2 for its rows whether or not a column of it appears in the result, and a WHERE over it reports what those rows hold, so it needs SELECT on t2 as well as on t1.

Databases nobody has written a rule for #

Step 5 is about a database your roles do cover. A database that no role in the workspace mentions at all is a different case, and the answer depends on who is holding the credentials.

Connection A database with no rules Why
Local Allowed The DSN is yours, on your machine. Refusing here would only be refusing you access to your own database.
Proxified Refused The credentials are held server-side and the query runs on our server. Nothing is granted that a role did not grant.
MCP Refused Same server, same rule. An agent gets what its key’s roles name, and nothing else.

Adding a database to a proxified workspace does not make it readable. Until a role carries an allow rule for it, every query against it is refused, including from an API key that can read every other database in the workspace.

Scope #

When adding a permission, you choose its scope using checkboxes. Granting access at the database level automatically covers all schemas, tables, and columns inside it. You can then narrow it down by selecting a specific schema, table, or column.

Example setup #

A typical team might have:

Role Database Action Effect
Developer dev-db SELECT, INSERT, UPDATE, DELETE allow
Developer prod-db SELECT allow
DBA * SELECT, INSERT, UPDATE, DELETE, MANAGE allow
Analyst prod-db SELECT allow