Skip to content

feat(core): union roles, $user_groups and source access grants; fix empty role filters and array in/nin - #640

Merged
amitdeshmukh merged 11 commits into
masterfrom
feat/role-union-groups
Sep 15, 2026
Merged

amitdeshmukh merged 11 commits into
masterfrom
feat/role-union-groups

Conversation

@amitdeshmukh

@amitdeshmukh amitdeshmukh commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #639.

Summary

GraphJin applied one role to each request. A caller with several roles kept the first configured role, and GraphJin dropped the others without an error. This PR adds groups support, per-role grants for sources mode, and fixes for two related bugs.

  1. Fix: role filters that compile to nothing. An empty object or list inside a role filter was skipped silently. { or: [{}, {}] } loaded as an OR with no children, and { and: [x, {}] } loaded with a branch removed. Config load now fails for these filters.
  2. Fix: in and nin with array variables on MySQL, MariaDB and MongoDB.
    • MySQL: JSON_CONTAINS(?, CAST(col AS JSON)) fails for text columns. MySQL also rendered nin without NOT, so nin: $list matched the listed values.
    • MariaDB: text columns went into JSON_CONTAINS unquoted.
    • MongoDB: nin with a variable rendered an empty $nin list, so it excluded nothing.
    • Result: MySQL and MariaDB now quote text columns with JSON_QUOTE, MySQL negates nin, and MongoDB passes the variable to $nin.
  3. identity.role_mode: union. GraphJin applies every role the caller holds, from token claims, a SQL roles_query or a GraphQL roles_query, and merges their table rules. first stays the default and keeps today's behaviour.
  4. $user_groups. identity.group_claims (default [groups]) fills a trusted identity variable that role filters can use. A request variable cannot replace it.
  5. allowed_roles. Exposed API operations accept group names and the component roles of a union.
  6. sources[].access.grants. Sources mode rejects roles[].tables, so a role had no way to get its own columns or row filter. A grant gives one role read access to a table with a column list and an optional filter. Union mode merges grant roles like any other roles.

Merge rule

The merged rule never allows a row, a column or an operation that no single role allows.

Reads Merged rule
One role allows the read That role's rule
One role has no filter and covers every other role's columns That role's rule
Same columns Those columns, filters joined with or
Same filter That filter, union of columns
Other cases Shared columns, filters joined with or; no shared column blocks the read
  • Writes: the rule never widens columns, because one role must allow the whole written row. Different presets or no shared column block the write.
  • Limit: the largest limit, or none if one role has none.
  • Functions: stay disabled if one role disables them.
  • Merged role name: the component names joined with +, for example finance+sales. The compiler caches the merged rule for each table.
  • No reload: a new combination of roles needs no config change.

Grants in sources mode

roles:
  - name: finance
  - name: sales
sources:
  - name: shop
    kind: database
    access:
      read: admin
      grants:
        - role: finance
          tables:
            - name: orders
              columns: [id, region, amount]
              filter: '{ region: { eq: "emea" } }'
        - role: sales
          tables:
            - name: orders
              columns: [id, region]
  • Replaces the read mode: a grant replaces the source read mode for its role and table only. Other roles and other tables keep the source mode.
  • Keeps the namespace filter: in account or owner mode, GraphJin joins that filter with the grant filter by and.
  • Reads only: writes and deletes keep access.write and access.delete.
  • Config load fails when:
    • the role is undefined, reserved or an admin role
    • a grant has no columns, or names a table or column that the database does not have
    • a role has two grants for one table
    • the table is blocked or internal
    • the source is not a database source
  • Security report: gj_security lists the grants of each source.

SQL roles_query in union mode

In union mode, the role statement returns one 0/1 column per role that has a match rule. It reads the first row of the roles query. Two new dialect methods give the SELECT prefix and the FROM suffix, because MSSQL uses TOP and Oracle uses FETCH FIRST without AS:

SELECT (CASE WHEN id = 1 THEN 1 ELSE 0 END), (CASE WHEN disabled = true THEN 1 ELSE 0 END)
FROM (SELECT * FROM users WHERE id = $1) AS _sg_auth_roles_query LIMIT 1

No row resolves to anon, and a row without a match resolves to user, as in first mode.

Design notes

  • Sources mode: roles[].tables is legacy config, and sources mode rejects it. In sources mode, the union merges the generated sources[].access and system.root_access rules. For example, a token with member and admin keeps admin access to gj_security.
  • Reserved variable: $user_groups is reserved, like $user_id. I used this name, not $groups, because it is less likely to clash with a client's own request variable.
  • NanoDB: it binds single values only, so $user_groups matches no rows there.
  • Issue correction: Support groups: one role per request cannot express users in several groups #639 said an explicit role table can drop the source namespace filter. Sources mode rejects explicit role tables at config validation (core/config.go), so that case cannot happen. Grants, the sources-mode replacement, always keep the namespace filter.

Acceptance criteria from #639

