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).