Summary
ObjectQL.aggregate() does not apply credential-field masking, unlike find() / findOne(). A query that selects, groups by, or otherwise surfaces a secret or password field through aggregate() can leak the stored value (an opaque secret:<id> ref for secret; plaintext for password) that the normal read path masks to SECRET_MASK.
This is a pre-existing gap for secret, surfaced while implementing read-path masking for password in #2036 / ADR-0100 (PR #3170). It was deliberately left out of that change to keep it focused on the read-contract decision.
Detail
find() → maskSecretFields (engine.ts) and findOne() → maskSecretFields both mask via collectMaskedReadFields.
aggregate() returns driver rows (or in-memory aggregation output) with no masking call.
count() is unaffected (returns only counts).
Why post-hoc masking is the wrong fix
Blindly masking aggregate output would corrupt group keys / aggregate values (e.g. GROUP BY on a credential field, or MIN/MAX over one), producing rows keyed on ••••••••. The value semantics of an aggregation over a credential field are meaningless anyway.
Proposed fix
Reject aggregations that reference a secret or password field (in select/aggregate expressions, groupBy, or having) with a clear error, rather than silently masking or leaking. Applies symmetrically to both credential types (reuse collectMaskedReadFields). Add unit coverage under packages/objectql.
Acceptance
aggregate() referencing a secret or password field fails fast with a descriptive error (or is otherwise provably non-leaking).
- Unit test pins the behavior for both types.
Ref: ADR-0100, #2036, PR #3170.
Summary
ObjectQL.aggregate()does not apply credential-field masking, unlikefind()/findOne(). A query that selects, groups by, or otherwise surfaces asecretorpasswordfield throughaggregate()can leak the stored value (an opaquesecret:<id>ref forsecret; plaintext forpassword) that the normal read path masks toSECRET_MASK.This is a pre-existing gap for
secret, surfaced while implementing read-path masking forpasswordin #2036 / ADR-0100 (PR #3170). It was deliberately left out of that change to keep it focused on the read-contract decision.Detail
find()→maskSecretFields(engine.ts) andfindOne()→maskSecretFieldsboth mask viacollectMaskedReadFields.aggregate()returns driver rows (or in-memory aggregation output) with no masking call.count()is unaffected (returns only counts).Why post-hoc masking is the wrong fix
Blindly masking aggregate output would corrupt group keys / aggregate values (e.g.
GROUP BYon a credential field, orMIN/MAXover one), producing rows keyed on••••••••. The value semantics of an aggregation over a credential field are meaningless anyway.Proposed fix
Reject aggregations that reference a
secretorpasswordfield (inselect/aggregate expressions,groupBy, orhaving) with a clear error, rather than silently masking or leaking. Applies symmetrically to both credential types (reusecollectMaskedReadFields). Add unit coverage underpackages/objectql.Acceptance
aggregate()referencing asecretorpasswordfield fails fast with a descriptive error (or is otherwise provably non-leaking).Ref: ADR-0100, #2036, PR #3170.