Guidance serves end users, content administrators, and campaign operators, and the three read surfaces have different security goals. End users should see only the current published body allowed by their audience. Administrators need Draft, state, and version metadata. Operators manage campaign lifecycle and audience. Content requests share the guidance/content resource, campaign requests use the guidance/campaign resource, and assistant requests use the guidance/assistant resource.
1. Explicit authorization declaration
Every request sets a ResourceDescriptor explicitly rather than relying on type-name inference. Actions are View/Create/Update/Delete for content and Read/Manage/Use for campaigns. The permission catalog registers six codes across three resources:
guidance.content.read,guidance.content.manage(content);guidance.campaign.read,guidance.campaign.manage,guidance.campaign.use(campaign);guidance.assistant.use(assistant).
How authorization Actions map to these codes must still be confirmed through real HTTP tests. The content side is still not split into finer permissions such as PublishedRead/DraftRead/Publish/PlatformManage, which is the root of the shared content-administration and end-user read surface (see §7).
2. Platform and tenant guards
TenancyDefaults.UnsetTenantId represents the platform tenant. Regular tenants can create/mutate only their own content. The platform tenant can create/mutate platform or arbitrary-tenant content. For reads, TenantId-null platform content passes for every tenant, and the platform tenant can read every tenant.
// Primary-key lookup is global, so object scope must be checked immediately.var content = (await store.GetByIdAsync(contentId, cancellationToken)).Value;if (content is null) return GuidanceErrors.ContentNotFound(contentId);
// Platform content requires the platform tenant; tenant content requires the same tenant.var access = GuidanceApplicationGuards.EnsureCanMutate(currentUser, content);if (access.IsFailure) return access.Error;
return await store.DeleteAsync(contentId, cancellationToken);Platform power is inferred from a special tenant ID. There is no evidence here of a separate operator role, approval, or step-up check. Platform administration still needs dedicated permission and audit.
3. Contextual-query security surface
GetContextualGuide requires a nonblank RouteKey and passes current TenantId, Roles, and LanguageCode to the store. The store queries only undeleted Published rows for the tenant or platform null, then applies RequiredRole and rank.
GET /api/v1/guidance/contextual?routeKey=%2Fbilling%2Finvoices&componentId=grid&languageCode=en-USAuthorization: Bearer <token>Success returns full BodyMarkdown. No match returns ContextualNotFound. The query never returns Draft and does not invoke AI automatically.
4. Four-level ranking
With a ComponentId, tenant-component is rank 0, tenant-page 1, platform-component 2, and platform-page 3. Without a component, component candidates are excluded and only page candidates remain.
Within a rank, a nonblank matching RequiredRole sorts before general content, followed by ContentVersion, modify/create time, and ID descending. Role-specific content can therefore beat a newer general guide.
5. Single-role audience limitations
RequiredRole supports one role name or no restriction. It cannot express any/all roles, permission, office, plan, feature, data attribute, or deny rule. Renaming a role silently hides content, with no foreign key or migration.
GA needs a stable Audience Policy and an administration explanation of why a guide matched. End-user errors must not reveal hidden candidates.
6. Platform override semantics
Tenant content always beats platform content. For a component request, a tenant page guide rank 1 also beats a platform component guide rank 2. That is the exact current algorithm; product owners should confirm whether tenant precedence should outweigh component specificity.
There is no explicit platform-guide suppression/tombstone. A tenant that wants to hide platform content must provide another candidate; an empty-body suppression record is invalid.
7. GetById Draft exposure
GetById loads only by global ID and undeleted state. The read guard permits TenantId-null platform content and checks neither Status nor RequiredRole. A regular tenant with content View can read full platform Draft Markdown if it knows the ID.
This is not a ranking defect. It is caused by reusing an administration-detail contract as a general read contract. The fix belongs in API/use-case authorization, not in hiding IDs in the UI.
8. List as an administration surface
List defaults IncludePlatform to true and filters exact route/component, language, status, and offset page. Search scopes tenant/platform rows but intentionally does not filter RequiredRole because it is an administration list. Its current permission is still ordinary View.
// End-user use case returns only Published content visible to the current audience.var visible = await publishedReader.FindForCurrentAudienceAsync( routeKey, componentId, languageCode, currentUser, cancellationToken);
// Administration requires dedicated DraftRead/Manage permission.authorization.Require(GuidancePermissions.DraftRead);var drafts = await adminStore.SearchDraftsAsync(scope, page, cancellationToken);These ports and permissions are target boundaries, not current source.
9. Language selection
Queries default to zh-CN. en-US normalizes case-insensitively; every other value becomes zh-CN. There is no automatic negotiation from request culture, user preference, or Accept-Language, and no explicit missing-en-US fallback chain.
Product policy should order explicit request, user preference, tenant default, and platform default, and distinguish missing translation from invalid locale.
10. Cache boundary
The current store queries the database every time and has no Guidance cache. A future key must contain TenantId, RouteKey, ComponentId, LanguageCode, Audience/role policy version, and PublishedRevision. A route-only key would leak across tenants or roles.
Publish, unpublish, audience changes, and role/permission changes must invalidate. Cache only a published safe projection; Draft administration needs a separate namespace.
11. Error and enumeration behavior
CrossTenantAccessDenied is Forbidden, while an unknown ID is NotFound. The handler reads globally before returning Forbidden for a cross-tenant ID, which may form an existence oracle. High-security APIs can normalize this to NotFound.
Platform Draft access should be Forbidden. An audience-mismatched Published guide should also look absent so its title/required role is not disclosed.
12. HTTP test matrix
| Scenario | Expected |
|---|---|
| anonymous Contextual | 401 |
| ordinary reader gets matching Published | 200 |
| ordinary reader gets platform Draft by ID | forbidden/not found |
| ordinary tenant lists platform Draft | excluded |
| role mismatch | contextual not found |
| tenant B asks for tenant A ID | not found |
| tenant A creates for tenant B | forbidden |
| regular tenant mutates platform content | forbidden |
| platform operator cross-tenant write | dedicated permission plus audit |
| unknown LanguageCode | validation |
13. Audit and privacy
Record route/component, scope, revision, match rank, audience-policy ID, and actor, but not BodyMarkdown. Platform cross-tenant writes need target tenant, reason, change summary, and correlation.
Aggregate no-match metrics safely. Do not return RequiredRole or hidden titles to unauthorized users.
14. Change-design checks
Before adding a match dimension, place it explicitly before or after the four scope ranks and define a truth table for every combination. Do not casually append a ThenBy; that silently changes which published content wins without a versioned migration.
Separate response models as well. End-user DTOs contain safe display fields and PublishedRevision; administration DTOs alone expose status, audience, actor, and change metadata. They must not share cached serialization objects.
- New dimensions require platform/tenant, page/component, and role-change regression tests.
- Selection-rule changes require compatibility notes, rollout metrics, and rollback.
Before rollout, replay production-shaped distributions through old and new algorithms and list every winner change instead of comparing only aggregate hit rate.
15. Review commands
# Current guards, rank, and administration list scope.rg -n "EnsureCanRead|EnsureCanMutate|MatchRank|RoleRank|IncludePlatform" \ src/Platform/Guidance -g '*.cs'
# Fine-grained permissions and audience policy should have no matches.rg -n "DraftRead|PublishedRead|AssistantUse|AudiencePolicy" \ src/Platform/Guidance -g '*.cs'