This document describes GraphJin's security model for operators and teams running GraphJin in multi-user, account-scoped, or agentic environments.
GraphJin is a compiler: it turns GraphQL into database queries. It is not a resolver framework where every field is guarded by custom application code. For SQL databases, authorization must therefore be resolved before and during query compilation, then enforced by the generated qcode/SQL path.
Security updates are provided for the current release line.
| Version | Supported |
|---|---|
| latest | yes |
Please report vulnerabilities privately. Email the maintainer or send a direct message to https://twitter.com/dosco.
Do not include live credentials, raw JWTs, customer data, connection strings, or full production query payloads in a report. A minimal reproduction, redacted configuration, affected version or commit, and observed impact are enough to start the investigation.
Source mode is GraphJin's secure multi-user configuration model. Source mode is
enabled when sources: is present in configuration.
In source mode:
- Request identity is request-wide and comes from verified authentication context, usually JWT claims plus optional identity enrichment.
- Source access is declared once at the source level, with small table/root classifications for exceptions.
- GraphJin generates internal role table rules from source access. Users should
not hand-write low-level
roles[].tablesfilters and presets in source mode. - The existing qcode/SQL compiler remains the enforcement path.
- Client variables are not trusted for identity fields such as
$account_idor$user_id. - Blocked or unauthorized direct access returns authorization errors. It should not silently look like an empty result set.
Legacy role table security still exists for legacy configurations, but source mode is intentionally stricter and is the migration target.
For LLM or MCP operators, discovery is intent-first. Start goal-driven config or security work with:
query_catalog(search: "<user instruction>")The best result should usually be a config_recipe row for roles, identity,
source access, table classifications, artifacts, GraphJin system roots, or
legacy roles[].tables migration. Inspect it with query_catalog(id: "...")
before reading gj_config or attempting a gj_config(id: "current", update: ...)
mutation. Use graphql_help(for: "discovery") only when the user intent is
unclear or catalog search returns no useful rows.
MCP discovery is caller-aware. The live tools/list, graphql_help, and
query_catalog responses include the caller's available tools, visible gj_*
roots, blocked roots, visible capability rows, recommended entrypoint, and
safety notes. If gj_catalog, gj_security, gj_runtime, or gj_config is
not visible to the caller, the operator or model must request the required
authenticated/admin access instead of guessing schema or policy.
sources: changes GraphJin into source mode. In that mode, old top-level
database, filesystem, OpenAPI, metadata, catalog, and table-access sections are
treated as legacy configuration and should be moved under sources.
Most importantly, source mode rejects user-supplied roles[].tables access
rules. Those rules are the old escape hatch for table-specific query filters,
mutation presets, column allow lists, and operation blocks. In source mode they
are generated internally from sources[].access.
Legacy mode remains supported for existing applications that do not configure
sources:. Those applications can continue to use roles_query, role match
predicates, and roles[].tables as before. New multi-user and agentic
deployments should use source mode.
Identity is configured at the top level because it is common to all sources. The common JWT or auth context should describe the caller once; individual sources should not re-interpret user/account claims independently.
identity:
user_id_claim: sub
role_claims: [role, roles]
namespace_claim: account_id
admin_roles: [admin]
query: ""| Field | Purpose |
|---|---|
user_id_claim |
JWT claim used as the trusted user id. Default: sub. |
role_claims |
JWT claim names that may contain role candidates. Default: [role, roles]. |
namespace_claim |
JWT claim used as the account or tenant namespace. Default: account_id. |
admin_roles |
Role names that bypass source row filters unless a table/root is explicitly blocked. Default: [admin]. |
query |
Source-mode name for the existing identity/role enrichment query path. |
Role selection uses this order:
- Candidate roles from JWT or identity enrichment are matched against configured roles and admin roles.
- Role
matchpredicates are evaluated through the existing role enrichment path when applicable. - Authenticated callers fall back to
user. - Unauthenticated callers fall back to
anon.
roles_query remains available as a deprecated alias in source mode. For V1,
identity.query should be treated conservatively as the source-mode name for
the existing roles_query enrichment path, not as a general policy language.
Generated source policies use trusted identity variables such as $account_id
and $user_id. These values must come from verified auth context. Client
provided GraphQL variables with the same names are ignored or rejected for
generated identity checks.
Database sources get secure defaults:
sources:
- name: app
kind: database
access:
read: account
write: blocked
delete: blocked
namespace_column: account_id
missing_namespace_column: blockDefault behavior:
| Setting | Default | Meaning |
|---|---|---|
read |
account |
Reads require $account_id and filter by namespace_column. |
write |
blocked |
Inserts, updates, and upserts are denied unless explicitly enabled. |
delete |
blocked |
Deletes are denied unless explicitly enabled. |
namespace_column |
account_id |
Column used for account-scoped access. |
owner_column |
user_id |
Column used for owner-scoped access. |
missing_namespace_column |
block |
Account-mode tables missing the namespace column are blocked. |
Access modes:
| Mode | Meaning |
|---|---|
blocked |
Hidden from usable graph; direct access fails authorization. |
public |
Readable shared/reference data with no account filter. Public write/delete is not supported. |
authenticated |
Any authenticated non-anon caller. |
account |
Requires trusted $account_id; filters by namespace_column. |
owner |
Requires trusted $user_id; filters by owner_column. |
admin |
Requires effective role in identity.admin_roles. |
Table classifications provide source-level exceptions:
access:
read: account
write: blocked
delete: blocked
namespace_column: account_id
public_tables:
- countries
- currencies
- plans
admin_tables:
- audit_logs
blocked_tables:
- internal_eventsClassifications mean:
| Classification | Effect |
|---|---|
public_tables |
Read-only public/reference tables. No account filter. |
admin_tables |
Read-only admin tables. |
blocked_tables |
Fully blocked tables. Hidden from normal discovery and direct access. |
write covers insert, update, and upsert. delete is intentionally separate
and defaults to blocked. In production and agentic environments, enable delete
only when the data model and operational controls require it.
Admin roles bypass generated row filters by default, but not explicitly blocked tables or roots.
If missing_namespace_column: allow is set, an account-mode table without the
namespace column cannot be safely account-filtered. GraphJin treats that table
as authenticated but unscoped and reports the posture through gj_security.
Prefer the default block behavior.
Source access compiles into internal role rules before qcode compilation. Operators should configure the high-level source policy, not the generated rules.
Conceptually:
access:
read: account
write: account
delete: blocked
namespace_column: account_idgenerates internal behavior equivalent to:
query:
filters:
- "{ account_id: { eq: $account_id } }"
insert:
presets:
account_id: "$account_id"
update:
filters:
- "{ account_id: { eq: $account_id } }"
presets:
account_id: "$account_id"
upsert:
filters:
- "{ account_id: { eq: $account_id } }"
presets:
account_id: "$account_id"
delete:
block: trueThe generated mutation presets overwrite client-supplied namespace values with
trusted identity context. A caller cannot write another account's
account_id by passing it as a GraphQL variable or input field.
For delete: account, GraphJin generates a delete filter. It does not generate
a delete preset because deletes do not create or rewrite row values.
GraphJin system roots are controlled by a GraphJin source:
sources:
- name: graphjin
kind: graphjin
access:
roots:
gj_catalog: authenticated
gj_artifacts: account
gj_workflow: admin
gj_workflow_execution: account
gj_runtime: admin
gj_security: admin
gj_config: adminDefault root access in source mode:
| Root | Default Access | Purpose |
|---|---|---|
gj_catalog |
authenticated |
Queryable discovery for schema, config, language, policy, and agent guidance. |
gj_artifacts |
account |
Account-scoped mutable fragments, saved queries, and related artifacts. |
gj_workflow |
admin |
Workflow definition management and workflow source inspection. |
gj_workflow_execution |
account |
Account-scoped workflow execution. |
gj_runtime |
admin |
Recent bounded, redacted runtime status/events. |
gj_security |
admin |
Effective policy and security findings. |
gj_config |
admin |
Runtime configuration view and guarded config mutation. |
Source capabilities decide whether sensitive roots exist. Root access decides
who can use roots that exist. For example, disabling a runtime capability can
make gj_runtime unavailable; setting gj_runtime: admin controls access when
it is available.
Sensitive roots should stay admin-only unless there is a deliberate operational reason to expose them more broadly.
Artifacts are configured with artifacts and exposed through the GraphQL system
root gj_artifacts.
artifacts:
enabled: true
source: app
schema: _graphjin
auto_init: true
globals_path: ./configV1 artifacts require a writable SQL database source. Object stores and file backends are not artifact stores.
Logical artifact tables are:
_graphjin.artifacts_graphjin.artifact_revisions
SQL dialects without schemas may use equivalent prefixed physical table names, but operators should treat them as GraphJin-managed tables under the configured artifact schema.
Artifact behavior:
- Auto-init runs before normal schema discovery when artifacts are enabled and
auto_initis true. - Files from
globals_pathare surfaced assource=config,visibility=global, andread_only=true. - Config-folder globals are not copied into the database and cannot be mutated
through
gj_artifacts. - Database-backed artifacts are account-scoped by default.
- A database artifact can override a same-name global for that account without changing config files.
- Non-admin responses redact or hash identity fields such as account/user ids.
- Global writes are denied. Cross-account writes are denied by scoping IDs and filters to trusted identity.
GraphJin may discover blocked or admin-only tables internally so it can produce security evidence and detect broken posture. Unauthorized callers should not see those resources in normal discovery.
For non-admin callers, blocked/admin-only tables should be omitted from:
- GraphQL introspection
gj_catalog- MCP help, search, examples, and agent-facing discovery rows
- legacy discovery surfaces where source mode still exposes compatibility paths
Admins can use gj_security to see that a table or root exists and why it is
blocked, admin-only, or unsafe.
gj_security and gj_runtime are complementary.
gj_security is the configured/effective policy view. It reports:
- identity claim names, admin roles, and whether identity enrichment is enabled
- source default read/write/delete modes
- namespace columns and missing-column behavior
- public/admin/blocked classifications
gj_*root policy- artifact source posture
- findings for unsafe or broken configuration
Examples of findings include account-mode tables missing the namespace column,
public write/delete attempts, delete enabled outside development, sensitive
gj_* capability enabled without admin-only access, anonymous access to
non-public roots, and artifact stores pointing at read-only or non-SQL sources.
gj_runtime is bounded, redacted decision support. It can report recent
auth/access outcomes such as:
- missing or invalid JWT
- missing required identity claims
- identity enrichment failure
- role resolution fallback
- missing namespace for account access
- blocked/admin/write/delete denial
- artifact global-write denial
- cross-account artifact/data access attempt
gj_runtime is not a forensic audit log. It should not store raw JWTs, secrets,
full variables, full query text, connection strings, or plaintext account/user
identifiers. Prefer hashes, counts, classes, and short reason codes.
identity:
user_id_claim: sub
role_claims: [role, roles]
namespace_claim: account_id
admin_roles: [admin]
query: ""
artifacts:
enabled: true
source: app
schema: _graphjin
auto_init: true
globals_path: ./config
sources:
- name: app
kind: database
type: postgres
default: true
access:
read: account
write: blocked
delete: blocked
namespace_column: account_id
missing_namespace_column: block
public_tables: [countries, currencies, plans]
admin_tables: [audit_logs]
blocked_tables: [internal_events]
- name: graphjin
kind: graphjin
access:
roots:
gj_catalog: authenticated
gj_artifacts: account
gj_workflow: admin
gj_workflow_execution: account
gj_runtime: admin
gj_security: admin
gj_config: adminThis example starts with account-scoped reads, blocks writes/deletes by default, keeps reference tables public, hides internal tables, keeps audit/system roots admin-only, and stores mutable artifacts in GraphJin-managed SQL tables.
For new multi-user deployments, start in source mode rather than adding more legacy role table rules.
Use Source Mode Migration for step-by-step before/after examples. This section summarizes the security intent.
For agent-assisted migrations, the model-facing starting point is:
query_catalog(search: "migrate legacy roles tables to source access")The returned config_recipe row distinguishes currently supported
gj_config.update fields from changes that still require direct config-file
edits or future mutation support. Source-mode gj_config.update writes are
two-step: first run mode: "preview" with expected_catalog_revision, then
resend the exact same payload with mode: "apply" and the returned
preview_id. Preview IDs are bounded in memory, expire after 10 minutes, and
store only a patch hash, base catalog revision, expiry, and redacted summaries.
Source access and GraphJin root policy changes should use source_patches by
exact source name so unmentioned source fields are preserved automatically.
Top-level identity and artifacts changes still require direct config-file
edits or future mutation support.
When migrating:
- Move provider configuration into
sources. - Move common JWT claim configuration into
identity. - Replace repeated
roles[].tables.query.filterswith source-levelaccess.read. - Replace mutation namespace presets with source-level
access.write. - Keep
deleteblocked unless explicitly required. - Classify shared lookup tables as
public_tables. - Classify audit/control tables as
admin_tables. - Classify internal-only tables as
blocked_tables. - Move user/account mutable fragments, saved queries, and workflows to
gj_artifacts. - Keep config-folder fragments, saved queries, and workflows as read-only globals.
The public config name is artifacts, and the GraphQL root is
gj_artifacts.
The source-mode implementation should be verified beyond unit tests before it is merged or released.
Required follow-up:
- Run dialect integration scripts for PostgreSQL, MySQL, and SQLite.
- Keep the HTTP-level JWT end-to-end coverage passing for
sub,roles, andaccount_id. - Keep coverage proving account rows are filtered and client
$account_idcannot override the trusted claim. - Keep coverage proving
gj_security,gj_runtime, andgj_configare admin-only by default. - Keep runtime denial-event tests proving auth/access failures are redacted.
- Keep
identity.queryas the V1 source-mode alias for the existingroles_querypath. Full arbitrary identity enrichment is a separate follow-up feature with its own result schema and fail-closed behavior.
Useful local commands:
GOCACHE=/tmp/go-build go test ./auth ./core ./serv
./scripts/test-postgres.sh
./scripts/test-mysql.sh
./scripts/test-sqlite.shSome local sandboxes do not allow httptest loopback listeners. If a full Go
test run fails only with listen tcp6 [::1]:0: bind: operation not permitted,
rerun in an environment with local networking before treating it as a GraphJin
regression.