1 - How Caching and Rate Limiting Work

Under The Hood: Caching, Locking, Rate Limiting

You don't need this page to use the library, but if you're wiring it into something sensitive (camera, screen capture) you should know how the fast paths behave.


Caching — we cache the value, not the decision

This is the single most important design choice, so it gets its own paragraph.

The permission cache stores the integer permission value, not a true/false answer. Every cache hit is re-compared against the caller's requiredLevel:

// on a hit:
return cachedValue != PERMISSION_ABSENT && cachedValue >= requiredLevel;

Why it matters: if we cached a decision, a check that passed at level 3 would cache true, and a later check at level 10 would get that true back — instant privilege escalation. By caching the value (5), level 3 → true and level 10 → false, both honest, from the same entry. It also means requiredLevel is not part of the cache key.

Absence is negatively cached too, using a sentinel (PERMISSION_ABSENT), so repeatedly checking a requester that doesn't exist doesn't hammer the disk — but a sentinel is never mistaken for a real level.

Cache entries live in a SecureCache (encrypted at rest, wiped on eviction) with a 5-minute TTL. Grants/changes/removals update or evict the relevant entry immediately, so a change is never hidden behind a stale hit.
Revocation latency: a plain RemovePermission/ChangePermission does evict the cache, so it's immediate. But if a value were changed by editing the store file out-of-band, a cached grant could survive up to 5 minutes. For high-risk permissions, always go through the handler (which evicts), and consider a shorter TTL if you're paranoid. See 2 - Security Model and Threat Notes.

Writes are serialized per store

Every mutating call does load → mutate → save on the whole file. If two of those ran at once, one write would silently lose — and in a permission system a lost write is often a lost revocation.

So each store file has its own lock (a SemaphoreSlim, keyed on the file path). Two writes to the same store queue; two writes to different stores run in parallel. Reads that fall through to disk take the same lock so nobody reads a half-written file.

ChangePermission delegates to HandlePermission and does not take the lock itself — the lock isn't reentrant, so doing both would deadlock. If you add new mutating methods, take the lock once per top-level call.

Rate limiting — a real fixed window

Each requester gets a bucket: 100 requests per 60 seconds.

The window's expiry is stored alongside the count and is not pushed forward on every hit. That matters:

  • Wrong way (old behaviour): refresh the TTL every request → the window never ends for a busy caller → a legitimate, chatty app locks itself out permanently.
  • Right way (now): the count resets once the fixed 60-second window elapses. Busy-but-legit callers recover on their own.

The read-modify-write is done under a lock, so two concurrent calls can't both read the same count and overwrite each other.

// exceeding the bucket:
throw new RateLimitExceededException(); // message contains "Rate limit exceeded"
The bucket is keyed on the requester ID. Because those IDs are Manager-issued, not UI-chosen (see 1 - Welcome), an app can't just rotate its ID to reset the bucket. This only holds as long as that stays true — if UIs could mint their own IDs, rate limiting (and everything else) would be bypassable.

Auditing failures

Reads fail closed (false), but a failure caused by a wrong key, a deleted store, or a corrupted file is not silently identical to an honest denial:

Handler.OnStoreError = ex =>
{
    // log it, raise a device alert, whatever you need
};

Wire this up. A permission system that can't tell "denied" from "tampered" is unauditable, and unauditable is a euphemism for compromised.