QDG Knowledge Base Read-only viewer QWebHub
general

Architecture & Technical Reference

Version 1 · Initial technical deep-dive: data model, resolution algorithm, caching, request pipeline — derived from docs/PROJECT_DOCUMENTATION.md and a read of GateKeeper.Cache source (including the in-progress configurable-TTL change)

GateKeeper — Architecture & Technical Reference

Technical deep dive backing Overview. Reflects the code as it exists in the repository, not just the original design proposal — divergences from that proposal are in Known Issues.

Data model

Seven tables:

Table Purpose
customers One row per external client. Holds client_guid (public identifier sent on every request) and status.
api_tokens Secret-key half of the credential pair. SHA-256 hash only — the plaintext secret is never stored, shown once at issuance. Revocable/expirable independently of the customer.
packages Reusable named bundles of entitlement rules (rules JSON column — array of scopes, OR'd together).
data_types Admin-managed picklist for the data_type dimension (e.g. meetings, racecards, speedmaps) — a partial exception to "dimensions are just JSON keys" (see § DataType hybrid).
customer_subscriptions Time-bound grant of a package to a customer (start_date/end_date, status).
customer_overrides Per-customer grant or revoke of a single scope, independent of any package. Revokes always win over grants.
api_request_logs Append-only audit trail, written asynchronously in batches, never on the request's critical path. Partitioned by month.

Entities

Customer             Id, ClientGuid, Name, ContactEmail, Status, CreatedAt, UpdatedAt,
                      ApiHitLimitMonthly (int?, NOT YET ENFORCED)
ApiToken              Id, CustomerId, Label, TokenHash (SHA-256), Status, ExpiresAt, RevokedAt, CreatedAt
Package               Id, Code, Name, IsActive, Rules (List<EntitlementScope>, JSON), CreatedAt, UpdatedAt
DataType              Id, Code, Name, CreatedAt
CustomerSubscription  Id, CustomerId, PackageId, StartDate, EndDate, Status, CreatedAt
CustomerOverride      Id, CustomerId, Effect (Grant/Revoke), Constraints (single EntitlementScope, JSON),
                       StartDate, EndDate, Note, CreatedAt, CreatedBy
ApiRequestLog          Id, OccurredAt, CustomerId?, TokenId?, Endpoint, Method, StatusCode, LatencyMs, ClientIp

Enums (GateKeeper.Core/Enums.cs), PascalCase in C#, mapped to lowercase MySQL ENUM literals via EnumConversions.cs: CustomerStatus (Active/Suspended/Cancelled), TokenStatus (Active/Revoked), SubscriptionStatus (Active/Cancelled), OverrideEffect (Grant/Revoke).

EntitlementScope — the shared shape

public record EntitlementScope(Dictionary<string, List<string>> Constraints);

A set of dimension → allowed-values constraints, e.g. {"country":["AU","NZ"],"data_type":["speedmaps"]}. A dimension key absent from a scope is a wildcard (always matches). Dimensions currently used: country, discipline, data_type, venue (GateKeeper.Core/Dimensions.cs — adding one is a code change, not a schema migration). Package.Rules is a list of scopes (OR'd/unioned); CustomerOverride.Constraints is a single scope. A custom EntitlementScopeJsonConverter makes this one type serialize identically into an EF Core JSON column or a Redis payload.

Entitlement resolution

public record EffectiveEntitlement(
    long CustomerId,
    IReadOnlyList<EntitlementScope> Grants,   // union across active packages + grant overrides
    IReadOnlyList<EntitlementScope> Denies,   // revoke overrides
    DateTime ComputedAt);

Query (cache-miss only) — EntitlementRepository.ResolveAsync: all Rules from customer_subscriptions joined to packages where status = 'active' and today is within [start_date, end_date], plus all (effect, constraints) from customer_overrides where today is within range. These feed EntitlementResolver.Resolve (pure, static, no date logic of its own): package rules → Grants; grant overrides append to Grants; revoke overrides append to Denies.

Match (every request) — EffectiveEntitlement.IsAllowed: any Denies scope matches → deny; else any Grants scope matches → allow; else → deny (default-closed). A scope matches when, for every dimension it constrains, the request's value is in the allowed list (case-insensitive).

Caching (GateKeeper.Cache)

Cache-aside, two tiers:

L1 (IMemoryCache, per instance) L2 (Redis, shared)
Token key token:{hex(sha256(secret))} same
Entitlement key entitlement:{customerId} same
TTL fixed 4 s configurable, default 5 min
Refresh-on-hit no no
Negative caching none — a token that fails to resolve is never cached as "missing"

The Redis (L2) TTL for both caches was hardcoded at 5 minutes; it's now read from config — Cache:TokenTtlMinutes / Cache:EntitlementTtlMinutes (TwoTierTokenCache/TwoTierEntitlementCache, via injected IConfiguration), defaulting to 5 if unset, with 0 or negative meaning no expiry. This matters because a downstream consumer (TD) reads Redis directly and never triggers GateKeeper.Api's repopulate-on-miss path, so raising or disabling the L2 TTL just stops valid, untouched entries from spuriously falling out of cache — it doesn't weaken correctness on real changes, since those are pushed immediately regardless of TTL. L1 TTL stays a fixed 4 s constant in both classes.

Invalidation is push-based, not TTL-based: any admin write goes through GateKeeperAdminService, which deletes the Redis key and publishes on token:invalidate / entitlement:invalidate. Every API process runs CacheInvalidationSubscriber (a hosted service) listening on both channels, evicting the matching key from its own L1 the moment the message arrives. Net effect: an admin change is visible to new requests within the pub/sub propagation window (milliseconds), not the Redis TTL.

Request pipeline (GateKeeper.Api)

Middleware order in Program.cs:

AuditLoggingMiddleware              ← wraps EVERYTHING, including 401 short-circuits below
   │
   ├─ path starts with "/admin"?
   │     └─ AdminAuthMiddleware      (checks X-Admin-Key)
   │
   └─ else:
         ├─ ClientAuthenticationMiddleware   (checks X-Client-Guid + X-Api-Secret)
         └─ EntitlementResolutionMiddleware  (loads EffectiveEntitlement into HttpContext.Items)
   │
   ▼
Endpoint handler (/v1/meetings, or an /admin/* route)

AuditLoggingMiddleware is registered first (outermost) specifically so it captures the final status code even when auth middleware short-circuits with a 401 — registered last, rejected requests would never reach it.

ClientAuthenticationMiddleware, in order: X-Client-Guid present and a valid GUID (else 401) → X-Api-Secret present and non-empty (else 401) → SHA-256 the secret, look up token:{hash} (L1 → Redis → MySQL on miss) → reject if token not found/revoked/expired, owning customer not active, or the resolved token's ClientGuid doesn't match the header's (stops a leaked secret working under a different client-guid) → on success, stashes CustomerId/TokenId on HttpContext.Items.

EntitlementResolutionMiddleware: reads CustomerId from HttpContext.Items, loads entitlement:{customerId} (L1 → Redis → MySQL recompute on miss), stores the EffectiveEntitlement back on HttpContext.Items.

AdminAuthMiddleware: /admin/* routes skip both of the above, gated only by an exact-match X-Admin-Key header against Admin:ApiKey. These routes never populate CustomerId/TokenId, so their audit rows always have customer_id = token_id = NULL — still logged (timing + status), just without customer attribution.

Auditing: AuditLoggingMiddleware times the request and writes an AuditLogEntry into a bounded in-memory Channel (capacity 10,000, drops-and-continues if full — never adds response latency). AuditLogBackgroundService drains it in batches of up to 500, flushing on batch-full or every 250 ms, doing one bulk INSERT per flush against api_request_logs.

Admin UI (GateKeeper.AdminUI)

Blazor Server app (dotnet run --project GateKeeper.AdminUI, default http://localhost:5190), calling GateKeeperAdminService directly in-process — no HTTP round-trip to GateKeeper.Api. Login screen compares the entered key to the same Admin:ApiKey config value, tracked per Blazor circuit (AdminSession, scoped) — a hard reload logs you out; no cookie/JWT session, and this is implemented independently of AdminAuthMiddleware (both read the same config key but share no validation code).

Pages: Dashboard (customer counts, API hits this month, 6-month new-customer chart via Chart.js, subscriptions expiring in 30 days, per-package subscriber counts) · Customers (list/create/delete) · Customer detail (issue/revoke tokens, usage + the unenforced monthly hit limit, package subscriptions, overrides via comma-separated dimension inputs) · Packages (CRUD with a repeatable scope editor; data_type is a multi-select from the live table, country/discipline/venue are free-text comma-separated) · Data Types (CRUD for the picklist; code immutable after creation, delete does not check whether a package still references it).

Updated by Claude on Aug. 11, 2026, 10:19 a.m. · Task: updatewiki create project documentation