feat(core): union roles, $user_groups and source access grants; fix empty role filters and array in/nin - #640
Merged
Merged
Conversation
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
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
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
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
Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PgFGXx4S9j2NwHSftynT3k
6 of 7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
{ or: [{}, {}] }loaded as anORwith no children, and{ and: [x, {}] }loaded with a branch removed. Config load now fails for these filters.inandninwith array variables on MySQL, MariaDB and MongoDB.JSON_CONTAINS(?, CAST(col AS JSON))fails for text columns. MySQL also renderedninwithoutNOT, sonin: $listmatched the listed values.JSON_CONTAINSunquoted.ninwith a variable rendered an empty$ninlist, so it excluded nothing.JSON_QUOTE, MySQL negatesnin, and MongoDB passes the variable to$nin.identity.role_mode: union. GraphJin applies every role the caller holds, from token claims, a SQLroles_queryor a GraphQLroles_query, and merges their table rules.firststays the default and keeps today's behaviour.$user_groups.identity.group_claims(default[groups]) fills a trusted identity variable that role filters can use. A request variable cannot replace it.allowed_roles. Exposed API operations accept group names and the component roles of a union.sources[].access.grants. Sources mode rejectsroles[].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.
oror; no shared column blocks the read+, for examplefinance+sales. The compiler caches the merged rule for each table.Grants in sources mode
accountorownermode, GraphJin joins that filter with the grant filter byand.access.writeandaccess.delete.gj_securitylists the grants of each source.SQL
roles_queryin union modeIn union mode, the role statement returns one 0/1 column per role that has a
matchrule. It reads the first row of the roles query. Two new dialect methods give theSELECTprefix and theFROMsuffix, because MSSQL usesTOPand Oracle usesFETCH FIRSTwithoutAS:No row resolves to
anon, and a row without a match resolves touser, as in first mode.Design notes
roles[].tablesis legacy config, and sources mode rejects it. In sources mode, the union merges the generatedsources[].accessandsystem.root_accessrules. For example, a token withmemberandadminkeeps admin access togj_security.$user_groupsis reserved, like$user_id. I used this name, not$groups, because it is less likely to clash with a client's own request variable.$user_groupsmatches no rows there.core/config.go), so that case cannot happen. Grants, the sources-mode replacement, always keep the namespace filter.Acceptance criteria from #639
ordersrow and never seesamounton another region's rowExample_queryUnionRolesMergesRules,TestCompileUnionRoleAppliesMergedRules,TestUnionSafetyCheckDetectsNaiveMergerole_mode: firstkeeps current behaviourExample_queryUnionRolesFirstModeUnchanged,TestInitialRequestRoleUnionMode,TestUnionRolesDisabledKeepsPlainLookup$user_groupsExample_queryWithGroupsFilter,TestSourceModeJWTGroupClaimsBecomeTrustedGroups,TestGroupsArgValueTestUnionReadRules/empty_intersection_blocks,TestUnionInsertNeverWidensColumnsTestAddRoleRejectsInvalidQueryFilters,TestAddRoleRejectsInvalidWriteFiltersTestUnionReadRules,TestUnionWriteRules,TestUnionLimitFunctionsAndUserFlagTestUnionRuleIsCached)TestApplySourceGrantsPerReadMode,TestSourceModeGrantsQuery,TestSourceModeHTTPJWTGrantRolesOther tests
roles_queryunion statement per dialectTestRenderRoleUnionStatementPerDialectanon, no match →user, matches → union keyTestScanRoleUnionRowOutcomesroles_queryunion end to endExample_queryUnionRolesWithSQLRolesQuery,Example_queryUnionRolesWithGraphQLRolesQueryin/ninrenderingTestInVariableRendersTextAndNumberColumns,TestMongoDBNotInVariableUsesTheVariablein/ninwith request variables on every Example databaseExample_queryWithWhereInTextVariableTestSourceModeHTTPJWTRoleModeUnionTestValidateSourceAccessGrants,TestApplySourceGrantsErrorsTestApplySourceGrantsPerReadMode,TestApplySourceGrantsIsIdempotentTestSourceModeGrantsQuery,TestSourceModeHTTPJWTGrantRolesExample_queryWithSourceAccessGrantsTestNewConfigParsesSourceAccessGrants,TestSecurityReportListsSourceAccessGrantsTestUnionReadNeverOverGrantschecks 2,000 random rule sets with a fixed seed. For each merged rule, it checks that every allowed column, under everyorbranch, is allowed by one single role.TestUnionSafetyCheckDetectsNaiveMergeproves that this check fails on a naive merge.Test plan
go test ./...incoreandserv(exit 0)go build ./...for every workspace moduleserv/config.schema.jsonregenerated withmake config-schema🤖 Generated with Claude Code