Skip to content

Made counts respect the access group of users - #2463

Open
jessevz wants to merge 8 commits into
masterfrom
2461-bug-error-in-counts
Open

Made counts respect the access group of users#2463
jessevz wants to merge 8 commits into
masterfrom
2461-bug-error-in-counts

Conversation

@jessevz

@jessevz jessevz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Scope APIv2 count endpoints to the access groups of the user

closes #2461

Problem

Counting in APIv2 ignored access groups entirely. GET /api/v2/ui/<model>/count and
GET /api/v2/helper/getCompletedCount reported numbers covering the whole installation,
while the matching list endpoints correctly returned only what the user may see.

The visible symptom was a dashboard reporting 7 completed tasks next to 1 total task:
the task list was ACL-filtered, the completed counter was not.

While fixing this a second defect surfaced in the same code path: /count silently dropped
filters it could not resolve, answering with an unfiltered total instead of an error.

Changes

1. Apply the ACL to /count (AbstractModelAPI::count())

getManyResources() merges getFilterACL() into its query, count() never called it.
The ACL filters and joins are now applied the same way, reusing checkJoinExists() so an ACL
join is not duplicated when the same relation was already joined through expand.

All 15 model APIs that override getFilterACL() were affected: Agent, AgentAssignment,
AgentError, AgentStat, ApiToken, Chunk, File, Hash, Hashlist, HealthCheckAgent,
Speed, Task, TaskWrapper and TaskWrapperDisplay.

meta.total_count was a second instance of the same leak (countFilter([]), no filters at all)
and is now counted within the ACL as well — "without any filter applied" means without the
request's filters, not without the user's access groups.

2. Fix filter resolution on /count (AbstractModelAPI::filterObjectMap())

The method matched filter keys against the raw DBA features, which are keyed by database column,
while makeFilter() — which actually builds the query — works with aliases:

  • filter[id] never resolved. The _id special case looked up array_column($features, 'alias', 'dbname') on features that carry no dbname key, which emitted an Undefined array key warning
    (corrupting the JSON body) and produced null. _id is only ever a response key, never a filter
    input, so the case is replaced by id, matching the list endpoint.
  • Fields whose alias differs from their column were dropped, affecting Hashlist.hashlistName→name,
    User.username→name, User.rightGroupId→globalPermissionGroupId, RightGroup.groupName→name and
    Task/TaskWrapperDisplay preprocessorId→usePreprocessor.
  • Unresolvable filters were skipped silently, so a bad filter returned an unfiltered count. They now
    raise HttpForbidden, like listing does.
  • The operator list gained __in/__nin so it stays in sync with makeFilter(); both previously
    returned 403 on /count while working on the list endpoint.

3. Apply the ACL to getCompletedCount

The helper validated permissions (permTaskWrapperRead, permTaskRead) but never access groups.
Both counts are now restricted with the same join to Hashlist and ContainFilter on
Hashlist::ACCESS_GROUP_ID that TaskWrapperDisplayAPI and TaskWrapperAPI use, so the dashboard
figure and the task list derive from one rule. The supertask query inherits the restriction through
the already ACL-filtered $taskWrapperIds.

TaskWrapperDisplay exposes its own accessGroupId, but that is the task wrapper's group while the
list endpoints key off the hashlist's group. Joining Hashlist keeps the helper consistent with them.

4. Allow extra joins in AbstractModelFactory::joinAggregationFilter()

The method ignored Factory::JOIN in its options, unlike filter() and countFilter(), leaving no way
to add the ACL join above. It now applies them. Purely additive: the only callers are the helper above
and AbstractModelFactoryTest, none of which passed JOIN before. The result is grouped by the primary
keys of the factory, so such joins must not multiply rows — noted in the docblock.

Behaviour changes

  • Counts drop for users who could previously see totals spanning access groups they are not a member of.
    They now agree with the corresponding list endpoint.
  • /count returns 403 for a filter it cannot resolve, where it previously returned an unfiltered
    count. Clients relying on a filter that silently did nothing will see an error instead of a wrong
    number. Dotted relation filters (task.taskName) fall into this group: the list endpoint resolves
    raise HttpForbidden, like listing does.
  • The operator list gained __in/__nin so it stays in sync with makeFilter(); both previously
    returned 403 on /count while working on the list endpoint.

3. Apply the ACL to getCompletedCount

The helper validated permissions (permTaskWrapperRead, permTaskRead) but never access groups.
Both counts are now restricted with the same join to Hashlist and ContainFilter on
Hashlist::ACCESS_GROUP_ID that TaskWrapperDisplayAPI and TaskWrapperAPI use, so the dashboard
figure and the task list derive from one rule. The supertask query inherits the restriction through
the already ACL-filtered $taskWrapperIds.

TaskWrapperDisplay exposes its own accessGroupId, but that is the task wrapper's group while the
list endpoints key off the hashlist's group. Joining Hashlist keeps the helper consistent with them.

