Appearance
User-write observability (audit, security logging, rate limiting, invalidation)
Epic 889 gave the tenant track (/api/v1/users/*) write capability alongside the existing admin track (/api/v1/admin/users/*). US 5.4 makes the two tracks homogeneous under four controls. This page is the operation × control matrix and the per-endpoint rate-limit decision.
The four controls
| Control | Mechanism | Where |
|---|---|---|
| Audit | AuditSaveChangesInterceptor writes an AuditEntry on commit for every changed IAuditableEntity. Each entry names the caller (IAuditContext.UserId), the token tenant, the target (entity id), the operation (ChangeType), before/after values and the correlation id. | GrydAudit.Infrastructure/Interceptors/AuditSaveChangesInterceptor.cs; context adapter HttpContextAuditContext |
| Security log | Structured Serilog warning on a security decision (a block), carrying an EventType and RiskLevel, with all personal data masked. | AuthTracingConstants.SecurityEvents + the emitting site |
| Rate limit | Per-endpoint ASP.NET rate-limiting policy; 429 on rejection. | GrydAuthRateLimitingPolicies / GrydAuthRateLimitingExtensions |
| Cache / token invalidation | Authorization-affecting domain events → AuthorizationAffectingEventHandler bumps TokenVersion and blacklists the user's tokens; affected users resolved by AffectedUsersResolver. | GrydAuth.Application/Features/Security/EventHandlers + AffectedUsersResolver |
Both tracks funnel their writes through the same MediatR handlers and the same SaveChangesAsync, so audit and invalidation are automatic for both — the admin track adds nothing the tenant track lacks. The audit entry does not carry a "track" field; the track is derivable from the caller's effective permissions and the request path.
Operation × control matrix
| Operation (endpoint) | Audit entity (change) | Security log on block | Rate limit | Invalidation event |
|---|---|---|---|---|
Create user (POST /users) | User (Created) + UserTenant (Created) + UserAppId | tenant_link_request_probe on existing-email (anti-enumeration) | tenant-user-create (per IP, US 4.2) | UserAssignedToTenantEvent |
Update user (PUT /users/{id}) | User (Updated); UserTenant (Updated) when IsActive flips | cross_tenant_access_denied on cross-tenant target (404) | none (see below) | UserTenantActivated/UserTenantDeactivated when IsActive flips |
Delete/unlink user (DELETE /users/{id}) | UserTenant (unlink) | cross_tenant_access_denied | none | UserRemovedFromTenantEvent (and UserDeletedEvent on the admin hard-delete) |
Assign role (POST /users/{id}/roles/{roleId}) | UserRole (Created) | role_assignment_escalation_blocked (US 5.1) · cross_tenant_access_denied | none | UserRoleAssignedEvent |
Remove role (DELETE /users/{id}/roles/{roleId}) | UserRole (Deleted) | cross_tenant_access_denied | none | UserRoleRemovedEvent |
Assign permission (POST /users/{id}/permissions/{permId}) | UserPermission (Created) | role_assignment_escalation_blocked (US 5.1) · cross_tenant_access_denied | none | UserPermissionAssignedEvent |
Remove permission (DELETE /users/{id}/permissions/{permId}) | UserPermission (Deleted) | cross_tenant_access_denied | none | UserPermissionRemovedEvent |
Admin equivalents (/admin/users/*) | same entities/changes | role_assignment_escalation_blocked (covered by admin:system override → never blocks) | none | same events |
| Any throttled request | — | rate_limit_exceeded (429) | the tripped policy | — |
Per-endpoint rate-limit decision
POST /users→tenant-user-create(per IP). This is the only tenant-track write that is an enumeration vector: it answers a genericUSER_EMAIL_UNAVAILABLEfor an already-used e-mail (anti-disclosure, D8), so an attacker could probe e-mail existence. It runs on its own IP partition, deliberately off the sharedauth:{ip}budget (US 4.2).- All other tenant-track writes (
PUT/DELETE /users/{id}, role/permission assign/remove): no dedicated policy. They operate on an already-resolved, known user in the caller's own tenant, gated by a privileged permission (update:users/delete:users) and by tenant isolation (a cross-tenant target answers 404). They are not enumeration vectors and do not leak existence, so a per-IP anti-enumeration limiter adds no security value. They remain subject to the global request pipeline. Revisit if abuse is observed in practice.
Security-event catalog
Domain-specific event types live in AuthTracingConstants.SecurityEvents:
| Event type | Emitted when | Risk |
|---|---|---|
role_assignment_escalation_blocked | a grant would exceed the caller's own permissions (US 5.1) | High |
cross_tenant_access_denied | a tenant-scoped request targeted a user outside the caller's tenant (US 5.4) | Medium |
rate_limit_exceeded | a request was throttled with 429 (US 5.4) | Medium |
tenant_link_request_probe | a tenant-scoped create hit an existing global e-mail (US 4.2) | Medium |
All of these follow docs/security/security-logging-policy.md: only ids, event types and decisions are logged in the clear; e-mail and IP are masked via SensitiveDataMasker; no tokens, claims or raw personal data ever reach the logs.
Cache-invalidation coverage note
AffectedUsersResolver covers every authorization-affecting write: role/permission assign-remove (direct and via RolePermission*), tenant assign/remove, tenant activate/deactivate, user deactivate/delete. Profile-only updates are intentionally not authorization-affecting — UserUpdatedEvent / UserProfileUpdatedEvent do not implement IAuthorizationAffectingEvent, because a name/e-mail edit changes no role, permission or tenant membership and therefore must not invalidate live sessions. Authorization changes that ride along an update (an IsActive flip) raise the UserTenant activation events, which are covered.