Reporting exposes two paginated queries and one asynchronous export submission endpoint. Queries use RFC 10008 QUERY as the primary method and generate a POST .../_query fallback for clients that cannot send QUERY. There is no GET alias. All three entry points resolve the active tenant from authentication context and do not accept a client-selected TenantId.
1. Endpoint table
| Capability | Primary entry | Compatibility entry | Permission |
|---|---|---|---|
| Ticket summary | QUERY /api/reports/tickets/summary | POST /api/reports/tickets/summary/_query | reporting.ticket-summary.read |
| Audit activity daily | QUERY /api/reports/activities/daily | POST /api/reports/activities/daily/_query | reporting.activity.read |
| Ticket summary export | POST /api/reports/tickets/summary/export | None | reporting.ticket-summary.export |
QUERY and its POST fallback consume the same JSON body. OpenAPI 3.1 publishes only the POST fallback operation and describes the preferred QUERY transport through extensions such as x-http-query-method and x-http-query-path. Generated SDKs should honor those extensions instead of treating the operation as an ordinary command-style POST.
2. Ticket summary query
QUERY /api/reports/tickets/summary HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/jsonAccept: application/json
{ "status": "Closed", "from": "2026-07-01T00:00:00+08:00", "to": "2026-07-31T23:59:59+08:00", "pageIndex": 1, "pageSize": 20}| Field | Type/default | Current handling |
|---|---|---|
status | string? | null/blank means no filter; otherwise trimmed and compared exactly to StatusName; at most 64 characters; no controls; not resolved against the enum |
from | DateTimeOffset? | inclusive OpenedAt >= from |
to | DateTimeOffset? | inclusive OpenedAt <= to |
pageIndex | int = 1 | must be at least 1 |
pageSize | int = 20 | must be between 1 and 1000 |
From later than To, or a span beyond 366 days, returns Reporting.ReportingQuery.InvalidDateRange. DateTimeOffset retains the supplied offset; the rule does not rewrite boundaries to UTC. For a calendar month, supply an intentionally inclusive final instant or convert the business boundary explicitly. Do not pass the first instant of the next month as if To were exclusive.
{ "items": [ { "ticketId": "019c48cb7ba47000953d5af574ddc531", "requesterId": "100", "assigneeId": "200", "subject": "Unable to sign in to the production tenant", "priorityName": "High", "statusName": "Closed", "openedAt": "2026-07-03T08:30:00Z", "closedAt": "2026-07-03T10:15:00Z" } ], "totalCount": 1, "pageIndex": 1, "pageSize": 20}Public rows omit the Mart’s LastEventId and LastUpdatedAt; they cannot serve as a projection watermark.
3. Audit activity daily query
POST /api/reports/activities/daily/_query HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "from": "2026-07-01T00:00:00Z", "to": "2026-07-31T00:00:00Z", "pageIndex": 1, "pageSize": 50}from and to are required DateTime values. They are inclusive, must be ordered, and may span at most 366 days. Paging uses the same boundaries as ticket summary. The response is a complete PagedResult:
{ "items": [ { "activityDate": "2026-07-15T00:00:00", "userId": "42", "actionType": "ticket.view", "activityCount": 17 } ], "totalCount": 1, "pageIndex": 1, "pageSize": 50}The protocol still does not define DateTime Kind, tenant timezone, or date-only normalization, and no production writer exists. See Audit activity daily aggregation.
4. Input error catalog
| Condition | Stable error code |
|---|---|
| Page index below 1 or page size outside 1–1000 | Reporting.ReportingQuery.InvalidPage |
| Status longer than 64 characters or containing controls | Reporting.ReportingQuery.InvalidStatus |
| Reversed date range or span beyond 366 days | Reporting.ReportingQuery.InvalidDateRange |
| Not a real/delegated user, non-positive UserId, or invalid tenant | Reporting.User.Required |
| Authorization does not yield Tenant DataScope | Reporting.DataScope.TenantRequired |
| Export format is not csv/excel | Reporting.Export.InvalidFormat |
| Idempotency key is not 8–128 control-free characters | Reporting.Export.InvalidIdempotencyKey |
5. Exact Read permissions
Both queries implement IAuthorizedRequest with AuthorizationAction.Read:
public ResourceDescriptor Resource { get; } = new(ReportingPermissions.Module, ReportingPermissions.TicketResource);
public AuthorizationAction Action { get; } = AuthorizationAction.Read;// => reporting.ticket-summary.readThe governance catalog and authorization seed contain:
reporting.ticket-summary.read;reporting.ticket-summary.export;reporting.activity.read;reporting.export-job.read.
The old .view versus .read mismatch has been fixed. Do not grant .view to compensate for obsolete documentation; follow the current generated permission and seed.
6. Runtime Feature fact
FeaturePolicyEvaluator.ModuleFeatureMap contains:
["reporting"] = "platform.reporting"The central Feature seed defines platform.reporting as Disabled by default and marks it as commercial; a tenant override may enable it. The evaluator returns Deny, not Neutral, when it is disabled.
Reporting separately contributes an owner catalog definition named reporting.mart, also disabled by default, but the authorization chain does not evaluate that code for a resource in module reporting.
| Code | Current purpose |
|---|---|
platform.reporting | Runtime authorization Feature gate, license examples, and central seed |
reporting.mart | Reporting owner governance catalog definition |
Until the two concepts are merged or explicitly aliased, runtime configuration must enable platform.reporting. Enabling only reporting.mart does not open these query endpoints.
7. Current user and Tenant DataScope
After endpoint authorization, each handler still applies this gate:
// Override resource TenantId from authentication; never trust the request body.var decision = await authorization.EvaluateAsync( user, resource with { TenantId = user.TenantId }, action, cancellationToken);
// Reporting requires the exact tenant scope; User, Department, and All are not substitutes.if (!decision.IsAllowed || decision.DataScope != DataScope.Tenant) return ReportingErrors.TenantDataScopeRequired;The current principal must satisfy all of the following:
IsAuthenticated=true;- CallerType is
UserorDelegated; - UserId exists and is positive;
- TenantId passes
TenancyDefaults.IsValid.
Every Store count, page, find, Upsert, and export-batch predicate also includes TenantId, as do unique indexes. A caller cannot select another tenant in the body.
Own, Department, Office, and other narrower scopes are rejected rather than translated into Mart predicates. The current wide rows do not contain enough dimensions to express those scopes safely. A future implementation must convert owner rules into database predicates or a safe candidate set; it must not fetch the whole tenant and filter in memory.
8. Export security contract
{ "status": "Closed", "from": "2026-07-01T00:00:00Z", "to": "2026-07-31T23:59:59Z", "format": "excel", "idempotencyKey": "reporting-closed-202607-v1"}Export is not merely “run the query and download it”:
- Submission requires the exact
.exportpermission and Tenant DataScope. - The server fixes Builder Key, columns, and base filename.
- status/from/to/format participate in the idempotency fingerprint.
- Jobs accept only
ExportScope.All, with no CheckedIds. - Execution and retry restore the original tenant/user and re-evaluate
.export. - Every Mart row is rechecked by the Tickets owner’s
EnsureSubjectCanViewAsync; AccessDenied/NotFound rows are omitted, while infrastructure errors fail the job. - The eight fixed columns are ticketId, subject, statusName, priorityName, requesterId, assigneeId, openedAt, and closedAt.
This makes export stricter than the interactive query because it performs per-ticket owner authorization. The query itself still requires tenant scope and returns the tenant-wide Mart page.
9. Field sensitivity
Ticket Subject is free text, RequesterId/AssigneeId identify people, and audit UserId/ActionType/count can form a behavioral profile. No field-security contributor or response masker is wired today. Hiding a column in the client is not a security control.
Before production use, define at least:
- which roles may read per-ticket Subject and person IDs;
- which roles receive aggregate counts only;
- export watermark, download expiry, and audit policy;
- when audit daily aggregates by organization or pseudonymizes UserId;
- how deletion, retention, and legal hold propagate into derived Marts.
10. Contract test checklist
- QUERY and POST fallback use the same body and response; GET is unmapped or 405.
- OpenAPI extensions identify the primary QUERY path and fallback path.
- Exact
.read,.export, and missing grants produce stable outcomes. - Enabled/disabled
platform.reportingand the unavailable adapter fail explicitly. - User, Delegated, System, anonymous, invalid UserId, and invalid TenantId boundaries.
- Tenant, Own, Department, and Office DataScope matrix.
- Null, blank, 64-character, and control-character status values.
- Inclusive time boundaries, offsets, Kind, reversal, and 366/367-day spans.
- Page index/size boundaries at 1, 20, 1000, and 1001.
- Export format, idempotency key, fingerprint, retry recheck, and mid-job revocation.
- Subject/UserId field policy and export auditing.
- Stable Problem Details codes and description consistency.
11. Review commands
# Routes, actions, and request rules.rg -n "HttpRoute\.(Query|Post)|AuthorizationAction\.(Read|Export)|Validate(Page|DateRange|Idempotency)" \ src/Platform/Reporting -g '*.cs'
# Review the runtime Feature and owner catalog code together.rg -n "platform\.reporting|reporting\.mart|\[\"reporting\"\]" src tests -g '*.cs'
# Tenant DataScope and per-ticket export rechecks.rg -n "RequireTenantDataScopeAsync|DataScope\.Tenant|EnsureSubjectCanViewAsync" \ src/Platform/Reporting src/Platform/Tickets -g '*.cs'