4. Allow extra joins in AbstractModelFactory::joinAggregationFilter()

The method ignored Factory::JOIN in its options, unlike filter() and countFilter(), leaving no way
to add the ACL join above. It now applies them. Purely additive: the only callers are the helper above
and AbstractModelFactoryTest, none of which passed JOIN before. The result is grouped by the primary
keys of the factory, so such joins must not multiply rows — noted in the docblock.

Behaviour changes

  • Counts drop for users who could previously see totals spanning access groups they are not a member of.
    They now agree with the corresponding list endpoint.
  • /count returns 403 for a filter it cannot resolve, where it previously returned an unfiltered
    count. Clients relying on a filter that silently did nothing will see an error instead of a wrong
    number. Dotted relation filters (task.taskName) fall into this group: the list endpoint resolves
    them, /count has never supported them, and they now fail loudly rather than being ignored.
  • filter[format__nin]=3 and friends now work on /count.

Updated test expectations

test_realworld_dump.py::test_known_dump_counts had three values baked in from the unfiltered
behaviour. In the dump, admin (userId 1) is only a member of the default group while hashlist 272
lives in access group 5, so that hashlist plus its 5477 hashes and 10 tasks are no longer counted:

assertion was now
Hash.count(hashId__lte=30129) 28957 23480
Hashlist.count(hashlistId__lte=272) 267 266
Task.count(taskId__lte=1336) 304 294

The new values match what admin can already list today. The other counts and every list and
pagination assertion in that file are unchanged.

Tests

  • _test_acl_count() added to ci/apiv2/utils.py next to _test_acl_list(), asserting a user with no
    access groups counts 0 in both count and total_count while an admin counts more than 0. Wired into
    the eight existing test_acl cases: agent, agentassignment, agentstat, apitoken, file, hashlist, task
    and taskwrapper.
  • test_count.py covers the id key, aliased fields, rejection of the underlying column name and
    rejection of an unknown filter.
  • test_completed_count.py gains test_acl_counts_are_scoped_to_access_groups. Verified that it catches
    the regression: with the ACL reverted it fails with 8 != 0, reproducing the reported dashboard state.

@jessevz
jessevz requested a lite review from Copilot August 26, 2026 06:39
@jessevz jessevz linked an issue Aug 26, 2026 that may be closed by this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR scopes APIv2 count-related endpoints to the requesting user’s access groups (aligning /count, meta.total_count, and getCompletedCount with list endpoint ACL behavior) and fixes /count filter mapping so invalid/unresolvable filters fail loudly instead of being silently ignored.

Changes:

  • Apply getFilterACL() (filters + joins) to /api/v2/ui/<model>/count and to meta.total_count.
  • Fix /count filter resolution to use aliased fields (incl. id), support __in/__nin, and reject unknown/unmappable filters with 403.
  • Scope getCompletedCount to access groups via Hashlist join + ContainFilter, and extend CI coverage for ACL-scoped counts.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/inc/startup/setup.php Disables migration execution on startup (unrelated to count ACL; high impact).
src/inc/apiv2/openapi/ModelApiPathBuilder.php Updates OpenAPI text to clarify total_count is ACL-scoped.
src/inc/apiv2/openapi/JsonApiFragments.php Updates OpenAPI schema descriptions to reflect ACL-scoped counts.
src/inc/apiv2/helper/GetCompletedCountHelperAPI.php Adds access-group scoping to completed task/supertask counts via Hashlist ACL join/filter.
src/inc/apiv2/common/AbstractModelAPI.php Fixes filter mapping and applies ACL scoping to /count and total_count.
src/dba/AbstractModelFactory.php Allows extra joins in joinAggregationFilter() via Factory::JOIN.
ci/apiv2/utils.py Adds _test_acl_count() helper asserting ACL-scoped count and total_count.
ci/apiv2/test_taskwrapper.py Extends ACL test to cover count.
ci/apiv2/test_task.py Extends ACL test to cover count.
ci/apiv2/test_realworld_dump.py Updates expected dump counts to match ACL-scoped behavior.
ci/apiv2/test_hashlist.py Extends ACL test to cover count.
ci/apiv2/test_file.py Extends ACL test to cover count.
ci/apiv2/test_count.py Adds tests for id, aliased-field filtering, and rejection of unknown/column-name filters.
ci/apiv2/test_completed_count.py Adds regression test ensuring getCompletedCount is ACL-scoped.
ci/apiv2/test_apitoken.py Extends ACL test to cover count.
ci/apiv2/test_agentstat.py Extends ACL test to cover count.
ci/apiv2/test_agentassignment.py Extends ACL test to cover count.
ci/apiv2/test_agent.py Extends ACL test to cover count.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/inc/startup/setup.php Outdated
Comment thread src/inc/apiv2/common/AbstractModelAPI.php Outdated
@jessevz
jessevz marked this pull request as draft August 27, 2026 06:26
@jessevz
jessevz marked this pull request as ready for review August 27, 2026 15:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: error in counts

2 participants