Criterion Test
Finance + Sales caller sees every orders row and never sees amount on another region's row Example_queryUnionRolesMergesRules, TestCompileUnionRoleAppliesMergedRules, TestUnionSafetyCheckDetectsNaiveMerge
role_mode: first keeps current behaviour Example_queryUnionRolesFirstModeUnchanged, TestInitialRequestRoleUnionMode, TestUnionRolesDisabledKeepsPlainLookup
Filters can use $user_groups Example_queryWithGroupsFilter, TestSourceModeJWTGroupClaimsBecomeTrustedGroups, TestGroupsArgValue
A merge that removes every column blocks the operation TestUnionReadRules/empty_intersection_blocks, TestUnionInsertNeverWidensColumns
A filter that fails to compile stops config load TestAddRoleRejectsInvalidQueryFilters, TestAddRoleRejectsInvalidWriteFilters
Every row of the merge table has a test TestUnionReadRules, TestUnionWriteRules, TestUnionLimitFunctionsAndUserFlag
A change to group membership needs no reload Covered by design: merged rules are built at request time and cached per compiler (TestUnionRuleIsCached)
Merged rules keep the source namespace filter TestApplySourceGrantsPerReadMode, TestSourceModeGrantsQuery, TestSourceModeHTTPJWTGrantRoles

Other tests

Change Test
SQL roles_query union statement per dialect TestRenderRoleUnionStatementPerDialect
No row → anon, no match → user, matches → union key TestScanRoleUnionRowOutcomes
SQL roles_query union end to end Example_queryUnionRolesWithSQLRolesQuery, Example_queryUnionRolesWithGraphQLRolesQuery
MySQL, MariaDB and MongoDB in / nin rendering TestInVariableRendersTextAndNumberColumns, TestMongoDBNotInVariableUsesTheVariable
Text in / nin with request variables on every Example database Example_queryWithWhereInTextVariable
Union mode over JWT in sources mode TestSourceModeHTTPJWTRoleModeUnion
Grant validation, one case per rule TestValidateSourceAccessGrants, TestApplySourceGrantsErrors
Grant rules for each read mode; other roles, admin roles and writes unchanged TestApplySourceGrantsPerReadMode, TestApplySourceGrantsIsIdempotent
Grants with first and union modes, account filter kept TestSourceModeGrantsQuery, TestSourceModeHTTPJWTGrantRoles
Grants on every Example database Example_queryWithSourceAccessGrants
YAML decoding and security report TestNewConfigParsesSourceAccessGrants, TestSecurityReportListsSourceAccessGrants

TestUnionReadNeverOverGrants checks 2,000 random rule sets with a fixed seed. For each merged rule, it checks that every allowed column, under every or branch, is allowed by one single role. TestUnionSafetyCheckDetectsNaiveMerge proves that this check fails on a naive merge.

Test plan

  • go test ./... in core and serv (exit 0)
  • Full suites: PostgreSQL, MySQL, MariaDB, SQLite, MongoDB
  • Affected examples: MSSQL, Oracle
  • Grant, union and groups examples: PostgreSQL, MySQL, MariaDB, SQLite, Oracle, MSSQL, MongoDB
  • go build ./... for every workspace module
  • serv/config.schema.json regenerated with make config-schema

🤖 Generated with Claude Code

amitdeshmukh and others added 9 commits September 15, 2026 16:49
Empty objects and lists inside a role filter were skipped by the
expression compiler, so { or: [{}, {}] } loaded as an OR with no
children and { and: [x, {}] } loaded with a branch removed. Role
filters now fail at config load instead.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
A caller can hold several roles, but GraphJin applied only the first
configured role and dropped the others. identity.role_mode: union now
applies every matching role from token claims or a GraphQL roles_query
and merges their table rules. The merge never allows a row, column or
operation that no single role allows: reads join filters with OR only
over shared columns, writes never widen columns, and different presets
or an empty column intersection block the operation.

identity.group_claims fills $groups, a trusted identity variable that
role filters can use and request variables cannot replace. Exposed API
operations accept groups and union role components in allowed_roles.

A SQL roles_query can match one role only, so union mode rejects it.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
Other examples insert products with high ids, so an open-ended
id >= 99 filter changed the result in the full suite.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
$groups is a common request variable name. $user_groups follows the
$user_id naming and is less likely to clash with client queries.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
…xt columns

MySQL cast text columns to JSON inside JSON_CONTAINS, which fails for
plain text, and rendered nin without NOT, so nin matched the listed
values. MariaDB passed text columns to JSON_CONTAINS unquoted. Both
dialects now quote text columns with JSON_QUOTE, and MySQL negates nin.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
Union mode rejected a SQL roles_query because the role statement
returned one role name. In union mode the statement now returns one
0/1 column per role with a match rule, read from the first row of the
roles query. Two dialect methods give the SELECT prefix and the FROM
suffix, because MSSQL uses TOP and Oracle uses FETCH FIRST without AS.
No row resolves to anon and a row without a match resolves to user, as
in first mode.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
MongoDB rendered nin with a variable as an empty $nin list, so nin
excluded nothing. nin now uses the same list and variable forms as in.

Refs #639

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
@amitdeshmukh amitdeshmukh changed the title feat(core): union role mode and $groups; reject empty role filters feat(core): union roles and $user_groups; fix empty role filters and array in/nin Sep 15, 2026
amitdeshmukh and others added 2 commits September 15, 2026 19:28
Sources mode rejects roles[].tables, so a role could not get its own
columns or row filter. sources[].access.grants gives one role read access
to a table with a column list and an optional filter. The account or
owner filter still applies, and union mode merges grant roles.

Config load fails for an undefined, reserved or admin role, a missing
table or column, two grants for one table, and a blocked table.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
@amitdeshmukh amitdeshmukh changed the title feat(core): union roles and $user_groups; fix empty role filters and array in/nin feat(core): union roles, $user_groups and source access grants; fix empty role filters and array in/nin Sep 15, 2026
@amitdeshmukh
amitdeshmukh merged commit e6f2da0 into master Sep 15, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support groups: one role per request cannot express users in several groups

1 participant