Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<key>:<name>:telemetry,routes[;<key>:<name>:…]
- AERIE_AUTH_MODE=open
- REDIS_URL=redis://redis:6379
- LIBRESPEED_URL=http://librespeed:80
- HYPERGLASS_URL=http://hyperglass:80
Expand Down
12 changes: 10 additions & 2 deletions configs/aerie.kyaml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
187 changes: 187 additions & 0 deletions src/api/zig/aspects.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// 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);
}
29 changes: 24 additions & 5 deletions src/api/zig/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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" {
Expand All @@ -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" {
Expand Down
Loading
Loading