Tickets has two authorization layers. Generated endpoints send IAuthorizedRequest.Resource/Action through the shared pipeline. Handlers then use TicketAuthorizationService to combine tenant identity, explicit denial, DataScope, requester, direct/group assignment, and live Sharing. Detail, list, global search, linked files, and Reporting export should all reuse the owner’s visibility decision.
1. Permissions and Feature
The Ticket resource uses ResourceDescriptor("tickets", "ticket").
| Scenario | Action/permission |
|---|---|
| open | Create / tickets.ticket.create |
| detail and list | View / tickets.ticket.view |
| state, comment, attachment | Update / tickets.ticket.update |
| assignment | Assign / tickets.ticket.assign |
| local search decision | Search / tickets.ticket.search |
| batch operations | tickets.ticket.batch |
| project, board, sprint | their own .view / .manage permissions |
At runtime, FeaturePolicyEvaluator maps module tickets to the central platform.tickets switch. The owner catalog also declares disabled-by-default tickets.manage. These are separate keys; a catalog definition is not the runtime module switch.
2. Record visibility
TicketAuthorizationService rejects cross-tenant calls and explicit Deny decisions first. Otherwise any of the following can grant read access:
- support-agent/operator;
- requester;
- direct Assignee;
- membership in the effective user-group key stored as AssigneeId;
- an allowed View decision whose DataScope is exactly Tenant;
- live Sharing from the Authorization owner at Read or higher.
Update similarly accepts a participant, support, Tenant DataScope, or Write Sharing. Assign accepts support, Tenant DataScope, or Write Sharing. A background export cannot use its System identity to widen access: ITicketResourceAuthorizationReader reloads the original user’s active subjects, permissions, and Sharing.
| Caller | Detail | Ordinary list | Note |
|---|---|---|---|
| support/operator | allowed | direct tenant-wide page | still subject to explicit Deny and trusted tenant |
| View with Tenant DataScope | allowed | direct tenant-wide page | requires explicit Allow plus Tenant scope |
| requester | allowed | included in visibility merge | no requester override is used now |
| direct assignee | allowed | included in visibility merge | detail and discovery are aligned |
| effective member of group assignee | allowed | included in visibility merge | resolved by TicketAssignmentSubjectResolver |
| Read Sharing only | allowed | merged with participant rows | disappears on the next query after revocation |
| same-tenant nonparticipant | denied/hidden | filtered out | explicit Deny outranks relation and Sharing |
| cross-tenant | denied | store predicate isolates | no client TenantId input exists |
3. List execution path
The handler for QUERY /api/tickets runs EnsureCanSearchAsync, then CanViewAllAsync:
A restricted caller’s client RequesterId is no longer replaced with their own ID. The database first applies all business filters. The handler then scans matching records in candidate batches of 200, merges participant and Sharing visibility, and pages the visible sequence. TotalCount therefore means visible records, not raw candidates.
This is authorization-correct but can cause multiple store calls, subject resolution, and Sharing decisions. Candidate Query Shapes use PageWindow with the default maximum offset 100000. Beyond that depth the next candidate page is empty and the handler ends its scan. Large tenants need a push-down DataScope/Sharing predicate or authorization index; otherwise visible TotalCount can be truncated by that boundary.
QUERY /api/tickets/todo follows a different path. The server resolves the current user and effective user-group keys, forces OpenOnly=true, and queries those AssigneeIds. It never accepts another subject from the client.
4. Standard paging semantics
Ticket list, Todo, project, board, sprint, and their Query Shapes use PagingLimits:
| Input | Normalized result |
|---|---|
| PageIndex ≤0 | 1 |
| PageSize ≤0 | 20 |
| PageSize 1..1000 | unchanged |
| PageSize >1000 | 1000 |
This replaces the old split behavior of Handler max 200 and Store max 100. Out-of-range values are normalized rather than returned as Validation failures. The restricted-list candidate batch remains 200; that is an internal authorization scan boundary, not the public PageSize maximum.
QUERY /api/tickets HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "status": "Assigned", "assigneeId": "agent-9", "searchText": "login", "pageIndex": 1, "pageSize": 50, "projectId": "project-1", "sprintId": "sprint-1"}Clients that cannot send QUERY use generated POST /api/tickets/_query; do not switch to GET. See the standard list-query contract.
5. Query Shape, ordering, and filters
The Ticket Query Shape reads Id, TenantId, Subject, Description, status/priority, assignee/requester, project/sprint, and audit timestamps. Its predicate always contains trusted TenantId and !IsDeleted, plus optional:
- Status or the OpenOnly status set;
- one AssigneeId or server-resolved AssigneeIds;
- RequesterId;
- Subject/Description Contains for SearchText;
- ProjectId and SprintId.
Default ordering is (ModifyTime descending, CreateTime descending) with no TicketId tail key. Identical timestamps or concurrent updates can still duplicate/omit rows across pages. Add Id for deterministic offset paging or use a cursor on frequently changing lists.
Subject/Description Contains has no Ticket-specific full-text index. Query Shape trims SearchText, but current request rules do not establish a maximum/minimum length or control-character policy. Add an input rule and rate limit before treating this as a large-scale search surface.
6. Detail reads and existence hiding
GetTicket calls FindAsync(currentTenant, ticketId) with TenantId, Id, and !IsDeleted. Missing, deleted, and cross-tenant IDs all return NotFound. Owner authorization runs only for a loaded same-tenant row, so another tenant cannot probe existence.
A same-tenant nonparticipant may receive Forbidden, which still distinguishes existing from missing within the tenant. If the product requires stronger hiding, map both to NotFound at the public boundary.
Detail currently returns the Ticket aggregate rather than a response DTO. Freeze actual HTTP JSON in contract tests so a new public aggregate property does not silently become API surface.
7. List-data minimization
TicketSummary includes the full Description. Descriptions can hold logs, email, device data, or personal details, so including them in every row increases exposure and payload size. Prefer:
- list item: Subject, state, priority, assignee, times, and short summary;
- detail: full Description, comments, and attachments;
- search projection: only an authorized hit snippet.
A single-tenant caller usually does not need TenantId either. Change fields through an explicit API version rather than following aggregate growth.
8. Impersonation and tenant snapshot
Tickets passes currentUser.User.TenantId, while persistence also has an ambient EffectiveTenant filter. During impersonation they must refer to the same target tenant. Capture one EffectiveTenant snapshot at request start and use it for authorization, query, command, notification, search, and audit.
Clients cannot override TenantId in the query body. A Host bypass must not coexist with an explicit home tenant passed into Tickets.
9. Update remains broad
A requester, assignee, or Write Sharing holder with Update can call Start, Resolve, Close, Reopen, comment, and attach. There is no finer policy where a customer only comments/reopens while an agent resolves/closes, and comments/files are not restricted by Ticket state.
If duties must be separated, add owner-local comment, attach, progress, resolve, close, and reopen permissions, each with participant/Sharing conditions.
10. Required contracts
- anonymous, wrong permission, explicit Deny, Tenant DataScope, and cross-tenant;
- requester, direct assignee, group assignee, support/operator;
- Read Sharing grant/revocation and correct Write semantics;
- PageIndex, PageSize, and TotalCount after participant/Sharing merge;
- PageSize 0, 500, 1001, candidate batch 200, and offset 100000;
- project, sprint, status, and text filters survive authorization merge;
- tied timestamps, concurrent update, and soft delete;
- explicit tenant equals ambient filter during impersonation;
- detail, global search, linked files, and Reporting export fail together after Sharing revocation;
- real HTTP QUERY/POST fallback, 401/403, and response fields.
11. Review commands
# Review permission, Feature, DataScope, participant, and Sharing together.rg -n "TicketPermissions|platform.tickets|CanViewAllAsync|FilterVisibleAsync|DataScope|Sharing" \ src/Platform/Tickets src/Framework/BitzOrcas.Application/Authorization -g '*.cs'
# Public paging and the internal authorization candidate batch are different limits.rg -n "PagingLimits|CandidatePageSize|PageWindow|ExecutePageAsync" \ src/Platform/Tickets src/Framework -g '*.cs'
# Inspect stable ordering, full-text filtering, and the list projection.rg -n "ReadModelSort|SearchText|Description|TicketListRow" \ src/Platform/Tickets/BitzOrcas.Platform.Tickets.Infrastructure -g '*.cs'Back to Tickets · Lifecycle and assignment · Comments and attachments