Access scopes limit what an authenticated principal may read, beyond its role permissions. Role permissions (see Authentication and authorization) decide which kinds of action a principal may call; access scopes decide which databases, tables, columns, SQL Resources, filter columns, and metadata operations those actions may touch. The frontend is never the boundary: every rule below is enforced by the API, including for direct requests.
config/access-scopes.json (or GENERIC_RUNTIME_CONFIG_DIR/access-scopes.json)
is deployment configuration: it is git-ignored, holds no secrets, and is never
created automatically. config/access-scopes.example.json
is a minimal safe example.
{
"version": 1,
"mode": "log-only",
"scopes": {
"frontend": {
"actions": ["select", "sql"],
"databases": {
"reporting": {
"tables": {
"Orders": "*",
"Customers": ["CustomerId", "Name", "City"],
"sales.Invoices": "*"
},
"resources": {
"widgets/sales-month-wise": { "filters": ["Bill_Date"] },
"reports/*": { "filters": [] }
},
"metadata": []
}
}
},
"read-only": {
"actions": ["select", "union", "unionAll", "metadata"],
"listDatabases": true,
"databases": {
"reporting": { "tables": { "Orders": "*" }, "metadata": ["tables"] }
}
},
"system-administrator": { "unrestricted": true }
}
}| Field | Rule |
|---|---|
version |
1 |
mode |
log-only or enforce (see Modes) |
scopes keys |
frontend (every principal with frontend access) or a role id: read-only, data-operator, api-administrator, system-administrator, application-administrator |
{"unrestricted": true} |
Explicit full access for that subject; the only way to permit routines and writes. Nothing is unrestricted by omission. |
actions |
Non-empty subset of select, union, unionAll, sql, metadata |
listDatabases |
Allows metadata.databases (default false) |
databases |
Keys are logical database ids from the registry — never physical names |
tables |
Table or schema.Table → "*" (every column) or a column list. Names match exactly as the request writes them (case-sensitive); dbo.Orders and Orders are different entries. sys and INFORMATION_SCHEMA are rejected. |
resources |
Exact resource id, or dir/* matching exactly one final segment. An exact entry wins over its pattern. |
resources.*.filters |
The only columns callers may filter that resource by |
metadata |
Subset of tables, columns, views, procedures, schema for that database |
Unknown keys, unknown subjects, duplicate entries, * table names, **,
inner wildcards, path separators other than /, file extensions, and
traversal are rejected. A malformed file is never partially applied.
Validate a file with php scripts/validate-access-scopes.php [path]. It is
read-only and distinguishes a valid file from one ready for enforcement:
| Exit | Meaning |
|---|---|
| 0 | Valid, complete, and enforce |
| 1 | Invalid (malformed); the API fails closed |
| 3 | Valid and complete, but missing or log-only |
| 4 | Valid but incomplete: it names an unregistered database or a SQL Resource that does not exist |
Its JSON output lists blocking items (exit 4) and review items that need an
explicit owner decision before enforcement: unrestricted subjects, "*"
tables, dir/* resource patterns, resource filter columns, metadata grants,
listDatabases, and roles that enabled users or API keys rely on but that
have no scope (those principals are denied once enforced). Review items never
change the exit status; signing them off is the owner's responsibility. The
example file is illustrative and reports exit 4 against a real registry.
The candidate scopes of a request are those of its subjects: frontend when
the principal has frontend access, plus its backend and frontend roles. Every
check keeps only the candidates that allow it, so one scope must allow the
whole request; grants of different scopes are never combined. A principal
with no candidate is denied.
| Request | Checked |
|---|---|
| Any scoped action | The action is in actions; procedure, function, tableFunction, and writes need an unrestricted scope |
select, union, unionAll |
Every database named anywhere in the request; every physical source (base, joins, subqueries, CTE bodies, every set-operation branch); every column the query builders render (projections, function arguments, expressions, CASE, predicates, joins, grouping, HAVING, sorting, windows). * needs "*" for every source it expands. COUNT(*) counts rows of an allowed table and is permitted. |
sql |
The resource id; the target database and any {{database:id}} placeholder database; every runtime filter field and execution.filters key against the resource's filters. Client-supplied filter expressions and HAVING mappings are refused for restricted scopes. |
metadata.* |
The operation for the target database; metadata.databases needs listDatabases |
Checks run before any query executes, in this order:
- Before the database availability gate: the action and the one database
that gate examines (the request's declared primary database, else the
default). An out-of-scope caller therefore receives the same
403whether that database is unregistered, disabled, unavailable, or simply not granted; the protected logs keep the probed id and reason. - Before database planning: every database the request names and the SQL Resource id, so an out-of-scope caller cannot learn whether a database or SQL Resource exists.
- After planning: every planned database (including SQL Resource placeholders), the resource's filter columns, and metadata operations.
- While the query builders resolve the request (
AuthorizingMetadataRepository): every table and column, before the catalog is consulted.
Callers inside their scope, and unrestricted principals, still receive the
availability gate's diagnostics (DATABASE_NOT_FOUND, DATABASE_DISABLED,
DATABASE_UNAVAILABLE). In log-only mode responses are the legacy ones, so
this protection, like every other, applies only once enforced. A denial returns 403 AUTHORIZATION_DENIED with no
details. Metadata results are not filtered by table scope: grant metadata
operations only where listing every object of that database is acceptable.
Administration, setup, auth.*, and frontend user management are not scoped;
their existing authorization is unchanged.
| Mode / state | Behavior |
|---|---|
enforce |
Out-of-scope requests are denied before execution |
log-only |
The same rules run; would-be denials are logged once per request and the legacy response is unchanged |
File missing, production (GENERIC_APP_ENV=production) |
Fail closed: every scoped request is denied |
| File missing, other environments | log-only with no scopes: legacy behavior, each scoped request logged as scope_configuration_missing |
| File invalid, any environment | Fail closed |
Logs use the operational message Access scope denied / Access scope would deny and the audit events authorization.scope_denied /
authorization.scope_would_deny with errorCode: ACCESS_SCOPE_DENIED, the
action, logical database, object identifier, and reason (for example
table_not_in_scope, filter_not_in_scope). They never contain filter values,
query text, credentials, or data, and are distinct from query failures.
Accounts created on the frontend user-management screen as Read Only have
frontend access and no backend role; they hold only frontend.read and are
governed by the frontend scope. They cannot call routines or writes.
Disabling a frontend user, or revoking its frontend access, increments its authentication version: its live sessions end on their next request, it can no longer log in, and API keys it owns stop authenticating. Revoking frontend access from an account without a backend role also disables it; it stays a role-less frontend account that an Application Administrator can restore (assign frontend access, then enable). No backend role is granted on restore.
Accounts created before this rule still hold the backend read-only role, so
the read-only scope also applies to them. List them with
php scripts/report-frontend-reader-accounts.php (read-only), then remove the
backend role in the Admin Console while keeping frontend access. The change
ends their current sessions. API keys are unaffected.
Authentication mode none is refused in production (503 AUTHENTICATION_UNAVAILABLE), and the Admin Console cannot select it there,
unless GENERIC_ALLOW_ANONYMOUS_PRODUCTION=true is set for an approved
exceptional deployment. publicRoles defaults to an empty list, so an
anonymous principal holds no permission unless a deployment assigns roles, and
it never receives the frontend scope.
- Generate a draft from the reporting frontend:
php scripts/generate-access-scope-draft.php <frontend>/src/config [default-database-id] > draft.json. It grants the columns the frontend's structured requests use (fields, grouping, sorting, displayed columns, and filters) and the SQL Resources it runs with the filter fields it sends. Its stderr lists what needs explicit review: hidden filter columns (filterable but not displayed, including SQL Resource filters the frontend maps to source columns) and joined tables granted"*"because their columns cannot be attributed. A draft is never production-ready on its own; validate it and review every entry. - Add scopes for each role that API keys, the legacy key, or backend users
rely on (see API-key integrations). Grant
{"unrestricted": true}only by explicit decision. - Install the file with
"mode": "log-only"and watch forauthorization.scope_would_denyuntil the expected traffic produces none. - Switch to
"mode": "enforce". In production, a missing or invalid file denies scoped requests, so keep the file under configuration management. - Migrate legacy frontend reader accounts (see Frontend users).
Rollback is a configuration release: restore the previous reviewed file (for
example back to log-only while a missing grant is added). Do not remove the
file or grant unrestricted to restore production access.
Managed API keys and the legacy key are governed by the scope of their role
(read-only or data-operator); keys of the same role share one scope, and
legacy frontend readers that still hold read-only share it too. A role with
no scope is denied in enforce mode — nothing else stands in for it. Before
enforcing, the owner of each enabled key confirms:
- The integration and its owner, and whether it is still needed (revoke it otherwise).
- The logical databases it reads.
- Its structured queries: tables, and the columns each needs (avoid
"*"). - Whether it needs
union/unionAll, metadata operations, ormetadata.databases. - Its SQL Resources and the filter columns it sends.
- That it needs no routines or writes (otherwise only an explicitly approved
unrestrictedscope serves it).
Record the approved scope, run it in log-only until the key's traffic
produces no authorization.scope_would_deny, and only then enforce. Do not
enforce while an in-use key's scope is unknown.
- Database: register it, then add its logical id under the scopes that need it. A scope never reaches an unlisted database.
- Table or column: add
Table(orschema.Table) with the narrowest column list that serves the report; use"*"only when every column is approved. - SQL Resource: review the SQL file, add its exact id with the filter
columns callers may use, and grant its target database. Prefer exact ids over
dir/*. - Metadata: grant only the needed operation for one database.
- Run
php scripts/validate-access-scopes.phpand the backend tests before deploying.
Access scopes do not replace database permissions. The application's reporting logins should be able to read only what reporting needs:
- Run
php scripts/check-database-privileges.php <database-id> ...(read-only, SQL Server).ELEVATEDmeans the login issysadmin,db_owner,db_datawriter, ordb_ddladmin. - Create a dedicated reporting login per server profile with
CONNECTandSELECTonly on the reporting tables and views (or a reporting schema) of each registered database, plusEXECUTE/SELECTonly on functions actually used. The SQL Resources inqueries/and the scoped tables define the required objects. - Do not grant server roles,
db_owner,db_datawriter,db_ddladmin, orCONTROL. Writes, if ever needed, use a separate profile and login. - Update the server profile in the Admin Console (Databases → Servers), test the connection, and verify reports.
These steps change database security and are performed by the database owner; the API never alters logins or grants.