Skip to content

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 ​

ControlMechanismWhere
AuditAuditSaveChangesInterceptor 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 logStructured Serilog warning on a security decision (a block), carrying an EventType and RiskLevel, with all personal data masked.AuthTracingConstants.SecurityEvents + the emitting site
Rate limitPer-endpoint ASP.NET rate-limiting policy; 429 on rejection.GrydAuthRateLimitingPolicies / GrydAuthRateLimitingExtensions
Cache / token invalidationAuthorization-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 blockRate limitInvalidation event
Create user (POST /users)User (Created) + UserTenant (Created) + UserAppIdtenant_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 flipscross_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_deniednoneUserRemovedFromTenantEvent (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_deniednoneUserRoleAssignedEvent
Remove role (DELETE /users/{id}/roles/{roleId})UserRole (Deleted)cross_tenant_access_deniednoneUserRoleRemovedEvent
Assign permission (POST /users/{id}/permissions/{permId})UserPermission (Created)role_assignment_escalation_blocked (US 5.1) · cross_tenant_access_deniednoneUserPermissionAssignedEvent
Remove permission (DELETE /users/{id}/permissions/{permId})UserPermission (Deleted)cross_tenant_access_deniednoneUserPermissionRemovedEvent
Admin equivalents (/admin/users/*)same entities/changesrole_assignment_escalation_blocked (covered by admin:system override → never blocks)nonesame 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 generic USER_EMAIL_UNAVAILABLE for 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 shared auth:{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 typeEmitted whenRisk
role_assignment_escalation_blockeda grant would exceed the caller's own permissions (US 5.1)High
cross_tenant_access_denieda tenant-scoped request targeted a user outside the caller's tenant (US 5.4)Medium
rate_limit_exceededa request was throttled with 429 (US 5.4)Medium
tenant_link_request_probea 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.

Released under the MIT License.