diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ef0de92..53195cf 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -60,6 +60,34 @@ https://github.com/hyperpolymath/aerie/pulls[hyperpolymath/aerie]. === Added +* The weave, Phase 2 — aspects as data: + `src/api/zig/aspects.zig` runs the five-step pipeline (cache read -> + produce -> envelope -> cache write -> audit) ONCE; resolvers.zig's + five hand-copied pipelines collapse to producers plus aspect lists + (`telemetry_aspects` = cache:30s, enveloped, audited; audit is never + cached; temporal is dual-audited to Redis AND VerisimDB). REST, gRPC + and GraphQL all resolve through the same pipelines now. Cached + responses are audited too (the old code skipped audit on cache + hits); the temporal producer also fixes a dangling-slice bug in the + as_of default-time path. +* `src/api/zig/keystore.zig` — API keys and entitlements as data + (AERIE_API_KEYS / KYAML api_keys: "key:name:mod1,mod2"), with + constant-time compares and no early exit. `auth_mode` now defaults + to DENY: missing/unknown keys get 401, unentitled modules 403; + health and meta stay public; every decision — allowed or not — is + audited. Dev posture: AERIE_AUTH_MODE=open (compose sets it). +* `/api/v1/meta` — the gateway describes itself from the same route + and aspect tables the dispatcher uses: paths, verbs, modules, + aspects, auth posture, versions. The description cannot drift from + the behaviour because it IS the behaviour. + +=== Changed + +* `policyContextString` now states phase2-weave without the false + "entitlements=all" claim. + +=== Added (phase 1) + * Gateway skeleton, Phase 1 of the aspect weave — `src/api/zig/{kyaml,config,ctx,errors,router,respond}.zig`: the KYAML parser (strict KEP-5295 subset, estate rule Y-3); typed diff --git a/compose.yml b/compose.yml index e88226d..457db28 100644 --- a/compose.yml +++ b/compose.yml @@ -20,6 +20,11 @@ services: environment: - PORT=4000 - GRPC_PORT=4001 + # Phase 2 policy gate: the binary default is deny-by-default; + # the dev compose stays permissive. For a key-checked gateway: + # AERIE_AUTH_MODE=deny + # AERIE_API_KEYS=::telemetry,routes[;::…] + - AERIE_AUTH_MODE=open - REDIS_URL=redis://redis:6379 - LIBRESPEED_URL=http://librespeed:80 - HYPERGLASS_URL=http://hyperglass:80 diff --git a/configs/aerie.kyaml b/configs/aerie.kyaml index 1581c23..0539504 100644 --- a/configs/aerie.kyaml +++ b/configs/aerie.kyaml @@ -12,5 +12,13 @@ librespeed_url: "http://librespeed:80" hyperglass_url: "http://hyperglass:80" smokeping_url: "http://smokeping:80" verisim_url: "http://verisim:8084" -# auth: "open" (Phase-1 default, permissive) | "deny" (Phase-2 keystore) -auth: "open" +# Deny-by-default (Phase 2): only public routes (health, meta) answer +# without a key. "open" restores the permissive dev posture. +auth: "deny" +# API keys: "key" (all modules) | "key:name" | "key:name:mod1,mod2". +# Env alternative: AERIE_API_KEYS="spec;spec". Keys never appear in +# logs or /api/v1/meta except redacted. +api_keys: [ + "change-me-0000000000001:ops:*", + "change-me-0000000000002:soc:telemetry,routes,smokeping", +] diff --git a/src/api/zig/aspects.zig b/src/api/zig/aspects.zig new file mode 100644 index 0000000..e635cfd --- /dev/null +++ b/src/api/zig/aspects.zig @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// aspects.zig — the weave. Cross-cutting concerns are ASPECTS: data on +// the resolution, not code copied into every resolver. The pipeline +// below is the single implementation of what resolvers.zig used to +// hand-copy five times: +// +// cache read -> produce payload -> proof envelope -> cache write +// -> audit +// +// METAICONIC: the aspect list is data (rendered by /api/v1/meta from +// the same constants the pipeline interprets); adding cache/audit to a +// resolution is a table edit, not a code edit. +// +// Honesty notes: cached results are audited too (a served response is +// an auditable event — the old code skipped audit on cache hits); +// producer errors pass through un-enveloped and un-cached, exactly as +// the per-resolver code did. + +const std = @import("std"); +const ctx = @import("ctx.zig"); +const res = @import("resolvers.zig"); +const prf = @import("proof.zig"); +const rc = @import("redis_client.zig"); +const vc = @import("verisim_client.zig"); +const respond = @import("respond.zig"); + +/// One cross-cutting concern, as data. +pub const Aspect = union(enum) { + /// Read-through cache, key = module[:extra], TTL in seconds. + cache: struct { ttl_s: u32 }, + /// Wrap the payload in the proof envelope (skip on error payloads). + enveloped, + /// Audit the resolution to Redis (runs on cache hits too). + audited, + /// Audit to Redis AND VerisimDB (temporal module). + dual_audited, +}; + +/// A payload producer: probe/query and render the payload JSON into +/// `payload_buf` (stack storage provided by the pipeline), returning a +/// slice of it. `arg` is the resolution's primary parameter (target, +/// mode, limit-as-string) — adapters extract it from their protocol. +pub const Producer = *const fn (c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8; + +/// Does the list carry this aspect? (Public: resolvers' tests and +/// meta rendering ask the same question the pipeline asks.) +pub fn hasAspect(aspects_: []const Aspect, comptime tag: std.meta.Tag(Aspect)) bool { + for (aspects_) |a| { + if (a == tag) return true; + } + return false; +} + +/// The cache TTL the list declares (0 when uncached). +pub fn cacheTtlOf(aspects_: []const Aspect) u32 { + for (aspects_) |a| switch (a) { + .cache => |spec| return spec.ttl_s, + else => {}, + }; + return 0; +} + +/// The pipeline. Runs the aspects around `producer` and places the +/// final body in the response slot. +pub fn run(c: *ctx.Ctx, aspects_: []const Aspect, extra: []const u8, producer: Producer) void { + const mod = if (c.route) |r| r.module else "unknown"; + + // 1. Cache read — a hit is served verbatim (it stores the enveloped + // body) and still audited. + const caching = hasAspect(aspects_, .cache); + var key_buf: [160]u8 = undefined; + var cache_key: []const u8 = ""; + if (caching) { + cache_key = if (extra.len > 0) + std.fmt.bufPrint(&key_buf, "{s}:{s}", .{ mod, extra }) catch mod + else + mod; + const cached = c.redis.getCached(cache_key, c.out_buf); + if (cached.len > 0) { + if (hasAspect(aspects_, .audited)) res.logAudit(c.redis, c.policy); + if (hasAspect(aspects_, .dual_audited)) res.logDualAudit(c); + respond.respond(c, 200, cached); + return; + } + } + + // 2. Produce the payload. + var payload_buf: [65536]u8 = undefined; + const payload = producer(c, extra, &payload_buf); + const is_error = std.mem.startsWith(u8, payload, "{\"error\":"); + + // 3. Envelope (errors pass through raw, matching the historical + // per-resolver behaviour). + var result: []const u8 = undefined; + if (hasAspect(aspects_, .enveloped) and !is_error) { + var ctx_buf: [128]u8 = undefined; + const pctx = prf.policyContextString(mod, &ctx_buf) catch "aerie-policy-v1"; + result = prf.wrapBodyWithProof(payload, pctx, c.out_buf) catch { + respond.respondError(c, 500, "proof wrap failed"); + return; + }; + } else { + // payload lives in this frame's stack: copy into response scratch + result = c.copyToBody(payload, "{\"error\":\"internal error\"}"); + } + + // 4. Cache write (never cache error payloads). + if (caching and !is_error) { + c.redis.cacheResult(cache_key, result, cacheTtlOf(aspects_)); + } + + // 5. Audit. + if (hasAspect(aspects_, .audited)) res.logAudit(c.redis, c.policy); + if (hasAspect(aspects_, .dual_audited)) res.logDualAudit(c); + + respond.respond(c, 200, result); +} + +/// Render one aspect for /api/v1/meta (reflective description). +pub fn describe(a: Aspect, buf: []u8) []const u8 { + return switch (a) { + .cache => |spec| std.fmt.bufPrint(buf, "cache:{d}s", .{spec.ttl_s}) catch "cache", + .enveloped => "enveloped", + .audited => "audited", + .dual_audited => "dual_audited", + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "aspects: presence detection and ttl" { + const aspects_ = [_]Aspect{ .{ .cache = .{ .ttl_s = 30 } }, .enveloped, .audited }; + try std.testing.expect(hasAspect(&aspects_, .cache)); + try std.testing.expect(hasAspect(&aspects_, .enveloped)); + try std.testing.expect(!hasAspect(&aspects_, .dual_audited)); + try std.testing.expectEqual(@as(u32, 30), cacheTtlOf(&aspects_)); + + const none = [_]Aspect{.enveloped}; + try std.testing.expectEqual(@as(u32, 0), cacheTtlOf(&none)); +} + +test "aspects: describe renders for meta" { + var buf: [32]u8 = undefined; + try std.testing.expectEqualStrings("cache:30s", describe(.{ .cache = .{ .ttl_s = 30 } }, &buf)); + try std.testing.expectEqualStrings("enveloped", describe(.enveloped, &buf)); +} + +fn testProducer(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + _ = c; + _ = arg; + return std.fmt.bufPrint(payload_buf, "{{\"ok\":true,\"from\":\"producer\"}}", .{}) catch "{}"; +} + +test "aspects: pipeline envelopes and copies to response scratch" { + var arena_inst = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_inst.deinit(); + var redis = rc.RedisClient.init(std.testing.allocator); + defer redis.deinit(); + + var out: [1024]u8 = undefined; + var c = ctx.Ctx{ + .arena = arena_inst.allocator(), + .method = "GET", + .path = "/api/v1/telemetry", + .query = "", + .body = "", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = &redis, + .verisim = undefined, + .out_buf = &out, + }; + + const aspects_ = [_]Aspect{ .enveloped }; + run(&c, &aspects_, "", testProducer); + + try std.testing.expectEqual(@as(u16, 200), c.status); + // enveloped: proof envelope present around the payload + try std.testing.expect(std.mem.indexOf(u8, c.resp_body, "\"proof\":") != null); + try std.testing.expect(std.mem.indexOf(u8, c.resp_body, "\"from\":\"producer\"") != null); +} diff --git a/src/api/zig/config.zig b/src/api/zig/config.zig index fcb9ba7..e411ee8 100644 --- a/src/api/zig/config.zig +++ b/src/api/zig/config.zig @@ -29,10 +29,19 @@ pub const Config = struct { smokeping_url: []const u8 = "http://smokeping:80", verisim_url: []const u8 = "http://verisim:8084", - /// Phase 2 flips the default to .deny when the keystore lands; - /// until then .open preserves the Phase-1 permissive behaviour — - /// honestly, not silently. - auth_mode: AuthMode = .open, + /// Deny-by-default (Phase 2): without a valid, entitled API key the + /// gateway refuses everything except public routes (health, meta). + /// Local development: AERIE_AUTH_MODE=open. This is the secure + /// default; the permissive phase is over. + auth_mode: AuthMode = .deny, + + /// Raw AERIE_API_KEYS env value (semicolon-separated key specs) — + /// consumed by the keystore; config.zig stays the only getenv + /// reader in the gateway. + api_keys_env: []const u8 = "", + /// The KYAML file path (AERIE_CONFIG), for the keystore's + /// api_keys: list. Null when configuration is env-only. + config_path: ?[]const u8 = null, /// Environment accessor, injectable for tests. pub const Env = *const fn (name: []const u8) ?[]const u8; @@ -123,6 +132,8 @@ pub const Config = struct { if (std.mem.eql(u8, v, "deny")) cfg.auth_mode = .deny; if (std.mem.eql(u8, v, "open")) cfg.auth_mode = .open; } + if (env("AERIE_API_KEYS")) |v| cfg.api_keys_env = arena.dupe(u8, v) catch cfg.api_keys_env; + if (env("AERIE_CONFIG")) |v| cfg.config_path = arena.dupe(u8, v) catch cfg.config_path; return cfg; } @@ -170,7 +181,8 @@ test "config: defaults match the compose topology" { try std.testing.expectEqual(@as(u16, 4000), cfg.port); try std.testing.expect(cfg.rest_enabled and cfg.graphql_enabled and cfg.grpc_enabled); try std.testing.expectEqualStrings("http://librespeed:80", cfg.librespeed_url); - try std.testing.expectEqual(AuthMode.open, cfg.auth_mode); + // deny-by-default: the permissive phase is over + try std.testing.expectEqual(AuthMode.deny, cfg.auth_mode); } test "config: env overrides defaults" { @@ -180,10 +192,17 @@ test "config: env overrides defaults" { .{ "PORT", "4321" }, .{ "ENABLE_GRPC", "false" }, .{ "LIBRESPEED_URL", "http://probe:9999" }, + .{ "AERIE_AUTH_MODE", "open" }, + .{ "AERIE_API_KEYS", "aaaaaaaaaaaaaaaaaaaa-one;bbbbbbbbbbbbbbbbbbbb-two:telemetry" }, })); try std.testing.expectEqual(@as(u16, 4321), cfg.port); try std.testing.expect(!cfg.grpc_enabled); try std.testing.expectEqualStrings("http://probe:9999", cfg.librespeed_url); + try std.testing.expectEqual(AuthMode.open, cfg.auth_mode); + try std.testing.expectEqualStrings( + "aaaaaaaaaaaaaaaaaaaa-one;bbbbbbbbbbbbbbbbbbbb-two:telemetry", + cfg.api_keys_env, + ); } test "config: kyaml overlay and typo rejection" { diff --git a/src/api/zig/ctx.zig b/src/api/zig/ctx.zig index 3d4a9b1..8d1127a 100644 --- a/src/api/zig/ctx.zig +++ b/src/api/zig/ctx.zig @@ -17,6 +17,7 @@ const config = @import("config.zig"); const router = @import("router.zig"); const rc = @import("redis_client.zig"); const vc = @import("verisim_client.zig"); +const ks = @import("keystore.zig"); pub const Ctx = struct { // --- request (slices alias gnosis-owned storage; valid for the call) @@ -39,6 +40,11 @@ pub const Ctx = struct { // --- aspects (filled during dispatch) policy: t.PolicyDecision = std.mem.zeroes(t.PolicyDecision), route: ?*const router.Route = null, + keystore: ?*const ks.KeyStore = null, + /// Temporal-audit parameters extracted by the protocol adapter + /// (REST query / gRPC body / GraphQL args) and consumed by the + /// temporal producer. + temporal: ?t.TemporalParams = null, // --- response slot (the ONLY place a response is assembled). // out_buf is the gnosis-owned per-connection scratch: it outlives the @@ -97,6 +103,34 @@ pub const Ctx = struct { return dst; } + /// Protocol-neutral parameter: REST query string, then JSON body + /// field, then camelCase JSON field (event_id -> eventId). Returns + /// "" when absent. This is what lets REST, gRPC-JSON and GraphQL + /// adapters share one resolver signature. + pub fn param(self: *const Ctx, name: []const u8) []const u8 { + const q = self.queryParam(name); + if (q.len > 0) return q; + const j = self.jsonStrField(name); + if (j.len > 0) return j; + if (std.mem.indexOfScalar(u8, name, '_')) |_| { + var cb: [32]u8 = undefined; + var n: usize = 0; + var upper = false; + for (name) |ch| { + if (ch == '_') { + upper = true; + continue; + } + if (n >= cb.len) break; + cb[n] = if (upper) std.ascii.toUpper(ch) else ch; + upper = false; + n += 1; + } + return self.jsonStrField(cb[0..n]); + } + return ""; + } + /// Extract a JSON string field from the request body without /// allocating (slice into the body). "" when absent. pub fn jsonStrField(self: *const Ctx, key: []const u8) []const u8 { @@ -176,6 +210,40 @@ test "ctx: query params parse from the raw query" { try std.testing.expectEqualStrings("", c.queryParam("mode")); } +test "ctx: param() falls back across protocols" { + const c = Ctx{ + .arena = undefined, + .method = "POST", + .path = "/grpc/GetTemporalAuditSnapshot", + .query = "", + .body = "{\"mode\": \"as_of\", \"eventId\": \"abc-123\"}", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + try std.testing.expectEqualStrings("as_of", c.param("mode")); + try std.testing.expectEqualStrings("abc-123", c.param("event_id")); // camelCase fallback + try std.testing.expectEqualStrings("", c.param("start")); + + const r = Ctx{ + .arena = undefined, + .method = "GET", + .path = "/api/v1/routes", + .query = "target=198.51.100.9", + .body = "", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = undefined, + .redis = undefined, + .verisim = undefined, + }; + try std.testing.expectEqualStrings("198.51.100.9", r.param("target")); +} + test "ctx: json field extraction from body" { const c = Ctx{ .arena = undefined, diff --git a/src/api/zig/keystore.zig b/src/api/zig/keystore.zig new file mode 100644 index 0000000..5500c41 --- /dev/null +++ b/src/api/zig/keystore.zig @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// keystore.zig — API keys, entitlements and the deny-by-default gate +// (Phase 2 of the aspect weave). +// +// Keys are DATA, not code: loaded from AERIE_API_KEYS (semicolon- +// separated specs) and/or the KYAML `api_keys:` flow list. A spec is +// key — name defaults to the key, all modules +// key:name — named principal, all modules +// key:name:mod1,mod2 — named principal, module entitlements +// ("*" in the module list means every module.) +// +// Authorisation compares against every entry (no early exit) with a +// constant-time equality — key presence is not a timing side channel. +// Keys never appear in logs, decisions or meta output except redacted. + +const std = @import("std"); +const kyaml = @import("kyaml.zig"); + +pub const MAX_KEYS: usize = 32; +pub const MAX_MODULES_PER_KEY: usize = 8; + +pub const Denial = enum { + missing_key, + malformed_key, + unknown_key, + no_entitlement, +}; + +pub const EntryView = struct { + name: []const u8, + entitled_to_all: bool, + modules: []const []const u8, +}; + +pub const Authz = union(enum) { + granted: EntryView, + denied: Denial, + + pub fn reason(self: Authz) []const u8 { + return switch (self) { + .granted => "authorized", + .denied => |d| switch (d) { + .missing_key => "API key required (X-Api-Key header)", + .malformed_key => "malformed API key", + .unknown_key => "unknown API key", + .no_entitlement => "no entitlement for this module", + }, + }; + } +}; + +pub const KeyEntry = struct { + key: [64]u8 = undefined, + key_len: usize = 0, + name: [32]u8 = undefined, + name_len: usize = 0, + modules: [MAX_MODULES_PER_KEY][32]u8 = undefined, + module_lens: [MAX_MODULES_PER_KEY]usize = .{0} ** MAX_MODULES_PER_KEY, + module_count: usize = 0, + all_modules: bool = false, +}; + +/// Validate API key format: minimum 16 characters, alphanumeric + hyphen. +pub fn isValidKeyFormat(key: []const u8) bool { + if (key.len < 16 or key.len > 63) return false; + for (key) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != '-') return false; + } + return true; +} + +/// Constant-time equality (length mismatch is public: format is fixed). +fn eqlConstTime(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + var diff: u8 = 0; + for (a, b) |x, y| diff |= x ^ y; + return diff == 0; +} + +pub const KeyStore = struct { + entries: [MAX_KEYS]KeyEntry = [_]KeyEntry{.{}} ** MAX_KEYS, + count: usize = 0, + + pub fn init() KeyStore { + return .{}; + } + + /// Parse one key spec and add it. Errors on malformed specs — a bad + /// key file must fail loudly, not silently reduce the store. + pub fn add(self: *KeyStore, spec: []const u8) !void { + if (self.count >= MAX_KEYS) return error.KeystoreFull; + var it = std.mem.splitScalar(u8, spec, ':'); + const key = std.mem.trim(u8, it.next() orelse return error.BadKeySpec, " "); + if (!isValidKeyFormat(key)) return error.BadKeyFormat; + const name = std.mem.trim(u8, it.next() orelse key, " "); + const mods = it.next(); // null => all modules + + var e = KeyEntry{}; + @memcpy(e.key[0..key.len], key); + e.key_len = key.len; + const n = @min(name.len, 31); + @memcpy(e.name[0..n], name[0..n]); + e.name_len = n; + + if (mods == null) { + e.all_modules = true; + } else { + var mit = std.mem.splitScalar(u8, mods.?, ','); + while (mit.next()) |m| { + const mm = std.mem.trim(u8, m, " "); + if (mm.len == 0) continue; + if (e.module_count >= MAX_MODULES_PER_KEY) return error.TooManyModules; + if (std.mem.eql(u8, mm, "*")) { + e.all_modules = true; + continue; + } + const mn = @min(mm.len, 31); + @memcpy(e.modules[e.module_count][0..mn], mm[0..mn]); + e.module_lens[e.module_count] = mn; + e.module_count += 1; + } + } + self.entries[self.count] = e; + self.count += 1; + } + + /// Load from an environment-style value: "spec;spec;...". + /// Returns the number of keys added; malformed specs are skipped + /// (the env var is operator input, load what is well-formed). + pub fn loadFromEnvValue(self: *KeyStore, value: []const u8) usize { + var added: usize = 0; + var it = std.mem.splitScalar(u8, value, ';'); + while (it.next()) |spec| { + const s = std.mem.trim(u8, spec, " "); + if (s.len == 0) continue; + self.add(s) catch continue; + added += 1; + } + return added; + } + + /// Load from a KYAML document: api_keys: ["spec", ...]. + pub fn loadFromKyamlSrc(self: *KeyStore, arena: std.mem.Allocator, src: []const u8) !usize { + const doc = try kyaml.parse(arena, src); + const list = doc.get("api_keys") orelse return 0; + const items = list.asList() orelse return error.BadKeySpec; + var added: usize = 0; + for (items) |item| { + const spec = item.asString() orelse continue; + try self.add(spec); + added += 1; + } + return added; + } + + fn entryKey(e: *const KeyEntry) []const u8 { + return e.key[0..e.key_len]; + } + + /// Authorize `api_key` for `module`. Iterates every entry with a + /// constant-time compare — no early exit on match. + pub fn authorize(self: *const KeyStore, api_key: []const u8, module: []const u8) Authz { + if (api_key.len == 0) return .{ .denied = .missing_key }; + if (!isValidKeyFormat(api_key)) return .{ .denied = .malformed_key }; + + var matched: ?*const KeyEntry = null; + for (self.entries[0..self.count]) |*e| { + if (eqlConstTime(api_key, entryKey(e))) matched = e; + } + const e = matched orelse return .{ .denied = .unknown_key }; + + if (e.all_modules) { + return .{ .granted = .{ + .name = e.name[0..e.name_len], + .entitled_to_all = true, + .modules = &.{}, + } }; + } + for (0..e.module_count) |i| { + if (std.mem.eql(u8, e.modules[i][0..e.module_lens[i]], module)) { + var mods: [MAX_MODULES_PER_KEY][]const u8 = undefined; + for (0..e.module_count) |j| mods[j] = e.modules[j][0..e.module_lens[j]]; + return .{ .granted = .{ + .name = e.name[0..e.name_len], + .entitled_to_all = false, + .modules = mods[0..e.module_count], + } }; + } + } + return .{ .denied = .no_entitlement }; + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "keystore: spec parsing and entitlement" { + var ks = KeyStore.init(); + try ks.add("aaaaaaaaaaaaaaaaaaaa-soc"); + try ks.add("bbbbbbbbbbbbbbbbbbbb-readonly:readonly:telemetry,routes"); + try std.testing.expectEqual(@as(usize, 2), ks.count); + + // bare key: all modules + const g1 = ks.authorize("aaaaaaaaaaaaaaaaaaaa-soc", "audit"); + try std.testing.expect(g1 == .granted); + try std.testing.expect(g1.granted.entitled_to_all); + + // entitled module + const g2 = ks.authorize("bbbbbbbbbbbbbbbbbbbb-readonly", "telemetry"); + try std.testing.expect(g2 == .granted); + try std.testing.expect(!g2.granted.entitled_to_all); + try std.testing.expectEqualStrings("readonly", g2.granted.name); + + // wrong module + const d1 = ks.authorize("bbbbbbbbbbbbbbbbbbbb-readonly", "audit"); + try std.testing.expectEqual(Denial.no_entitlement, d1.denied); + + // unknown key + const d2 = ks.authorize("cccccccccccccccccccc-unknown", "telemetry"); + try std.testing.expectEqual(Denial.unknown_key, d2.denied); + + // missing / malformed + try std.testing.expectEqual(Denial.missing_key, ks.authorize("", "telemetry").denied); + try std.testing.expectEqual(Denial.malformed_key, ks.authorize("short", "telemetry").denied); +} + +test "keystore: env and kyaml loading" { + var ks = KeyStore.init(); + try std.testing.expectEqual(@as(usize, 2), ks.loadFromEnvValue( + "aaaaaaaaaaaaaaaaaaaa-one; bbbbbbbbbbbbbbbbbbbb-two:two:telemetry; bad", + )); + try std.testing.expect(ks.authorize("aaaaaaaaaaaaaaaaaaaa-one", "any") == .granted); + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + var ks2 = KeyStore.init(); + const n = try ks2.loadFromKyamlSrc(arena.allocator(), + "---\napi_keys: [\"cccccccccccccccccccc-three:three:routes,audit\",]\n"); + try std.testing.expectEqual(@as(usize, 1), n); + try std.testing.expectEqual(Denial.no_entitlement, ks2.authorize("cccccccccccccccccccc-three", "telemetry").denied); + try std.testing.expect(ks2.authorize("cccccccccccccccccccc-three", "routes") == .granted); +} + +test "keystore: malformed specs rejected loudly" { + var ks = KeyStore.init(); + try std.testing.expectError(error.BadKeyFormat, ks.add("short")); + try std.testing.expectError(error.BadKeyFormat, ks.add("has spaces in it!")); + try std.testing.expectEqual(@as(usize, 0), ks.count); +} diff --git a/src/api/zig/main.zig b/src/api/zig/main.zig index 76f7d1e..f9ae444 100644 --- a/src/api/zig/main.zig +++ b/src/api/zig/main.zig @@ -29,6 +29,7 @@ const config = @import("config.zig"); const ctx = @import("ctx.zig"); const router = @import("router.zig"); const respond = @import("respond.zig"); +const ks = @import("keystore.zig"); /// C ABI from the in-repo FFI (declared in src/abi/Gnosis.idr). const c = @cImport({ @@ -42,6 +43,7 @@ const c = @cImport({ var g_cfg: config.Config = undefined; var g_redis: ?*rc.RedisClient = null; var g_verisim: vc.VerisimDBClient = undefined; +var g_keystore: ks.KeyStore = undefined; var g_alloc: std.mem.Allocator = undefined; var g_ready: bool = false; @@ -107,6 +109,7 @@ export fn aerieHandlerV2( .cfg = &g_cfg, .redis = redis, .verisim = &g_verisim, + .keystore = &g_keystore, .pool_state = if (gnosis_http_handle != 0) c.uapi_gnosis_state(gnosis_http_handle) else @@ -170,6 +173,24 @@ pub fn main() !void { g_verisim = vc.VerisimDBClient.init(); g_alloc = gpa; g_redis = redis_ptr; + + // Keystore: env specs first, then the KYAML api_keys list. + g_keystore = ks.KeyStore.init(); + const env_keys = g_keystore.loadFromEnvValue(g_cfg.api_keys_env); + var yaml_keys: usize = 0; + if (g_cfg.config_path) |path| { + if (std.fs.cwd().readFileAlloc(cfg_arena.allocator(), path, 1 << 20)) |src| { + yaml_keys = g_keystore.loadFromKyamlSrc(cfg_arena.allocator(), src) catch |e| blk: { + std.debug.print("[aerie] keystore: KYAML parse error in {s} ({}) — env keys only\n", .{ path, e }); + break :blk 0; + }; + } else |e| { + std.debug.print("[aerie] keystore: cannot read {s} ({}) — env keys only\n", .{ path, e }); + } + } + if (g_cfg.auth_mode == .deny) { + std.debug.print("[aerie] keystore: {d} key(s) loaded (env: {d}, kyaml: {d}) — deny-by-default\n", .{ g_keystore.count, env_keys, yaml_keys }); + } g_ready = true; // Single-port setup: create → register V2 handler → start. @@ -210,12 +231,12 @@ fn printBanner(cfg: *const config.Config) void { \\| REST : /api/v1/* {s} | \\| GraphQL : /graphql {s} | \\| gRPC-JSON : /grpc/* {s} | - \\| Auth mode : {s} | + \\| Auth mode : {s} ({d} keys loaded) | \\+----------------------------------------------------------+ \\| Server pool : uapi_gnosis_* (in-repo zig_api) | \\| Connector pool : uapi_connector_* (in-repo zig_api) | \\| Proof mode : light (SHA-256) | - \\| Policy gate : Phase 1 (permissive; deny lands P2) | + \\| Policy gate : Phase 2 (keystore, deny-by-default) | \\+----------------------------------------------------------+ \\ , .{ @@ -224,5 +245,6 @@ fn printBanner(cfg: *const config.Config) void { if (cfg.graphql_enabled) "ENABLED " else "disabled", if (cfg.grpc_enabled) "ENABLED " else "disabled", @tagName(cfg.auth_mode), + g_keystore.count, }); } diff --git a/src/api/zig/policy.zig b/src/api/zig/policy.zig index 25f500a..233c375 100644 --- a/src/api/zig/policy.zig +++ b/src/api/zig/policy.zig @@ -1,28 +1,25 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// policy.zig — Policy Gate Middleware +// policy.zig — the policy gate (Phase 2: keystore + deny-by-default). // -// Phase 1: Permissive gate — all requests are allowed. If the X-Api-Key -// header is present it is validated for format and logged. If absent the -// request proceeds but is marked "anonymous" in the audit log. -// -// Phase 2+ will add per-module entitlements. +// Two modes: +// * open — Phase-1 semantics (permissive; format-check keys; for +// local development). Set AERIE_AUTH_MODE=open. +// * deny — DEFAULT. The keystore decides: missing/unknown/malformed +// key or missing module entitlement => denied. Public +// routes (health, meta) are exempted by the router, not +// here — every decision is still recorded. // // Replaces: policy.v -const std = @import("std"); -const t = @import("types.zig"); -const prf = @import("proof.zig"); +const std = @import("std"); +const t = @import("types.zig"); +const prf = @import("proof.zig"); +const ks = @import("keystore.zig"); +const config = @import("config.zig"); -/// Validate API key format: minimum 16 characters, alphanumeric + hyphen. -fn isValidKeyFormat(key: []const u8) bool { - if (key.len < 16) return false; - for (key) |ch| { - if (!std.ascii.isAlphanumeric(ch) and ch != '-') return false; - } - return true; -} +const empty_store = ks.KeyStore.init(); /// Redact an API key to "first8chars...". Result placed in `out`. fn redactKey(key: []const u8, out: *[64]u8) usize { @@ -34,7 +31,6 @@ fn redactKey(key: []const u8, out: *[64]u8) usize { } /// Copy `src` into the fixed-length field `dst`, null-terminating. -/// Returns the number of bytes written (excluding the null). fn copyField(comptime N: usize, dst: *[N]u8, src: []const u8) usize { const n = @min(src.len, N - 1); @memcpy(dst[0..n], src[0..n]); @@ -44,77 +40,148 @@ fn copyField(comptime N: usize, dst: *[N]u8, src: []const u8) usize { /// Evaluate the policy gate for an incoming request. /// -/// Phase 1 behaviour: all requests allowed regardless of API key. -/// API keys are validated for format; missing keys are "anonymous". -/// Invalid-format keys are still allowed (permissive phase). -pub fn evaluatePolicy(api_key: []const u8, module_name: []const u8) t.PolicyDecision { +/// Deny mode (default): the keystore authorizes key x module; denials +/// carry the reason. Open mode: permissive Phase-1 semantics for local +/// development. Every decision (allowed or not) is audit-logged by the +/// dispatch pipeline. +pub fn evaluatePolicy( + store: ?*const ks.KeyStore, + mode: config.AuthMode, + api_key: []const u8, + module_name: []const u8, +) t.PolicyDecision { var dec: t.PolicyDecision = std.mem.zeroes(t.PolicyDecision); - dec.module_len = copyField(64, &dec.module_name, module_name); + dec.module_len = copyField(64, &dec.module_name, module_name); var ts_buf: [32]u8 = undefined; prf.formatRfc3339(&ts_buf); dec.timestamp_len = copyField(32, &dec.timestamp, std.mem.sliceTo(&ts_buf, 0)); - if (api_key.len == 0) { - dec.allowed = true; - dec.access_level = .anonymous; - dec.api_key_len = 0; - dec.api_key[0] = 0; - dec.reason_len = copyField(128, &dec.reason, - "Phase 1 permissive: anonymous access allowed"); - return dec; + if (api_key.len > 0) { + dec.api_key_len = redactKey(api_key, &dec.api_key); } - dec.api_key_len = redactKey(api_key, &dec.api_key); - dec.allowed = true; - - if (isValidKeyFormat(api_key)) { - dec.access_level = .authenticated; - dec.reason_len = copyField(128, &dec.reason, "Valid API key authenticated"); - } else { - dec.access_level = .invalid; - dec.reason_len = copyField(128, &dec.reason, - "Phase 1 permissive: invalid key format but access allowed"); + if (mode == .open) { + dec.allowed = true; + if (api_key.len == 0) { + dec.access_level = .anonymous; + dec.reason_len = copyField(128, &dec.reason, "open mode: anonymous access allowed"); + } else if (ks.isValidKeyFormat(api_key)) { + dec.access_level = .authenticated; + dec.reason_len = copyField(128, &dec.reason, "open mode: key format valid (not verified)"); + } else { + dec.access_level = .invalid; + dec.reason_len = copyField(128, &dec.reason, "open mode: malformed key (allowed)"); + } + return dec; } + // Deny mode: the keystore decides. + const store_ptr = store orelse &empty_store; + const authz = store_ptr.authorize(api_key, module_name); + switch (authz) { + .granted => |g| { + dec.allowed = true; + dec.access_level = .authenticated; + var rb: [128]u8 = undefined; + const r = std.fmt.bufPrint(&rb, "key '{s}' authorized for module '{s}'", .{ g.name, module_name }) catch "key authorized"; + dec.reason_len = copyField(128, &dec.reason, r); + }, + .denied => |d| { + dec.allowed = false; + dec.access_level = switch (d) { + .missing_key => .anonymous, + .malformed_key, .unknown_key => .invalid, + .no_entitlement => .authenticated, + }; + dec.reason_len = copyField(128, &dec.reason, authz.reason()); + }, + } return dec; } /// Convert a PolicyDecision into an AuditEvent for the Redis audit log. -/// `query_id` is used as the event_id (mirrors decision_to_audit_event in policy.v). +/// `query_id` is used as the event_id (mirrors decision_to_audit_event +/// in policy.v). pub fn decisionToAuditEvent(decision: t.PolicyDecision, query_id: []const u8) t.AuditEvent { var ev: t.AuditEvent = std.mem.zeroes(t.AuditEvent); - - // event_id — UUID supplied by caller (from proof envelope query_id) _ = copyField(37, &ev.event_id, query_id); const ts = std.mem.sliceTo(&decision.timestamp, 0); _ = copyField(32, &ev.valid_time, ts); - _ = copyField(32, &ev.tx_time, ts); + _ = copyField(32, &ev.tx_time, ts); - const severity: []const u8 = switch (decision.access_level) { - .anonymous, .authenticated => "info", - .invalid => "warning", - }; + const severity: []const u8 = if (decision.allowed) + (if (decision.access_level == .authenticated) "info" else "info") + else + "warning"; _ = copyField(16, &ev.severity, severity); const reason = std.mem.sliceTo(&decision.reason, 0); - const mod = std.mem.sliceTo(&decision.module_name, 0); + const mod = std.mem.sliceTo(&decision.module_name, 0); var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "{s} [module={s}]", .{ reason, mod }) catch reason; ev.message_len = copyField(256, &ev.message, msg); - // Tags: policy-gate, phase-1, , _ = copyField(32, &ev.tags[0], "policy-gate"); - _ = copyField(32, &ev.tags[1], "phase-1"); + _ = copyField(32, &ev.tags[1], "phase-2"); _ = copyField(32, &ev.tags[2], mod); - const level_tag: []const u8 = switch (decision.access_level) { - .anonymous => "anonymous", + const level_tag: []const u8 = if (!decision.allowed) + "denied" + else switch (decision.access_level) { + .anonymous => "anonymous", .authenticated => "authenticated", - .invalid => "invalid-key", + .invalid => "invalid-key", }; _ = copyField(32, &ev.tags[3], level_tag); ev.tag_count = 4; return ev; } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "policy: deny mode is the default posture and keystore decides" { + var store = ks.KeyStore.init(); + try store.add("test-key-aaaaaaaaaaaaaaaa:ops:telemetry,routes"); + + // entitled + const g = evaluatePolicy(&store, .deny, "test-key-aaaaaaaaaaaaaaaa", "telemetry"); + try std.testing.expect(g.allowed); + try std.testing.expect(g.access_level == .authenticated); + + // no entitlement -> denied, authenticated (maps to 403) + const d1 = evaluatePolicy(&store, .deny, "test-key-aaaaaaaaaaaaaaaa", "audit"); + try std.testing.expect(!d1.allowed); + try std.testing.expect(d1.access_level == .authenticated); + + // missing key -> denied, anonymous (401) + const d2 = evaluatePolicy(&store, .deny, "", "telemetry"); + try std.testing.expect(!d2.allowed and d2.access_level == .anonymous); + + // unknown key -> denied, invalid (401) + const d3 = evaluatePolicy(&store, .deny, "unknown-key-bbbbbbbbbbbbbb", "telemetry"); + try std.testing.expect(!d3.allowed and d3.access_level == .invalid); + + // null keystore in deny mode denies everything + const d4 = evaluatePolicy(null, .deny, "test-key-aaaaaaaaaaaaaaaa", "telemetry"); + try std.testing.expect(!d4.allowed); +} + +test "policy: open mode stays permissive for local development" { + const a = evaluatePolicy(null, .open, "", "telemetry"); + try std.testing.expect(a.allowed and a.access_level == .anonymous); + const b = evaluatePolicy(null, .open, "whatever", "telemetry"); + try std.testing.expect(b.allowed and b.access_level == .invalid); +} + +test "policy: denial decisions audit as warnings with a denied tag" { + const d = evaluatePolicy(null, .deny, "", "telemetry"); + const ev = decisionToAuditEvent(d, "00000000-0000-4000-8000-000000000000"); + const sev = std.mem.sliceTo(&ev.severity, 0); + try std.testing.expectEqualStrings("warning", sev); + const tag4 = std.mem.sliceTo(&ev.tags[3], 0); + try std.testing.expectEqualStrings("denied", tag4); +} diff --git a/src/api/zig/proof.zig b/src/api/zig/proof.zig index fe5cf4c..f7a93b6 100644 --- a/src/api/zig/proof.zig +++ b/src/api/zig/proof.zig @@ -129,7 +129,7 @@ pub fn wrapBodyWithProof( pub fn policyContextString(module_name: []const u8, out: []u8) ![]const u8 { return try std.fmt.bufPrint( out, - "aerie-policy-v1:phase1-permissive:module={s}:entitlements=all", + "aerie-policy-v1:phase2-weave:module={s}", .{module_name}, ); } diff --git a/src/api/zig/resolvers.zig b/src/api/zig/resolvers.zig index f9346a7..660a815 100644 --- a/src/api/zig/resolvers.zig +++ b/src/api/zig/resolvers.zig @@ -1,323 +1,216 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// resolvers.zig — GraphQL and REST Resolver Implementations +// resolvers.zig — protocol-neutral resolution via the aspect pipeline. // -// Shared resolution logic used by all three protocols (REST, GraphQL, gRPC). -// Each resolver: -// 1. Checks the Redis cache (TTL varies by data freshness) -// 2. Queries the backend probe -// 3. Wraps the result in a ProofEnvelope -// 4. Caches the result -// 5. Logs the audit event +// Phase 2 of the weave: each resolution is a PRODUCER (probe/query -> +// payload JSON) plus an ASPECT LIST (data). The five-step pipeline +// (cache read -> produce -> envelope -> cache write -> audit) lives +// once in aspects.zig; these lists are the single source the route +// table references and /api/v1/meta renders. // // Replaces: resolvers.v -const std = @import("std"); -const t = @import("types.zig"); -const prf = @import("proof.zig"); -const pol = @import("policy.zig"); -const rc = @import("redis_client.zig"); -const vc = @import("verisim_client.zig"); -const ls = @import("librespeed_client.zig"); -const hg = @import("hyperglass_client.zig"); -const sp = @import("smokeping_client.zig"); - -/// Resolve telemetry from LibreSpeed. -/// Checks the cache (30s TTL) before querying the probe. -/// Writes the final JSON into `out_buf`; returns a slice of it. -pub fn resolveTelemetry( - redis: *rc.RedisClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { - _ = arena; - - // Cache check - var cache_buf: [8192]u8 = undefined; - const cached = redis.getCached("telemetry", &cache_buf); - if (cached.len > 0) { - const n = @min(cached.len, out_buf.len); - @memcpy(out_buf[0..n], cached[0..n]); - return out_buf[0..n]; - } - - // Probe LibreSpeed - var sample: t.TelemetrySample = undefined; - ls.getTelemetry(&sample); +const std = @import("std"); +const t = @import("types.zig"); +const prf = @import("proof.zig"); +const rc = @import("redis_client.zig"); +const vc = @import("verisim_client.zig"); +const ls = @import("librespeed_client.zig"); +const hg = @import("hyperglass_client.zig"); +const sp = @import("smokeping_client.zig"); +const ctx = @import("ctx.zig"); +const a = @import("aspects.zig"); +const respond = @import("respond.zig"); - var payload_buf: [512]u8 = undefined; - const payload_json = ls.telemetryPayloadToJson(sample, &payload_buf) catch - return errorJson("telemetry probe failed", out_buf); +pub const TemporalParams = t.TemporalParams; - // Wrap in proof - var ctx_buf: [128]u8 = undefined; - const ctx = prf.policyContextString("telemetry", &ctx_buf) catch "aerie-policy-v1"; - - const result = prf.wrapBodyWithProof(payload_json, ctx, out_buf) catch - return errorJson("proof wrap failed", out_buf); +// --------------------------------------------------------------------------- +// Aspect lists — the weave's data (referenced by router.zig; rendered +// by /api/v1/meta). One source of truth for behaviour and description. +// --------------------------------------------------------------------------- - // Cache for 30 seconds - redis.cacheResult("telemetry", result, 30); +pub const telemetry_aspects = [_]a.Aspect{ .{ .cache = .{ .ttl_s = 30 } }, .enveloped, .audited }; +pub const routes_aspects = [_]a.Aspect{ .{ .cache = .{ .ttl_s = 60 } }, .enveloped, .audited }; +pub const audit_aspects = [_]a.Aspect{ .enveloped, .audited }; // never cached — always fresh +pub const smokeping_aspects = [_]a.Aspect{ .{ .cache = .{ .ttl_s = 120 } }, .enveloped, .audited }; +pub const temporal_aspects = [_]a.Aspect{ .enveloped, .dual_audited }; - // Audit - logAudit(redis, policy); +// --------------------------------------------------------------------------- +// Producers (probe/query -> payload JSON into payload_buf) +// --------------------------------------------------------------------------- - return result; +fn fetchTelemetry(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + _ = c; + _ = arg; + var sample: t.TelemetrySample = undefined; + ls.getTelemetry(&sample); + return ls.telemetryPayloadToJson(sample, payload_buf) catch + errorJson("telemetry probe failed", payload_buf); } -/// Resolve BGP route forensics from Hyperglass for `target`. -/// Checks the cache (60s TTL). -pub fn resolveRouteForensics( - target: []const u8, - redis: *rc.RedisClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { - _ = arena; - - var key_buf: [128]u8 = undefined; - const cache_key = std.fmt.bufPrint(&key_buf, "routes:{s}", .{target}) catch "routes"; - - var cache_buf: [16384]u8 = undefined; - const cached = redis.getCached(cache_key, &cache_buf); - if (cached.len > 0) { - const n = @min(cached.len, out_buf.len); - @memcpy(out_buf[0..n], cached[0..n]); - return out_buf[0..n]; - } - +fn fetchRoutes(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + _ = c; var hops: [hg.MAX_HOPS]t.RouteHop = undefined; - const hop_count = hg.getRouteForensics(target, &hops); - - var payload_buf: [8192]u8 = undefined; - const payload_json = hg.routeForensicsToJson(target, hops[0..hop_count], &payload_buf) - catch return errorJson("route forensics serialise failed", out_buf); - - var ctx_buf: [128]u8 = undefined; - const ctx = prf.policyContextString("routes", &ctx_buf) catch "aerie-policy-v1"; - - const result = prf.wrapBodyWithProof(payload_json, ctx, out_buf) catch - return errorJson("proof wrap failed", out_buf); - - redis.cacheResult(cache_key, result, 60); - logAudit(redis, policy); - return result; + const hop_count = hg.getRouteForensics(arg, &hops); + return hg.routeForensicsToJson(arg, hops[0..hop_count], payload_buf) catch + errorJson("route forensics serialise failed", payload_buf); } -/// Resolve audit events from the Redis log. -/// Never cached — always fresh. -pub fn resolveAudit( - limit: u32, - redis: *rc.RedisClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { +fn fetchAudit(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + const limit = std.fmt.parseInt(u32, arg, 10) catch 50; var events_list: std.ArrayList([]const u8) = .{}; - redis.getAuditLog(limit, &events_list, arena); - - var events_buf: [32768]u8 = undefined; - var fbs = std.io.fixedBufferStream(&events_buf); - const w = fbs.writer(); - w.writeAll("{\"events\":[") catch return errorJson("audit serialise failed", out_buf); - for (events_list.items, 0..) |ev, i| { - if (i > 0) w.writeByte(',') catch break; - w.writeAll(ev) catch break; - } - w.writeAll("]}") catch return errorJson("audit serialise failed", out_buf); - const data_json = fbs.getWritten(); - - var ctx_buf: [128]u8 = undefined; - const ctx = prf.policyContextString("audit", &ctx_buf) catch "aerie-policy-v1"; - - const result = prf.wrapBodyWithProof(data_json, ctx, out_buf) catch - return errorJson("proof wrap failed", out_buf); - - logAudit(redis, policy); - return result; + c.redis.getAuditLog(limit, &events_list, c.arena); + return eventsJson("events", events_list.items, payload_buf) catch + errorJson("audit serialise failed", payload_buf); } -/// Resolve SmokePing latency/jitter for `target`. -/// Checks the cache (120s TTL — data changes slowly). -pub fn resolveSmokeping( - target: []const u8, - redis: *rc.RedisClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { - _ = arena; - - var key_buf: [128]u8 = undefined; - const cache_key = std.fmt.bufPrint(&key_buf, "smokeping:{s}", .{target}) catch "smokeping"; - - var cache_buf: [32768]u8 = undefined; - const cached = redis.getCached(cache_key, &cache_buf); - if (cached.len > 0) { - const n = @min(cached.len, out_buf.len); - @memcpy(out_buf[0..n], cached[0..n]); - return out_buf[0..n]; - } - +fn fetchSmokeping(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + _ = c; var current: t.SmokePingSample = undefined; var chart: [sp.MAX_CHART_POINTS]t.SmokeChartPoint = undefined; - const chart_count = sp.getSmokepingData(target, ¤t, &chart); - - var payload_buf: [65536]u8 = undefined; - const payload_json = sp.smokepingPayloadToJson(current, chart[0..chart_count], &payload_buf) - catch return errorJson("smokeping serialise failed", out_buf); - - var ctx_buf: [128]u8 = undefined; - const ctx = prf.policyContextString("smokeping", &ctx_buf) catch "aerie-policy-v1"; - - const result = prf.wrapBodyWithProof(payload_json, ctx, out_buf) catch - return errorJson("proof wrap failed", out_buf); - - redis.cacheResult(cache_key, result, 120); - logAudit(redis, policy); - return result; + const chart_count = sp.getSmokepingData(arg, ¤t, &chart); + return sp.smokepingPayloadToJson(current, chart[0..chart_count], payload_buf) catch + errorJson("smokeping serialise failed", payload_buf); } -/// TemporalParams bundles query parameters for temporal audit queries. -pub const TemporalParams = struct { - time: []const u8 = "", - start: []const u8 = "", - end: []const u8 = "", - event_id: []const u8 = "", - limit: u32 = 50, -}; - -/// Resolve temporal audit from VerisimDB. -/// mode: "as_of", "between", or "history". -pub fn resolveTemporalAudit( - mode: []const u8, - params: TemporalParams, - redis: *rc.RedisClient, - verisimdb: *const vc.VerisimDBClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { - var events_list: std.ArrayList([]const u8) = .{}; +fn fetchTemporal(c: *ctx.Ctx, arg: []const u8, payload_buf: []u8) []const u8 { + const mode = arg; + const params = c.temporal orelse t.TemporalParams{}; + var events_list: std.ArrayList([]const u8) = .{}; if (std.mem.eql(u8, mode, "as_of")) { + // Fixes the original's dangling block-local slice: the default + // time lives in this producer frame, valid for the whole call. + var time_buf: [32]u8 = undefined; const as_of = if (params.time.len > 0) params.time else blk: { - var ts_buf: [32]u8 = undefined; - prf.formatRfc3339(&ts_buf); - // Return current time — but we need a stable slice; use out_buf scratch - var scratch: [32]u8 = undefined; - prf.formatRfc3339(&scratch); - break :blk std.mem.sliceTo(&scratch, 0); + prf.formatRfc3339(&time_buf); + break :blk std.mem.sliceTo(&time_buf, 0); }; - verisimdb.queryAsOf(as_of, params.limit, &events_list, arena); + c.verisim.queryAsOf(as_of, params.limit, &events_list, c.arena); } else if (std.mem.eql(u8, mode, "between")) { if (params.start.len == 0 or params.end.len == 0) { - return errorJson("between mode requires start and end parameters", out_buf); + return errorJson("between mode requires start and end parameters", payload_buf); } - verisimdb.queryBetween(params.start, params.end, params.limit, &events_list, arena); + c.verisim.queryBetween(params.start, params.end, params.limit, &events_list, c.arena); } else if (std.mem.eql(u8, mode, "history")) { if (params.event_id.len == 0) { - return errorJson("history mode requires event_id parameter", out_buf); + return errorJson("history mode requires event_id parameter", payload_buf); } - verisimdb.queryHistory(params.event_id, &events_list, arena); + c.verisim.queryHistory(params.event_id, &events_list, c.arena); } else { - return errorJson("Unknown temporal mode (available: as_of, between, history)", out_buf); + return errorJson("Unknown temporal mode (available: as_of, between, history)", payload_buf); } - var data_buf: [32768]u8 = undefined; - var fbs = std.io.fixedBufferStream(&data_buf); + var fbs = std.io.fixedBufferStream(payload_buf); const w = fbs.writer(); w.print("{{\"mode\":\"{s}\",\"events\":[", .{mode}) catch - return errorJson("temporal serialise failed", out_buf); + return errorJson("temporal serialise failed", payload_buf); for (events_list.items, 0..) |ev, i| { if (i > 0) w.writeByte(',') catch break; w.writeAll(ev) catch break; } - w.writeAll("]}") catch return errorJson("temporal serialise failed", out_buf); - const data_json = fbs.getWritten(); + w.writeAll("]}") catch return errorJson("temporal serialise failed", payload_buf); + return fbs.getWritten(); +} + +/// Render an events list as {"":[...]} into `payload_buf`. +fn eventsJson(key: []const u8, events: []const []const u8, payload_buf: []u8) ![]const u8 { + var fbs = std.io.fixedBufferStream(payload_buf); + const w = fbs.writer(); + try w.writeAll("{\""); + try w.writeAll(key); + try w.writeAll("\":["); + for (events, 0..) |ev, i| { + if (i > 0) try w.writeByte(','); + try w.writeAll(ev); + } + try w.writeAll("]}"); + return fbs.getWritten(); +} + +// --------------------------------------------------------------------------- +// Resolutions — producer + aspect list, one line each +// --------------------------------------------------------------------------- + +pub fn resolveTelemetry(c: *ctx.Ctx) void { + a.run(c, &telemetry_aspects, "", fetchTelemetry); +} - var ctx_buf: [128]u8 = undefined; - const ctx = prf.policyContextString("temporal_audit", &ctx_buf) catch "aerie-policy-v1"; +pub fn resolveRoutes(c: *ctx.Ctx, target: []const u8) void { + a.run(c, &routes_aspects, target, fetchRoutes); +} + +pub fn resolveAudit(c: *ctx.Ctx, limit: u32) void { + var lb: [16]u8 = undefined; + const lim = std.fmt.bufPrint(&lb, "{d}", .{limit}) catch "50"; + a.run(c, &audit_aspects, lim, fetchAudit); +} - const result = prf.wrapBodyWithProof(data_json, ctx, out_buf) catch - return errorJson("proof wrap failed", out_buf); +pub fn resolveSmokeping(c: *ctx.Ctx, target: []const u8) void { + a.run(c, &smokeping_aspects, target, fetchSmokeping); +} - vc.dualLogAudit(redis, verisimdb, auditFromPolicy(policy)); - return result; +pub fn resolveTemporal(c: *ctx.Ctx, mode: []const u8, params: TemporalParams) void { + c.temporal = params; + defer c.temporal = null; + a.run(c, &temporal_aspects, mode, fetchTemporal); } // --------------------------------------------------------------------------- -// GraphQL resolver dispatcher +// GraphQL resolver dispatcher — same resolutions, GraphQL error shape // --------------------------------------------------------------------------- -/// Resolve a GraphQL query string, dispatching to the appropriate resolver. -/// Mirrors resolve_graphql_query() in resolvers.v. -pub fn resolveGraphqlQuery( - query: []const u8, - redis: *rc.RedisClient, - verisimdb: *const vc.VerisimDBClient, - policy: t.PolicyDecision, - out_buf: []u8, - arena: std.mem.Allocator, -) []const u8 { +/// Resolve a GraphQL query string. Errors are reported GraphQL-style +/// (HTTP 200 with an errors array); successful resolutions run the +/// same aspect pipelines as REST/gRPC. +pub fn resolveGraphqlQuery(c: *ctx.Ctx, query: []const u8) void { if (std.mem.indexOf(u8, query, "telemetrySnapshot") != null) { - return resolveTelemetry(redis, policy, out_buf, arena); + resolveTelemetry(c); + return; } if (std.mem.indexOf(u8, query, "routeForensicsSnapshot") != null) { - const target = gqlArgStr(query, "target", arena) orelse - return gqlError("routeForensicsSnapshot requires a target argument", out_buf); - defer arena.free(target); - return resolveRouteForensics(target, redis, policy, out_buf, arena); + const target = gqlArgStr(query, "target", c.arena) orelse + return gqlFail(c, "routeForensicsSnapshot requires a target argument"); + resolveRoutes(c, target); + return; } - // Check temporalAuditSnapshot BEFORE auditSnapshot (substring match ordering) + // Check temporalAuditSnapshot BEFORE auditSnapshot (substring ordering) if (std.mem.indexOf(u8, query, "temporalAuditSnapshot") != null) { - const mode = gqlArgStr(query, "mode", arena) orelse - return gqlError("temporalAuditSnapshot requires mode argument (as_of, between, history)", out_buf); - defer arena.free(mode); - - const time_val = gqlArgStr(query, "time", arena); - const start_val = gqlArgStr(query, "start", arena); - const end_val = gqlArgStr(query, "end", arena); - const eid_val = gqlArgStr(query, "eventId", arena); - defer { if (time_val) |v| arena.free(v); } - defer { if (start_val) |v| arena.free(v); } - defer { if (end_val) |v| arena.free(v); } - defer { if (eid_val) |v| arena.free(v); } - - const limit_val = gqlArgInt(query, "limit"); + const mode = gqlArgStr(query, "mode", c.arena) orelse + return gqlFail(c, "temporalAuditSnapshot requires mode argument (as_of, between, history)"); const params = TemporalParams{ - .time = if (time_val) |v| v else "", - .start = if (start_val) |v| v else "", - .end = if (end_val) |v| v else "", - .event_id = if (eid_val) |v| v else "", - .limit = if (limit_val > 0) @intCast(limit_val) else 50, + .time = gqlArgStr(query, "time", c.arena) orelse "", + .start = gqlArgStr(query, "start", c.arena) orelse "", + .end = gqlArgStr(query, "end", c.arena) orelse "", + .event_id = gqlArgStr(query, "eventId", c.arena) orelse "", + .limit = if (gqlArgInt(query, "limit") > 0) @intCast(gqlArgInt(query, "limit")) else 50, }; - return resolveTemporalAudit(mode, params, redis, verisimdb, policy, out_buf, arena); + resolveTemporal(c, mode, params); + return; } if (std.mem.indexOf(u8, query, "auditSnapshot") != null) { const limit_val = gqlArgInt(query, "limit"); - const limit: u32 = if (limit_val > 0) @intCast(limit_val) else 50; - return resolveAudit(limit, redis, policy, out_buf, arena); + resolveAudit(c, if (limit_val > 0) @intCast(limit_val) else 50); + return; } if (std.mem.indexOf(u8, query, "smokePingSnapshot") != null) { - const target = gqlArgStr(query, "target", arena) orelse - return gqlError("smokePingSnapshot requires a target argument", out_buf); - defer arena.free(target); - return resolveSmokeping(target, redis, policy, out_buf, arena); + const target = gqlArgStr(query, "target", c.arena) orelse + return gqlFail(c, "smokePingSnapshot requires a target argument"); + resolveSmokeping(c, target); + return; } - return gqlError( - "Unknown query. Available: telemetrySnapshot, routeForensicsSnapshot(target), " ++ - "auditSnapshot(limit), temporalAuditSnapshot(mode,...), smokePingSnapshot(target)", - out_buf, - ); + gqlFail(c, "Unknown query. Available: telemetrySnapshot, routeForensicsSnapshot(target), " ++ + "auditSnapshot(limit), temporalAuditSnapshot(mode,...), smokePingSnapshot(target)"); +} + +fn gqlFail(c: *ctx.Ctx, msg: []const u8) void { + respond.respond(c, 200, gqlError(msg, c.out_buf)); } // --------------------------------------------------------------------------- @@ -357,6 +250,7 @@ fn gqlArgInt(query: []const u8, arg: []const u8) i64 { const needle = std.fmt.bufPrint(&nb, "{s}:", .{arg}) catch return 0; const pos = std.mem.indexOf(u8, query, needle) orelse return 0; var rest = std.mem.trimLeft(u8, query[pos + needle.len ..], " \t"); + _ = &rest; var end: usize = 0; while (end < rest.len and std.ascii.isDigit(rest[end])) end += 1; if (end == 0) return 0; @@ -364,24 +258,25 @@ fn gqlArgInt(query: []const u8, arg: []const u8) i64 { } /// Build an AuditEvent from a PolicyDecision for logging. -fn auditFromPolicy(decision: t.PolicyDecision) t.AuditEvent { +pub fn auditFromPolicy(decision: t.PolicyDecision) t.AuditEvent { const mod = std.mem.sliceTo(&decision.module_name, 0); const reason = std.mem.sliceTo(&decision.reason, 0); const ts = std.mem.sliceTo(&decision.timestamp, 0); var ev: t.AuditEvent = std.mem.zeroes(t.AuditEvent); - - // Generate a fresh UUID for this event prf.generateUuidV4(&ev.event_id); - const n_vt = @min(ts.len, 31); @memcpy(ev.valid_time[0..n_vt], ts[0..n_vt]); ev.valid_time[n_vt] = 0; - const n_tx = @min(ts.len, 31); @memcpy(ev.tx_time[0..n_tx], ts[0..n_tx]); ev.tx_time[n_tx] = 0; + const n_vt = @min(ts.len, 31); + @memcpy(ev.valid_time[0..n_vt], ts[0..n_vt]); + ev.valid_time[n_vt] = 0; + const n_tx = @min(ts.len, 31); + @memcpy(ev.tx_time[0..n_tx], ts[0..n_tx]); + ev.tx_time[n_tx] = 0; - const severity: []const u8 = switch (decision.access_level) { - .anonymous, .authenticated => "info", - .invalid => "warning", - }; - const n_sv = @min(severity.len, 15); @memcpy(ev.severity[0..n_sv], severity[0..n_sv]); ev.severity[n_sv] = 0; + const severity: []const u8 = if (decision.allowed) "info" else "warning"; + const n_sv = @min(severity.len, 15); + @memcpy(ev.severity[0..n_sv], severity[0..n_sv]); + ev.severity[n_sv] = 0; var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "{s} [module={s}]", .{ reason, mod }) catch reason; @@ -391,16 +286,20 @@ fn auditFromPolicy(decision: t.PolicyDecision) t.AuditEvent { const copyTag = struct { fn f(dst: *[32]u8, src: []const u8) void { - const n = @min(src.len, 31); @memcpy(dst[0..n], src[0..n]); dst[n] = 0; + const n = @min(src.len, 31); + @memcpy(dst[0..n], src[0..n]); + dst[n] = 0; } }.f; copyTag(&ev.tags[0], "policy-gate"); - copyTag(&ev.tags[1], "phase-1"); + copyTag(&ev.tags[1], "phase-2"); copyTag(&ev.tags[2], mod); - const level_tag: []const u8 = switch (decision.access_level) { - .anonymous => "anonymous", + const level_tag: []const u8 = if (!decision.allowed) + "denied" + else switch (decision.access_level) { + .anonymous => "anonymous", .authenticated => "authenticated", - .invalid => "invalid-key", + .invalid => "invalid-key", }; copyTag(&ev.tags[3], level_tag); ev.tag_count = 4; @@ -409,6 +308,33 @@ fn auditFromPolicy(decision: t.PolicyDecision) t.AuditEvent { } /// Log a PolicyDecision audit event to Redis. -fn logAudit(redis: *rc.RedisClient, policy: t.PolicyDecision) void { +pub fn logAudit(redis: *rc.RedisClient, policy: t.PolicyDecision) void { redis.logAudit(auditFromPolicy(policy)); } + +/// Log a PolicyDecision audit event to Redis AND VerisimDB (the +/// temporal module's dual-audit aspect). +pub fn logDualAudit(c: *ctx.Ctx) void { + vc.dualLogAudit(c.redis, c.verisim, auditFromPolicy(c.policy)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "resolvers: aspect lists say what the resolutions do" { + // telemetry: cached 30s, enveloped, audited + try std.testing.expect(a.hasAspect(&telemetry_aspects, .cache)); + try std.testing.expectEqual(@as(u32, 30), a.cacheTtlOf(&telemetry_aspects)); + // audit: never cached, always fresh + try std.testing.expect(!a.hasAspect(&audit_aspects, .cache)); + try std.testing.expect(a.hasAspect(&audit_aspects, .audited)); + // temporal: dual-audited + try std.testing.expect(a.hasAspect(&temporal_aspects, .dual_audited)); +} + +test "resolvers: error payloads pass through un-enveloped" { + var out: [256]u8 = undefined; + const body = errorJson("telemetry probe failed", &out); + try std.testing.expect(std.mem.startsWith(u8, body, "{\"error\":")); +} diff --git a/src/api/zig/router.zig b/src/api/zig/router.zig index 34c61cd..98aaa4a 100644 --- a/src/api/zig/router.zig +++ b/src/api/zig/router.zig @@ -20,10 +20,12 @@ const t = @import("types.zig"); const ctx = @import("ctx.zig"); const res = @import("resolvers.zig"); const pol = @import("policy.zig"); +const config = @import("config.zig"); const prf = @import("proof.zig"); const vg = @import("verb_governance.zig"); const respond = @import("respond.zig"); const errors = @import("errors.zig"); +const aspects = @import("aspects.zig"); pub const Resolver = *const fn (*ctx.Ctx) void; @@ -32,6 +34,12 @@ pub const Route = struct { verbs: []const []const u8, module: []const u8, resolver: Resolver, + /// Public routes bypass the deny-by-default gate (health, meta). + /// They are still policy-evaluated and audited. + public: bool = false, + /// The aspects this resolution runs (single source: the consts in + /// resolvers.zig; rendered by /api/v1/meta). + aspects: []const aspects.Aspect = &.{}, }; /// THE table. Longest-prefix wins; boundary-guarded (a route matches @@ -39,12 +47,13 @@ pub const Route = struct { /// nothing. gRPC is dispatched by the /grpc/ prefix (method names are /// dynamic) and is not table-mapped. pub const routes = [_]Route{ - .{ .path = "/api/v1/health", .verbs = &.{ "GET", "OPTIONS" }, .module = "health", .resolver = healthResolver }, - .{ .path = "/api/v1/telemetry", .verbs = &.{ "GET", "OPTIONS" }, .module = "telemetry", .resolver = telemetryResolver }, - .{ .path = "/api/v1/routes", .verbs = &.{ "GET", "OPTIONS" }, .module = "routes", .resolver = routesResolver }, - .{ .path = "/api/v1/audit/temporal", .verbs = &.{ "GET", "OPTIONS" }, .module = "temporal_audit", .resolver = temporalResolver }, - .{ .path = "/api/v1/audit", .verbs = &.{ "GET", "OPTIONS" }, .module = "audit", .resolver = auditResolver }, - .{ .path = "/api/v1/smokeping", .verbs = &.{ "GET", "OPTIONS" }, .module = "smokeping", .resolver = smokepingResolver }, + .{ .path = "/api/v1/health", .verbs = &.{ "GET", "OPTIONS" }, .module = "health", .resolver = healthResolver, .public = true }, + .{ .path = "/api/v1/meta", .verbs = &.{ "GET", "OPTIONS" }, .module = "meta", .resolver = metaResolver, .public = true }, + .{ .path = "/api/v1/telemetry", .verbs = &.{ "GET", "OPTIONS" }, .module = "telemetry", .resolver = telemetryResolver, .aspects = &res.telemetry_aspects }, + .{ .path = "/api/v1/routes", .verbs = &.{ "GET", "OPTIONS" }, .module = "routes", .resolver = routesResolver, .aspects = &res.routes_aspects }, + .{ .path = "/api/v1/audit/temporal", .verbs = &.{ "GET", "OPTIONS" }, .module = "temporal_audit", .resolver = temporalResolver, .aspects = &res.temporal_aspects }, + .{ .path = "/api/v1/audit", .verbs = &.{ "GET", "OPTIONS" }, .module = "audit", .resolver = auditResolver, .aspects = &res.audit_aspects }, + .{ .path = "/api/v1/smokeping", .verbs = &.{ "GET", "OPTIONS" }, .module = "smokeping", .resolver = smokepingResolver, .aspects = &res.smokeping_aspects }, .{ .path = "/graphql", .verbs = &.{ "GET", "POST", "OPTIONS" }, .module = "graphql", .resolver = graphqlResolver }, }; @@ -105,14 +114,21 @@ pub fn dispatch(c: *ctx.Ctx) void { if (std.mem.startsWith(u8, c.path, "/grpc/")) { const method_name = c.path["/grpc/".len..]; const grpc_method = if (method_name.len > 0) method_name else c.jsonStrField("method"); - c.policy = pol.evaluatePolicy(c.header("x-api-key"), grpc_method); + c.policy = pol.evaluatePolicy(c.keystore, c.cfg.auth_mode, c.header("x-api-key"), grpc_method); + if (!c.policy.allowed) { + enforceAuth(c); + return; + } grpcDispatch(c, grpc_method); return; } // Table routing. const route = find(c.path) orelse { - c.policy = pol.evaluatePolicy(c.header("x-api-key"), "unknown"); + // Unknown routes stay stealth-404 — the policy decision is still + // evaluated and audited, but never leaks route existence. + c.policy = pol.evaluatePolicy(c.keystore, c.cfg.auth_mode, c.header("x-api-key"), "unknown"); + res.logAudit(c.redis, c.policy); respond.respond(c, 404, notFoundJson(c)); return; }; @@ -121,20 +137,38 @@ pub fn dispatch(c: *ctx.Ctx) void { // Verb governance (stealth mode: 404 + timing jitter on denial). if (!verbAllowed(route, c.method)) { vg.stealthDelay(); - c.policy = pol.evaluatePolicy("", route.module); - respond.respondError(c, vg.denialStatusCode(.{ - .allowed = false, .matched = true, .stealth = true, - .rule_name = undefined, .rule_len = 0, .verb = undefined, .verb_len = 0, - }), "not found"); + c.policy = pol.evaluatePolicy(c.keystore, c.cfg.auth_mode, "", route.module); + res.logAudit(c.redis, c.policy); + respond.respondError(c, 404, "not found"); return; } - // Policy gate — with the REAL API key at last (V2 headers). - c.policy = pol.evaluatePolicy(c.header("x-api-key"), route.module); + // Policy gate — deny-by-default with keystore entitlements; public + // routes (health, meta) are exempt but still evaluated and audited. + c.policy = pol.evaluatePolicy(c.keystore, c.cfg.auth_mode, c.header("x-api-key"), route.module); + if (!route.public and !c.policy.allowed) { + enforceAuth(c); + return; + } + if (route.public and !c.policy.allowed) { + // Public route, denied key: serve, but audit the denial. + res.logAudit(c.redis, c.policy); + } route.resolver(c); } +/// Respond to a policy denial: 401 when the key is absent/unknown, +/// 403 when the key is valid but unentitled. The denial is audited. +fn enforceAuth(c: *ctx.Ctx) void { + res.logAudit(c.redis, c.policy); + const code: u16 = if (c.policy.access_level == .authenticated) 403 else 401; + var rb: [128]u8 = undefined; + const reason = std.fmt.bufPrint(&rb, "{s}", .{std.mem.sliceTo(&c.policy.reason, 0)}) + catch "not authorized"; + respond.respondError(c, code, reason); +} + // --------------------------------------------------------------------------- // Route adapters (the bridge to the existing resolvers; Phase 2 folds // the cache/envelope/audit decorators in here, once per concern) @@ -144,47 +178,51 @@ fn healthResolver(c: *ctx.Ctx) void { respond.respond(c, 200, healthJson(c)); } +fn metaResolver(c: *ctx.Ctx) void { + respond.respond(c, 200, metaJson(c)); +} + fn telemetryResolver(c: *ctx.Ctx) void { - respond.respond(c, 200, res.resolveTelemetry(c.redis, c.policy, c.out_buf, c.arena)); + res.resolveTelemetry(c); } fn routesResolver(c: *ctx.Ctx) void { - const target = c.queryParam("target"); + const target = c.param("target"); if (target.len == 0) { - respond.respondError(c, 400, "missing required query parameter: target (usage: /api/v1/routes?target=)"); + respond.respondError(c, 400, "missing required parameter: target (REST: ?target=…; gRPC body field target)"); return; } - respond.respond(c, 200, res.resolveRouteForensics(target, c.redis, c.policy, c.out_buf, c.arena)); + res.resolveRoutes(c, target); } fn temporalResolver(c: *ctx.Ctx) void { - const mode = c.queryParam("mode"); + const mode = c.param("mode"); if (mode.len == 0) { - respond.respondError(c, 400, "missing required query parameter: mode (usage: /api/v1/audit/temporal?mode=as_of&time=...; available: as_of, between, history)"); + respond.respondError(c, 400, "missing required parameter: mode (as_of, between, history)"); return; } - const params = res.TemporalParams{ - .time = c.queryParam("time"), - .start = c.queryParam("start"), - .end = c.queryParam("end"), - .event_id = c.queryParam("event_id"), - .limit = std.fmt.parseInt(u32, c.queryParam("limit"), 10) catch 50, + const params = t.TemporalParams{ + .time = c.param("time"), + .start = c.param("start"), + .end = c.param("end"), + .event_id = c.param("event_id"), + .limit = std.fmt.parseInt(u32, c.param("limit"), 10) catch 50, }; - respond.respond(c, 200, res.resolveTemporalAudit(mode, params, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); + res.resolveTemporal(c, mode, params); } fn auditResolver(c: *ctx.Ctx) void { - const limit = std.fmt.parseInt(u32, c.queryParam("limit"), 10) catch 50; - respond.respond(c, 200, res.resolveAudit(limit, c.redis, c.policy, c.out_buf, c.arena)); + const limit = std.fmt.parseInt(u32, c.param("limit"), 10) catch 50; + res.resolveAudit(c, limit); } fn smokepingResolver(c: *ctx.Ctx) void { - const target = c.queryParam("target"); + const target = c.param("target"); if (target.len == 0) { - respond.respondError(c, 400, "missing required query parameter: target (usage: /api/v1/smokeping?target=)"); + respond.respondError(c, 400, "missing required parameter: target (REST: ?target=…; gRPC body field target)"); return; } - respond.respond(c, 200, res.resolveSmokeping(target, c.redis, c.policy, c.out_buf, c.arena)); + res.resolveSmokeping(c, target); } fn graphqlResolver(c: *ctx.Ctx) void { @@ -199,13 +237,13 @@ fn graphqlResolver(c: *ctx.Ctx) void { respond.respond(c, 200, "{\"errors\":[{\"message\":\"Missing query field in request body\"}]}"); return; } - respond.respond(c, 200, res.resolveGraphqlQuery(query, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); + res.resolveGraphqlQuery(c, query); } /// gRPC-JSON dispatch (method name from path or body). fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { if (std.mem.eql(u8, method_name, "GetTelemetrySnapshot")) { - respond.respond(c, 200, res.resolveTelemetry(c.redis, c.policy, c.out_buf, c.arena)); + res.resolveTelemetry(c); return; } if (std.mem.eql(u8, method_name, "GetRouteForensicsSnapshot")) { @@ -214,12 +252,11 @@ fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { respond.respondError(c, 400, "target field required"); return; } - respond.respond(c, 200, res.resolveRouteForensics(target, c.redis, c.policy, c.out_buf, c.arena)); + res.resolveRoutes(c, target); return; } if (std.mem.eql(u8, method_name, "GetAuditSnapshot")) { - const limit = c.jsonIntField("limit") orelse @as(u32, 50); - respond.respond(c, 200, res.resolveAudit(limit, c.redis, c.policy, c.out_buf, c.arena)); + res.resolveAudit(c, c.jsonIntField("limit") orelse 50); return; } if (std.mem.eql(u8, method_name, "GetSmokePingSnapshot")) { @@ -228,7 +265,7 @@ fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { respond.respondError(c, 400, "target field required"); return; } - respond.respond(c, 200, res.resolveSmokeping(target, c.redis, c.policy, c.out_buf, c.arena)); + res.resolveSmokeping(c, target); return; } if (std.mem.eql(u8, method_name, "GetTemporalAuditSnapshot")) { @@ -237,14 +274,14 @@ fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { respond.respondError(c, 400, "mode field required (as_of, between, history)"); return; } - const params = res.TemporalParams{ + const params = t.TemporalParams{ .time = c.jsonStrField("time"), .start = c.jsonStrField("start"), .end = c.jsonStrField("end"), .event_id = c.jsonStrField("event_id"), .limit = c.jsonIntField("limit") orelse 50, }; - respond.respond(c, 200, res.resolveTemporalAudit(mode, params, c.redis, c.verisim, c.policy, c.out_buf, c.arena)); + res.resolveTemporal(c, mode, params); return; } var eb: [256]u8 = undefined; @@ -261,7 +298,7 @@ fn grpcDispatch(c: *ctx.Ctx, method_name: []const u8) void { // Reflective renderings (health + not-found describe the live table) // --------------------------------------------------------------------------- -const GATEWAY_VERSION = "0.3.0"; +const GATEWAY_VERSION = "0.4.0"; /// Health JSON — includes the live gnosis pool state handed in via Ctx. pub fn healthJson(c: *ctx.Ctx) []const u8 { @@ -281,16 +318,56 @@ pub fn healthJson(c: *ctx.Ctx) []const u8 { "\"timestamp\":\"{s}\",\"protocols\":{{\"rest\":{s},\"graphql\":{s},\"grpc\":{s}}}," ++ "\"active_protocols\":{d},\"bound_ports\":{d}," ++ "\"verb_governance\":true,\"stealth_mode\":true,\"proof_mode\":\"light\"," ++ - "\"policy_phase\":2,\"pool\":{{\"slot_state\":{d}}}}}", + "\"policy_phase\":2,\"auth\":\"{s}\",\"pool\":{{\"slot_state\":{d}}}}}", .{ GATEWAY_VERSION, ts, if (cfg.rest_enabled) "true" else "false", if (cfg.graphql_enabled) "true" else "false", if (cfg.grpc_enabled) "true" else "false", active, - bound, c.pool_state, + bound, @tagName(cfg.auth_mode), + c.pool_state, }, ) catch "{\"status\":\"healthy\"}"; } +/// /api/v1/meta — the gateway describes itself from the same tables the +/// dispatcher uses: routes, verbs, modules, aspects, auth posture, +/// versions. The description cannot drift from the behaviour because it +/// IS the behaviour. No key material, no URLs, no secrets. +pub fn metaJson(c: *ctx.Ctx) []const u8 { + var fbs = std.io.fixedBufferStream(c.out_buf); + const w = fbs.writer(); + w.print("{{\"service\":\"aerie-gateway\",\"version\":\"{s}\",\"policy_phase\":2," ++ + "\"auth\":\"{s}\",\"aspects\":[\"cache\",\"enveloped\",\"audited\",\"dual_audited\"]," ++ + "\"forensics\":\"FS-0 (untrusted search, trusted checking)\"," ++ + "\"routes\":[", .{ GATEWAY_VERSION, @tagName(c.cfg.auth_mode) }) catch {}; + for (&routes, 0..) |*route, i| { + if (i > 0) w.writeByte(',') catch {}; + w.print("{{\"path\":\"{s}\",\"verbs\":[", .{route.path}) catch {}; + for (route.verbs, 0..) |v, j| { + if (j > 0) w.writeByte(',') catch {}; + w.print("\"{s}\"", .{v}) catch {}; + } + w.writeAll("],\"module\":\"") catch {}; + w.writeAll(route.module) catch {}; + w.writeByte('"') catch {}; + if (route.public) w.writeAll(",\"public\":true") catch {}; + if (route.aspects.len > 0) { + w.writeAll(",\"aspects\":[") catch {}; + var abuf: [32]u8 = undefined; + for (route.aspects, 0..) |asp, j| { + if (j > 0) w.writeByte(',') catch {}; + w.writeByte('"') catch {}; + w.writeAll(aspects.describe(asp, &abuf)) catch {}; + w.writeByte('"') catch {}; + } + w.writeAll("]") catch {}; + } + w.writeAll("}") catch {}; + } + w.writeAll("]}") catch {}; + return fbs.getWritten(); +} + /// Not-found JSON — lists only enabled endpoints, derived from the table. pub fn notFoundJson(c: *ctx.Ctx) []const u8 { var fbs = std.io.fixedBufferStream(c.out_buf); @@ -326,6 +403,32 @@ test "router: boundary-guarded longest-prefix match" { try std.testing.expect(find("/nope") == null); } +test "router: meta renders the live table (self-description)" { + var arena_inst = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_inst.deinit(); + var out: [4096]u8 = undefined; + const cfg = config.Config{}; + var c = ctx.Ctx{ + .arena = arena_inst.allocator(), + .method = "GET", + .path = "/api/v1/meta", + .query = "", + .body = "", + .header_names = null, + .header_values = null, + .header_count = 0, + .cfg = &cfg, + .redis = undefined, + .verisim = undefined, + .out_buf = &out, + }; + const meta = metaJson(&c); + // telemetry row carries its aspects; meta itself is public + try std.testing.expect(std.mem.indexOf(u8, meta, "\"aspects\":[\"cache:30s\",\"enveloped\",\"audited\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, meta, "\"module\":\"meta\",\"public\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, meta, "\"auth\":\"deny\"") != null); +} + test "router: verbs enforced per route" { const telemetry = find("/api/v1/telemetry").?; try std.testing.expect(verbAllowed(telemetry, "GET")); diff --git a/src/api/zig/types.zig b/src/api/zig/types.zig index 20ab539..a9c5597 100644 --- a/src/api/zig/types.zig +++ b/src/api/zig/types.zig @@ -27,6 +27,16 @@ pub const AccessLevel = enum { invalid, // Malformed API key }; +/// TemporalParams bundles query parameters for temporal audit queries +/// (lives here so ctx, resolvers and adapters share one definition). +pub const TemporalParams = struct { + time: []const u8 = "", + start: []const u8 = "", + end: []const u8 = "", + event_id: []const u8 = "", + limit: u32 = 50, +}; + /// PolicyDecision captures the result of evaluating a request against /// the policy gate. Every decision is recorded in the Redis audit log. pub const PolicyDecision = struct {