Search mixes five hand-written Minimal APIs with four [GenerateEndpoint] routes. Effective authorization comes from each request’s ResourceDescriptor + AuthorizationAction; a comment saying “requires manage/check” does not change the suffix evaluated at runtime.
1. Endpoint matrix
| Method | Route | Implementation | Action | Derived permission |
|---|---|---|---|---|
| POST | /api/search/unified | manual conversion | View | search.search.view |
| POST | /api/search/conflict/check | manual conversion | Search | search.conflict.search |
| POST | /api/search/segment/words | manual enum check | Create | search.index.create |
| DELETE | /api/search/segment/words | manual enum check | Delete | search.index.delete |
| GET | /api/search/segment/words/{segmentType} | manual enum check | View | search.index.view |
| GET | /api/search/conflict/records/{recordId} | generated | View | search.conflict.view |
| GET | /api/search/index/catalog | generated | View | search.index.view |
| GET | /api/search/index/{indexKey}/statistics | generated | View | search.index.view |
| POST | /api/search/index/{indexKey}/rebuild | generated | Manage | search.index.manage |
The manual group requires authentication and userPolicy rate limiting. Unified, conflict, and word writes use StandardCommand; word listing uses ShortRead. The generated request records declare no Search-specific timeout policy.
2. Unified-search request
POST /api/search/unified HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "indexKey": "conflict", "keyword": "Hailan Technology", "skip": 0, "take": 20}The API model defaults Skip to 0 and Take to 20. No rule rejects blank/unknown IndexKey, negative Skip, non-positive or excessive Take, long Keyword, control/query syntax, or an expensive deep window.
The handler sends the same untrimmed term to Name, Subject, and Content. It does not select culture or analyzer. Public behavior should not depend on unspecified defaults inside the external package.
3. Conflict-check request
{ "tenantId": "client-supplied-and-ignored", "parties": [ { "name": "Hailan Technology Co., Ltd.", "foreignName": "海岚科技有限公司", "formerName": "Hailan Industries", "creditCode": "91310000MA00000000" } ], "excludeCaseId": "case-2026-0042"}The handler replaces TenantId with the current tenant. That is secure, but retaining the ignored field in DTO/API misleads clients. Remove it or mark it explicitly deprecated and ignored.
There is no Parties rule. A runtime null collection throws; an empty list returns HasConflict=false; null Name can still arrive from JSON; duplicate Name entries overwrite each other in the dictionary; a blank Name with another term becomes a blank category key. These are unsafe implicit semantics for a high-value compliance check.
4. SegmentType and Word
The route validates that SegmentType is 0/1/2 before Mediator. An invalid value returns an anonymous 400 object rather than shared Problem Details:
{ "code": "Search.Segment.InvalidType", "message": "Invalid segmentation type: 9"}Word is trimmed and rejected only when blank. There is no length, Unicode normalization, casing, control-character, or type-specific validation.
public static class SearchErrors{ public static readonly Error IndexUnknown = Error.NotFound("Search.Index.Unknown", "The search scenario is unavailable.");
public static readonly Error PageInvalid = Error.Validation("Search.Page.Invalid", "The search window is invalid.");
public static readonly Error KeywordTooLong = Error.Validation("Search.Keyword.TooLong", "The search term is too long.");}
// Resolve IndexKey through an enabled catalog instead of exposing physical names for probing.var catalog = await catalogStore.GetAsync(request.IndexKey, cancellationToken);if (catalog is null || catalog.Status != IndexStatus.Active) return SearchErrors.IndexUnknown;
// Large extraction belongs to an asynchronous export contract.if (request.Skip < 0 || request.Take is < 1 or > 100) return SearchErrors.PageInvalid;
// Normalize first, then enforce both rune and UTF-8 byte limits.if (Normalize(request.Keyword).Length > 200) return SearchErrors.KeywordTooLong;5. Permission drift
The catalog owns:
search.search.view;search.conflict.search;search.conflict.view;search.index.manage;search.index.view.
View endpoints, conflict check, and rebuild all align with the catalog. Only segmentation writes still drift:
// Conflict check derives the same suffix as the catalog.new ResourceDescriptor("search", "conflict");AuthorizationAction.Search; // => search.conflict.search
// Rebuild declares Manage and matches the catalog; word mutations declare Create and Delete.new ResourceDescriptor("search", "index");AuthorizationAction.Manage; // => search.index.manageAuthorizationAction.Create; // => search.index.createAuthorizationAction.Delete; // => search.index.deleteAuthorizationAction has Manage but not Check. The remaining fix is to standardize word create/delete on AuthorizationAction.Manage, or split them deliberately and migrate the catalog. Change catalog, grants, seed, cache, upgrade compatibility, and tests together.
6. Feature catalog is not an entitlement gate
SearchFeatures.Index = "search.index" is cataloged with DefaultEnabled=false. The central FeaturePolicyEvaluator.ModuleFeatureMap contains tickets/chat/workflow only. Search requests therefore receive Neutral without reading tenant entitlements.
If query, conflict, and index administration are separate commercial capabilities, one Feature may also be too coarse. Whatever the chosen granularity, enforce it through the shared evaluator or an explicit use-case gate and test enabled, disabled, and unavailable states.
7. Catalog versus statistics scope
Statistics calls the engine with current TenantId. Catalog is a global table and returns DirectoryName, EntityType, DocumentCount, LastRebuiltAt, and Version without tenant filtering. A tenant principal with index.view may see physical/operational metadata.
Decide whether the catalog is platform-admin only, a redacted tenant view, or an internal health surface. Query permission and physical directory visibility should not share a vague permission by accident.
8. Per-index authorization
UnifiedSearch accepts any IndexKey but always describes resource search/search. A caller with unified view may probe conflict or future sensitive indexes.
Options include a per-index permission, catalog-defined RequiredPermission/DataClassification, authorization attributes carrying IndexKey, or exclusive dedicated endpoints for high-sensitivity scenarios. Every model still needs owner-level authorization for hits.
9. Error contract
Stable module errors are limited to:
SegmentWord.EmptyWord;ConflictRecord.PendingCasesModule.
There is no stable IndexUnknown, QueryInvalid, WindowTooLarge, ProviderUnavailable, ProviderTimeout, or RebuildConflict classification. Raw provider exceptions reach the global exception layer.
10. Endpoint test matrix
- Every catalog grant versus actual action yields fixed 200/403.
- Feature enabled, disabled, and unavailable.
- Authentication, rate limit, and timeout.
- IndexKey allowlist and per-scenario permission.
- Skip, Take, and Keyword bounds.
- Null/empty/duplicate/Unicode/large Parties.
- Request TenantId cannot affect effective tenant.
- Invalid SegmentType uses shared Problem Details.
- Manual/generated OpenAPI consistency.
- Provider unavailability is never returned as zero hits.
11. Review commands
# Conflict and rebuild now match the catalog; word create/delete still drift from manage/view.rg -n "AuthorizationAction\.|search\.(search|conflict|index)\." \ src/Platform/Search src/Hosts/BitzOrcas.Api/Endpoints/SearchEndpoints.cs -g '*.cs'
# Search currently has no central runtime feature mapping.rg -n "search\.index|ModuleFeatureMap|\[\"search\"\]" src tests -g '*.cs'
# Target state: explicit rules for every high-risk Search request.rg -n "IRequestRule<.*(UnifiedSearch|CheckConflict|RebuildIndex|Segment)" \ src/Platform/Search tests -g '*.cs'Back to Search · Unified search and conflict · Testing and GA