Tasks 1.1-1.9 marked done with their landing commit SHAs. Tasks 1.10 (observability), 1.12 (production hardening), and 1.13 (device authority) marked paused with explicit resume triggers — pilot deployment on real Teltonika hardware takes priority. Task 1.11 remains as next, in slimmed form for the pilot (no /readyz healthcheck since the metrics endpoint is part of paused 1.10).
8.6 KiB
Task 1.13 — Device authority (Redis allow-list refresher)
Phase: 1 — Inbound telemetry
Status: ⏸ Paused — deferred until after the real-device pilot test, AND until Directus has a devices collection publishing the allow-list to Redis. See ROADMAP.md "Deferred" section. The DeviceAuthority seam exists with AllowAllAuthority (default, in src/adapters/teltonika/device-authority.ts); this task adds RedisAllowListAuthority.
Depends on: 1.4 (DeviceAuthority seam), 1.10 (metrics)
Wiki refs: docs/wiki/concepts/plane-separation.md, docs/wiki/entities/directus.md, docs/wiki/entities/redis-streams.md
Goal
Provide a real DeviceAuthority implementation that classifies an IMEI as known or unknown by consulting an allow-list published from Directus into Redis and cached in-memory in each Ingestion instance. This is the operational link between the business plane (where the source-of-truth devices collection lives) and the telemetry plane (where Ingestion makes its handshake decisions).
Non-goals
- Not a security boundary. Real device security is network-level + downstream filtering. This list is a soft signal for observability and (optionally) a hard reject under
STRICT_DEVICE_AUTH. - Not a real-time check. The list is cached locally with periodic refresh; new device provisioning takes effect within the refresh interval.
Deliverables
src/adapters/teltonika/redis-allow-list-authority.ts:RedisAllowListAuthorityimplementingDeviceAuthority.- In-memory
Set<string>of allowed IMEIs. - Refresh worker that pulls from Redis on a configurable cadence.
start()runs an initial fetch synchronously (so the cache is warm before the TCP listener accepts) and then starts the periodic refresh.stop()halts the refresh ticker.
src/main.tsupdated:- Read
DEVICE_AUTHORITY_MODEenv var (allow_all|redis_allow_list, defaultallow_all). - Construct the appropriate authority and pass it into the adapter context.
- Read
- Documentation in
OPERATIONS.md(task 1.12) — section "Device authority" describing the env vars, refresh cadence, and Directus contract.
Specification
Redis contract
The Ingestion side reads from a single Redis key. Two viable shapes; pick one and stick with it.
Option 1: Redis Set. Simple, idiomatic for membership checks.
SADD devices:allowed <imei1> <imei2> ...
SMEMBERS devices:allowed # what the refresher reads
SISMEMBER devices:allowed <imei> # what an on-demand check would do (we do not use this; we cache)
Option 2: Redis Hash with metadata per device. Useful if downstream wants more than membership (e.g. device model, firmware version, owner).
HSET devices:allowed <imei> '{"model":"FMB920","fw":"03.27"}'
HGETALL devices:allowed
Recommendation: Option 1 (Set). Membership is the only signal Ingestion uses; metadata belongs in Directus where it's queryable. If a future task needs metadata in Ingestion, switch to Option 2.
Directus → Redis sync (out of scope for this task)
This task implements the Ingestion-side reader. The Directus-side publisher is a separate piece of work in the Directus repo:
- A
devicescollection in Directus with at leastimei,activefields. - A Directus Flow or hook that, on
items.create | items.update | items.deleteofdevices, updates the Redis Set:- Active inserted/updated →
SADD devices:allowed <imei>. - Deleted or
active=false→SREM devices:allowed <imei>.
- Active inserted/updated →
- A periodic full-resync (e.g. nightly cron) that snapshots the collection into Redis to recover from any drift:
DEL devices:allowed && SADD devices:allowed <imei1> ... <imeiN>.
Document this contract in the Ingestion repo's OPERATIONS.md so on-call understands the dependency, but the implementation lives in Directus.
Refresh strategy
class RedisAllowListAuthority implements DeviceAuthority {
private cache = new Set<string>();
private timer?: NodeJS.Timeout;
constructor(
private redis: Redis,
private key: string = 'devices:allowed',
private intervalMs: number = 30_000,
private logger: Logger,
private metrics: Metrics,
) {}
async start(): Promise<void> {
await this.refresh(); // synchronous initial load before TCP listener is up
this.timer = setInterval(() => {
this.refresh().catch((err) => this.logger.warn({ err }, 'allow-list refresh failed'));
}, this.intervalMs);
}
stop(): void { if (this.timer) clearInterval(this.timer); }
async check(imei: string): Promise<'known' | 'unknown'> {
return this.cache.has(imei) ? 'known' : 'unknown';
}
private async refresh(): Promise<void> {
const start = process.hrtime.bigint();
const members = await this.redis.smembers(this.key);
this.cache = new Set(members);
const ms = Number(process.hrtime.bigint() - start) / 1e6;
this.metrics.allowListRefresh.observe(ms / 1000);
this.metrics.allowListSize.set(this.cache.size);
this.logger.debug({ size: this.cache.size, took_ms: ms }, 'allow-list refreshed');
}
}
Failure modes
- Redis unavailable at startup.
start()throws → process exits non-zero → orchestrator restarts. Loud failure, easy to alert. Operators may opt to fall back toallow_allvia env var change. - Redis unavailable mid-flight.
refreshfails; the cache stays at last-known-good.checkkeeps working off the stale cache. Log warn; metric for refresh failures. Eventually the cache is "stale forever" if Redis never recovers — that's fine because telemetry is still flowing. - Empty allow-list. A bug or misconfiguration in Directus could publish an empty Set. The Ingestion side will then mark every device as
unknown. WithSTRICT_DEVICE_AUTH=false(default), this is a visibility problem (alert-worthy) but not a service outage. WithSTRICT_DEVICE_AUTH=true, the entire fleet would be rejected — bad. Add a safety: refuse to apply a refresh result of size 0 unlessALLOW_EMPTY_ALLOW_LIST=trueis set explicitly. Log error; keep the previous cache.
Configuration
Add to the env schema (task 1.3):
DEVICE_AUTHORITY_MODE: z.enum(['allow_all', 'redis_allow_list']).default('allow_all'),
DEVICE_ALLOW_LIST_KEY: z.string().default('devices:allowed'),
DEVICE_ALLOW_LIST_REFRESH_MS: z.coerce.number().int().min(1000).default(30_000),
STRICT_DEVICE_AUTH: z.coerce.boolean().default(false),
ALLOW_EMPTY_ALLOW_LIST: z.coerce.boolean().default(false),
Metrics
Add to task 1.10's inventory:
| Metric | Type | Labels | Description |
|---|---|---|---|
teltonika_allow_list_size |
gauge | — | Number of IMEIs in the local cache. Sudden drops are alert-worthy. |
teltonika_allow_list_refresh_duration_seconds |
histogram | — | Time to refresh from Redis. |
teltonika_allow_list_refresh_failures_total |
counter | reason |
Refresh attempts that failed (network, empty-rejected, etc.). |
Acceptance criteria
- With
DEVICE_AUTHORITY_MODE=allow_all, behavior is identical to Phase 1 default — every IMEI isknown. - With
DEVICE_AUTHORITY_MODE=redis_allow_listand a populated Redis Set,check(imei)returns'known'for members and'unknown'for non-members. - Initial load happens before the TCP listener accepts connections.
- Refresh runs every
DEVICE_ALLOW_LIST_REFRESH_MSand updates the cache. - Empty allow-list refresh is rejected (cache preserved) unless
ALLOW_EMPTY_ALLOW_LIST=true; metric increments withreason=empty_rejected. - Mid-flight Redis outage does not crash the service; subsequent successful refresh restores the cache.
teltonika_allow_list_sizeandteltonika_allow_list_refresh_duration_secondsappear in/metrics.STRICT_DEVICE_AUTH=truecombined withredis_allow_listcauses0x00rejection of unknown IMEIs (verified by integration test).
Risks / open questions
- Provisioning lag. A newly added device waits up to
DEVICE_ALLOW_LIST_REFRESH_MSbefore being recognized. Default 30s is fine for most ops; tune down to 5s if the team has a workflow where they provision and immediately expect the device to beknown. - Cache size. A Set of 100k IMEIs is ~6MB in memory — fine. At 1M+ devices, consider a Bloom filter + Redis fallback for misses, or split into shards. Not a near-term concern.
- Drift between Directus and Redis. Hooks-based sync can miss updates if Directus has an issue mid-write. The nightly full-resync cron mitigates. Discussed in the Directus-side task (out of repo scope here).
- Should
STRICT_DEVICE_AUTHbe observable? Yes — log at info on startup which mode the authority is in, so operators can verify config without reading env vars.
Done
(Fill in once complete.)