Core

Permissions & RLS

Permissions & RLS #

Authoritative tables live in the husaria_data schema, bootstrapped from internal/database/bootstrap/002_authorization.sql.

Schema #

  • husaria_data.roles — seeded with admin (system) and anonymous (system). Application roles are created through the console.
  • husaria_data.table_permissions — one row per (role, schema, table, operation). The UNIQUE(role_id, table_schema, table_name, operation) constraint enforces a single policy per operation. Columns:
    • operation — one of select, insert, update, delete
    • columns (JSONB allow-list, null means all)
    • computed_fields (JSONB allow-list of computed fields)
    • filter (JSONB predicate evaluated against session variables)
    • check_filter (JSONB validation predicate for insert/update rows)
    • set_cols (JSONB column presets for insert/update; see below)
    • limit_rows (max rows returned)
    • allow_aggregations (boolean, default false)
    • backend_only (boolean, default false)
  • husaria_data.role_inheritance — role hierarchy. The check_permission plpgsql helper walks the parent chain recursively.

The in-process rls.Registry loads this catalog on startup. The console re-publishes it when permissions change so policy edits take effect without a restart.

Column presets (set_cols) #

set_cols holds a JSON object of column -> value server-set values injected on insert/update. The client cannot override a preset column, and preset columns are exempt from the columns allow-list. A preset value that is an X-Husaria-* string resolves to the matching session variable at request time; any other value binds as a literal. Only the insert and update rows consult set_cols.

Session variables #

Session variables are read off X-Husaria-* headers:

  • X-Husaria-User-Id
  • X-Husaria-Role
  • X-Husaria-Org-Id
  • X-Husaria-Group-Id
  • X-Husaria-Department-Id
  • X-Husaria-Tenant-Id

Any other X-Husaria-* headers are captured in CustomVars. X-Husaria-Allowed-Roles is populated only from validated JWT claims — never from inbound headers.

Example policy #

Allow the user role to select only its own rows in public.notes, exposing only two columns:

INSERT INTO husaria_data.table_permissions
  (role_id, table_schema, table_name, operation, columns, filter)
VALUES
  ((SELECT id FROM husaria_data.roles WHERE name = 'user'),
   'public', 'notes', 'select',
   '["id","body"]'::jsonb,
   '{"user_id": {"_eq": "X-Husaria-User-Id"}}'::jsonb);

A request with X-Husaria-Role: user and X-Husaria-User-Id: <uuid> then sees only the rows where user_id matches that header.

In-memory permission shape #

The rls.Registry projects each table's rows into a per-operation struct (rls.TablePermission). Its YAML/JSON tags differ from the SQL column names, so a config-loader can unmarshal a policy directly:

select:
  columns: [id, body]
  filter: { user_id: { _eq: X-Husaria-User-Id } }
  limit: 100              # SQL column: limit_rows
  allow_aggregations: true
insert:
  columns: [body]
  check: { user_id: { _eq: X-Husaria-User-Id } }   # SQL column: check_filter
  set:                                              # SQL column: set_cols
    user_id: X-Husaria-User-Id
update:
  filter: { user_id: { _eq: X-Husaria-User-Id } }  # pre-update predicate
  check:  { user_id: { _eq: X-Husaria-User-Id } }  # post-update predicate
  columns: [body]
  set:
    updated_by: X-Husaria-User-Id
delete:
  filter: { user_id: { _eq: X-Husaria-User-Id } }

Per-operation fields:

OperationFields
selectfilter, columns, limit, allow_aggregations
insertcheck, columns, set
updatefilter, check, columns, set
deletefilter

A missing operation key is a default-deny: a nil record rejects the request. allow_aggregations is tri-state — unset allows (preserves prior behaviour), only an explicit false denies aggregate queries.

The in-memory struct has no backend_only field. That flag lives only on the SQL table_permissions row.

Supported filter operators #

The WHERE compiler in internal/graphql/execution/where.go accepts the operators below. Unknown operators are rejected at compile time.

Logical #

_and, _or, _not combine sub-predicates.

Comparison #

OperatorMeaning
_eq= value (_eq: null compiles to IS NULL)
_neq<> value (_neq: null compiles to IS NOT NULL)
_gt> value
_gte>= value
_lt< value
_lte<= value
_invalue in list
_ninvalue not in list
_is_nullIS NULL when true, IS NOT NULL when false (expects a bool)

Pattern matching #

OperatorMeaning
_likeLIKE
_nlikeNOT LIKE
_ilikeILIKE (case-insensitive)
_nilikeNOT ILIKE
_similarSIMILAR TO
_nsimilarNOT SIMILAR TO
_regex~ (POSIX regex)
_iregex~* (case-insensitive regex)
_nregex!~ (negated regex)
_niregex!~* (negated case-insensitive regex)

JSONB / array #

OperatorMeaning
_contains@> (left contains right)
_contained_in<@ (left contained in right)
_has_key? (object has key)
_has_keys_any?| (object has any of the keys)
_has_keys_all?& (object has all of the keys)

@> / <@ also serve array-column containment; the operand is the whole array and Postgres resolves it element-wise.

Column comparison #

The right-hand side is the name of another column on the same row, not a literal.

OperatorMeaning
_ceqcol = other_col
_cneqcol <> other_col
_cgtcol > other_col
_cgtecol >= other_col
_cltcol < other_col
_cltecol <= other_col