You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
service-analytics: on driver-sql (sqlite) the NativeSQLStrategy answers POST /analytics/dataset/query without the object-level grant or the tenant wall — a user with NO read grant on an object gets its row count (200) where GET /data/OBJECT answers 403; the memory driver's path refuses both #16645
Measured on@objectstack/cli 17.3.0 (service-analytics / rest / driver-sql / plugin-security 17.3.0) in the objectstack-ai/ats app, demo seed, objectstack dev --fresh on the default sqlite driver and on --database-driver memory. The request is the smallest dataset query there is — an inline { object, measures: [{ name: 'cnt', aggregate: 'count' }] } with selection: { measures: ['cnt'] } — compared with GET /api/v1/data/OBJECT?$top=1&$count=true for the same signed-in user.
persona
object
sqlite: analytics
sqlite: REST
memory: analytics
memory: REST
job seeker (ats_job_seeker, no grant on this object)
So three layers behave differently on the sqlite analytics path:
Object-level grant — not applied. The seeker's permission set has no ats_employer_member entry at all; the list API refuses, the dataset query counts 24 rows. (ats_employer_member is tenancy-scoped and private; the 24 is not "all 30", so something narrowed it — a sharing/tenant clause — but the grant that should have made it 403 was never consulted.)
Row-level policies — applied. Both employer administrators get exactly their own numbers on both paths (1 / 3 / 10 / 2 / 5 / 27 / 70 and 1 / 3 / 6 / 2 / 5 / 31 / 69), and the seeker's ats_employer count is the RLS-scoped 9 — so security.getReadFilter is threaded (ADR-0021 D-C); it is the other two checks that the native path lacks.
On the memory driver the NativeSQLStrategy cannot run (raw SQL unsupported) and the query goes through the ObjectQL engine, where all three layers apply and every cell equals REST — the seeker's ats_employer_member included. That is what points at the native-SQL strategy rather than at the app's metadata: same metadata, same user, same request; the answer depends on which strategy served it.
Why it matters./analytics/dataset/query is the endpoint every dashboard widget and report calls (objectui DatasetWidget). Any signed-in user can post an inline dataset to it, so on a SQL deployment a user with no grant on an object can learn its row count, and — with dimensions — its grouped counts by any field (GROUP BY over the object's columns), which is a read. The record-level half of this was #4467 (closed); this is the object-level grant and the tenant wall on the native strategy.
Expected. The native strategy consults the same object-level read grant and tenant scope the engine path consults (or is refused for a user the engine path would refuse), so the two strategies give the same answer for the same user; a 403 where /data answers 403.
Evidence files (tables above, plus the widget-level cross-check they came from): docs/evidence/issue-8/22-analytics-vs-rest-by-persona-memory.md and 23-…-sqlite.md on branch claude/issue-8-dashboards of objectstack-ai/ats.
Measured on
@objectstack/cli17.3.0 (service-analytics/rest/driver-sql/plugin-security17.3.0) in theobjectstack-ai/atsapp, demo seed,objectstack dev --freshon the default sqlite driver and on--database-driver memory. The request is the smallest dataset query there is — an inline{ object, measures: [{ name: 'cnt', aggregate: 'count' }] }withselection: { measures: ['cnt'] }— compared withGET /api/v1/data/OBJECT?$top=1&$count=truefor the same signed-in user.ats_job_seeker, no grant on this object)ats_employer_memberPERMISSION_DENIEDPERMISSION_DENIEDPERMISSION_DENIEDverification_status == 'verified')ats_employerviewAllRecords)ats_employer/ats_employer_member/ats_interview/ats_offeremployer_org)So three layers behave differently on the sqlite analytics path:
ats_employer_memberentry at all; the list API refuses, the dataset query counts 24 rows. (ats_employer_memberistenancy-scoped andprivate; the 24 is not "all 30", so something narrowed it — a sharing/tenant clause — but the grant that should have made it 403 was never consulted.)/dataon sqlite (On the sqlite driver, tenant-scoped objects (ats_employer,ats_interview,ats_offer) return 0 rows to platform admins holdingviewAllRecords; the memory driver returns all of them ats#39 is the/dataside of that; whichever side is right, the two paths disagree on the same driver for the same user).ats_employercount is the RLS-scoped 9 — sosecurity.getReadFilteris threaded (ADR-0021 D-C); it is the other two checks that the native path lacks.On the memory driver the NativeSQLStrategy cannot run (raw SQL unsupported) and the query goes through the ObjectQL engine, where all three layers apply and every cell equals REST — the seeker's
ats_employer_memberincluded. That is what points at the native-SQL strategy rather than at the app's metadata: same metadata, same user, same request; the answer depends on which strategy served it.Why it matters.
/analytics/dataset/queryis the endpoint every dashboard widget and report calls (objectuiDatasetWidget). Any signed-in user can post an inline dataset to it, so on a SQL deployment a user with no grant on an object can learn its row count, and — withdimensions— its grouped counts by any field (GROUP BYover the object's columns), which is a read. The record-level half of this was #4467 (closed); this is the object-level grant and the tenant wall on the native strategy.Expected. The native strategy consults the same object-level read grant and tenant scope the engine path consults (or is refused for a user the engine path would refuse), so the two strategies give the same answer for the same user; a 403 where
/dataanswers 403.Evidence files (tables above, plus the widget-level cross-check they came from):
docs/evidence/issue-8/22-analytics-vs-rest-by-persona-memory.mdand23-…-sqlite.mdon branchclaude/issue-8-dashboardsof objectstack-ai/ats.Generated by Claude Code