Skip to content

feat(objectql): reject aggregations over secret/password fields (#3171)#3187

Merged
os-zhuang merged 1 commit into
mainfrom
claude/aggregate-reject-credential-fields-3171
Jul 18, 2026
Merged

feat(objectql): reject aggregations over secret/password fields (#3171)#3187
os-zhuang merged 1 commit into
mainfrom
claude/aggregate-reject-credential-fields-3171

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Summary

Closes #3171 — the follow-up recorded in ADR-0100 when #3170 (#2036) landed password/secret read-masking.

find / findOne mask credential fields (secret always; password on non-better-auth objects) so plaintext never leaves the engine. But aggregate() had no equivalent guard: a GROUP BY / MIN / MAX / array_agg over a secret or password column would surface the stored secret:<id> ref or the password value. Masking the aggregate output after the fact would corrupt group keys, so the correct fix is to refuse the aggregation.

What changed

  • New gate rejectCredentialAggregation at the top of ObjectQL.aggregate() (packages/objectql/src/engine.ts), called right after name resolution and before any driver work. It scans the only two output-bearing positions on EngineAggregateOptions:

    • aggregations[].field — skipping COUNT(*) (undefined field or '*')
    • groupBy[] — each item a string or a { field } bucket object

    and throws a fail-closed error naming object.field when any references a credential field.

  • New collector collectCredentialFields (packages/objectql/src/secret-fields.ts) — every secret or password field, unconditionally (ignores managedBy). This is deliberately stricter than collectMaskedReadFields: read-masking exempts password on better-auth objects so login reads work, but aggregating a credential is never legitimate — even on a better-auth object it would be an inference oracle over password hashes. Keeping the two collectors separate stops the read-mask and aggregate-reject concerns from drifting. Exported from core.ts / index.ts.

Why no legitimate caller breaks

The only path that aggregates a schema-declared field is recomputeSummaries() (roll-up summaries). Verified the shipped rollups target numeric fields (showcase_invoice_line.amount, showcase_task.estimate_hours, etc.) — none reference a credential field. A rollup over a credential column would be a misconfiguration, and rejecting it is correct. HTTP aggregate calls already funnel through the engine (the http-dispatcher FLS aggregate gate), so this gate is enforced there automatically; raw driver.aggregate() is intentionally below the engine gate, consistent with that existing design.

Tests

Added to packages/objectql/src/secret-fields.test.ts (real engine + in-memory driver harness):

  • secret rejected as an aggregation measure, as a string groupBy, and as a structured { field } groupBy
  • generic password rejected as a groupBy dimension
  • rejection still fires on a managedBy: 'better-auth' object (unconditional collector)
  • no false positive: COUNT(*) on an object that merely has a credential field resolves fine

Verification

  • @objectstack/objectqlsecret-fields.test.ts 18 tests (6 new) pass
  • aggregate-path regression check — engine-aggregate-timezone, in-memory-aggregation, engine-count-read-filter (22 tests) all pass
  • package builds/typechecks; changed files lint clean

🤖 Generated with Claude Code


Generated by Claude Code

`find`/`findOne` mask credential fields (secret always, password on
non-better-auth objects) so plaintext never leaves the engine, but
`aggregate()` had no equivalent guard — a GROUP BY / MIN / MAX / array_agg
over a `secret` or `password` column would surface the stored `secret:<id>`
ref or the password value. Post-hoc masking of aggregate output would
corrupt group keys, so the correct fix is to refuse the aggregation.

Add a fail-closed gate at the top of `ObjectQL.aggregate()` that scans the
two output-bearing positions on the aggregate query — `aggregations[].field`
(skipping COUNT(*)) and `groupBy` (string or `{ field }` bucket) — and throws
when any references a credential field. The gate keys off a new unconditional
`collectCredentialFields` collector (secret OR password, ignoring
`managedBy`): unlike read-masking, aggregating a credential is never allowed,
even on a better-auth object, where it would be an inference oracle over
hashes. Keeping the two collectors separate stops the concerns from drifting.

No legitimate caller aggregates a credential field (verified: the only
schema-field rollup path, recomputeSummaries, targets numeric fields like
`amount`/`estimate_hours`). Follows the http-dispatcher FLS aggregate gate.

Tests cover: secret/password rejected as measure, as string groupBy, and as
structured `{ field }` groupBy; no false positive when the field is
unreferenced; and rejection on a better-auth object. Closes #3171; the gap
was recorded as a follow-up in ADR-0100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJrKxe1jXGudFT3nUks1yN
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Building Building Preview, Comment Jul 18, 2026 6:49am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 18, 2026 07:29
@os-zhuang
os-zhuang merged commit 49b9adc into main Jul 18, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/aggregate-reject-credential-fields-3171 branch July 18, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

aggregate() does not mask secret/password fields (bypasses read-path masking)

2 participants