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 withadmin(system) andanonymous(system). Application roles are created through the console.husaria_data.table_permissions— one row per (role, schema, table, operation). TheUNIQUE(role_id, table_schema, table_name, operation)constraint enforces a single policy per operation. Columns:operation— one ofselect,insert,update,deletecolumns(JSONB allow-list,nullmeans 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, defaultfalse)backend_only(boolean, defaultfalse)
husaria_data.role_inheritance— role hierarchy. Thecheck_permissionplpgsql 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-IdX-Husaria-RoleX-Husaria-Org-IdX-Husaria-Group-IdX-Husaria-Department-IdX-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:
| Operation | Fields |
|---|---|
select | filter, columns, limit, allow_aggregations |
insert | check, columns, set |
update | filter, check, columns, set |
delete | filter |
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_onlyfield. That flag lives only on the SQLtable_permissionsrow.
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 #
| Operator | Meaning |
|---|---|
_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 |
_in | value in list |
_nin | value not in list |
_is_null | IS NULL when true, IS NOT NULL when false (expects a bool) |
Pattern matching #
| Operator | Meaning |
|---|---|
_like | LIKE |
_nlike | NOT LIKE |
_ilike | ILIKE (case-insensitive) |
_nilike | NOT ILIKE |
_similar | SIMILAR TO |
_nsimilar | NOT SIMILAR TO |
_regex | ~ (POSIX regex) |
_iregex | ~* (case-insensitive regex) |
_nregex | !~ (negated regex) |
_niregex | !~* (negated case-insensitive regex) |
JSONB / array #
| Operator | Meaning |
|---|---|
_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.
| Operator | Meaning |
|---|---|
_ceq | col = other_col |
_cneq | col <> other_col |
_cgt | col > other_col |
_cgte | col >= other_col |
_clt | col < other_col |
_clte | col <= other_col |