From db3c4b4c9352179c6e064e93db3b31861475bfc9 Mon Sep 17 00:00:00 2001 From: Carl Niklas Rydberg Date: Sat, 24 Jan 2026 10:16:22 +0100 Subject: [PATCH] Add per-request space selection via X-Amduat-Space --- CMakeLists.txt | 17 ++++++ README.md | 18 +++++++ src/amduatd.c | 41 ++++++++++++--- src/amduatd_caps.c | 4 ++ src/amduatd_http.c | 7 +++ src/amduatd_http.h | 3 ++ src/amduatd_space.c | 40 ++++++++++++++ src/amduatd_space.h | 11 ++++ tests/test_amduatd_space_resolve.c | 83 ++++++++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 tests/test_amduatd_space_resolve.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 560949f..3c716a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,23 @@ target_link_libraries(amduatd_test_store_backend add_test(NAME amduatd_store_backend COMMAND amduatd_test_store_backend) +add_executable(amduatd_test_space_resolve + tests/test_amduatd_space_resolve.c + src/amduatd_space.c +) + +target_include_directories(amduatd_test_space_resolve + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/vendor/amduat/include +) + +target_link_libraries(amduatd_test_space_resolve + PRIVATE amduat_asl_pointer_fs +) + +add_test(NAME amduatd_space_resolve COMMAND amduatd_test_space_resolve) + add_executable(amduatd_test_derivation_index tests/test_amduatd_derivation_index.c src/amduatd_derivation_index.c diff --git a/README.md b/README.md index 95f7b89..1efeaf4 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,24 @@ Artifact info (length + type tag): curl --unix-socket amduatd.sock 'http://localhost/v1/artifacts/?format=info' ``` +## Space selection + +Requests can select a space via the `X-Amduat-Space` header: + +```sh +curl --unix-socket amduatd.sock http://localhost/v1/concepts \ + -H 'X-Amduat-Space: demo' +``` + +Precedence rules: + +- `X-Amduat-Space` header (if present) +- daemon `--space` default (if configured) +- unscoped names (no space) + +When capability tokens are used, the requested space must match the token's +space (or the token must be unscoped), otherwise the request is rejected. + ## Current endpoints - `GET /v1/meta` → `{store_id, encoding_profile_id, hash_id, api_contract_ref}` diff --git a/src/amduatd.c b/src/amduatd.c index 5ebc015..de394eb 100644 --- a/src/amduatd.c +++ b/src/amduatd.c @@ -3881,6 +3881,10 @@ static bool amduatd_handle_conn(int fd, bool has_actor = false; bool has_uid = false; uid_t uid = 0; + amduatd_space_t req_space; + const amduatd_space_t *effective_space = NULL; + amduatd_cfg_t req_cfg; + const amduatd_cfg_t *effective_cfg = dcfg; if (!amduatd_http_parse_request(fd, &req)) { return false; @@ -3898,6 +3902,30 @@ static bool amduatd_handle_conn(int fd, amduatd_path_without_query(req.path, no_query, sizeof(no_query)); + { + const char *space_header = NULL; + amduatd_space_resolve_status_t space_st; + + if (req.x_space[0] != '\0') { + space_header = req.x_space; + } + space_st = amduatd_space_resolve_effective(&dcfg->space, + space_header, + &req_space, + &effective_space); + if (space_st != AMDUATD_SPACE_RESOLVE_OK || effective_space == NULL) { + ok = amduatd_send_json_error(fd, 400, "Bad Request", + "invalid X-Amduat-Space"); + goto conn_cleanup; + } + if (effective_space != &dcfg->space) { + req_cfg = *dcfg; + req_cfg.space = req_space; + effective_cfg = &req_cfg; + } + req.effective_space = effective_space; + } + if (!(strcmp(req.method, "GET") == 0 && strcmp(no_query, "/v1/cap/resolve") == 0) && !amduatd_actor_allowed(allowlist, has_uid, uid)) { @@ -3916,7 +3944,7 @@ static bool amduatd_handle_conn(int fd, ui_ctx.ui_ref = ui_ref; ui_ctx.store_cfg = cfg; ui_ctx.concepts = concepts; - ui_ctx.daemon_cfg = dcfg; + ui_ctx.daemon_cfg = effective_cfg; ui_ctx.root_path = root_path; ui_ctx.caps = caps; ui_resp.fd = fd; @@ -3965,7 +3993,7 @@ static bool amduatd_handle_conn(int fd, caps_ctx.ui_ref = ui_ref; caps_ctx.store_cfg = cfg; caps_ctx.concepts = concepts; - caps_ctx.daemon_cfg = dcfg; + caps_ctx.daemon_cfg = effective_cfg; caps_ctx.root_path = root_path; caps_ctx.caps = caps; caps_resp.fd = fd; @@ -3976,7 +4004,7 @@ static bool amduatd_handle_conn(int fd, goto conn_cleanup; } if (strcmp(req.method, "POST") == 0 && strcmp(no_query, "/v1/pel/run") == 0) { - ok = amduatd_handle_post_pel_run(fd, store, cfg, concepts, dcfg, + ok = amduatd_handle_post_pel_run(fd, store, cfg, concepts, effective_cfg, root_path, &req); goto conn_cleanup; } @@ -3987,7 +4015,8 @@ static bool amduatd_handle_conn(int fd, } if (strcmp(req.method, "POST") == 0 && strcmp(no_query, "/v1/context_frames") == 0) { - ok = amduatd_handle_post_context_frames(fd, store, cfg, concepts, dcfg, + ok = amduatd_handle_post_context_frames(fd, store, cfg, concepts, + effective_cfg, &req); goto conn_cleanup; } @@ -3999,7 +4028,7 @@ static bool amduatd_handle_conn(int fd, concepts_ctx.ui_ref = ui_ref; concepts_ctx.store_cfg = cfg; concepts_ctx.concepts = concepts; - concepts_ctx.daemon_cfg = dcfg; + concepts_ctx.daemon_cfg = effective_cfg; concepts_ctx.root_path = root_path; concepts_ctx.caps = caps; concepts_resp.fd = fd; @@ -4025,7 +4054,7 @@ static bool amduatd_handle_conn(int fd, caps_ctx.ui_ref = ui_ref; caps_ctx.store_cfg = cfg; caps_ctx.concepts = concepts; - caps_ctx.daemon_cfg = dcfg; + caps_ctx.daemon_cfg = effective_cfg; caps_ctx.root_path = root_path; caps_ctx.caps = caps; caps_resp.fd = fd; diff --git a/src/amduatd_caps.c b/src/amduatd_caps.c index 4f8b818..529c03b 100644 --- a/src/amduatd_caps.c +++ b/src/amduatd_caps.c @@ -1618,6 +1618,10 @@ static bool amduatd_handle_get_cap_resolve( token_hash); free(token_bytes); amduatd_cap_token_free(&token); + if (strcmp(reason, "wrong-space") == 0) { + return amduatd_send_json_error(fd, 403, "Forbidden", + "space not permitted by capability"); + } return amduatd_send_json_error(fd, 403, "Forbidden", "invalid capability"); } diff --git a/src/amduatd_http.c b/src/amduatd_http.c index 1fd40bb..a7f1feb 100644 --- a/src/amduatd_http.c +++ b/src/amduatd_http.c @@ -178,6 +178,7 @@ static bool amduatd_read_line(int fd, char *buf, size_t cap, size_t *out_len) { static void amduatd_http_req_init(amduatd_http_req_t *req) { memset(req, 0, sizeof(*req)); + req->effective_space = NULL; } bool amduatd_http_parse_request(int fd, amduatd_http_req_t *out_req) { @@ -254,6 +255,12 @@ bool amduatd_http_parse_request(int fd, amduatd_http_req_t *out_req) { v++; } strncpy(out_req->x_capability, v, sizeof(out_req->x_capability) - 1); + } else if (strncasecmp(line, "X-Amduat-Space:", 15) == 0) { + const char *v = line + 15; + while (*v == ' ' || *v == '\t') { + v++; + } + strncpy(out_req->x_space, v, sizeof(out_req->x_space) - 1); } } diff --git a/src/amduatd_http.h b/src/amduatd_http.h index f6c2a60..5466e64 100644 --- a/src/amduatd_http.h +++ b/src/amduatd_http.h @@ -2,6 +2,7 @@ #define AMDUATD_HTTP_H #include "amduat/asl/core.h" +#include "amduatd_space.h" #include #include @@ -19,11 +20,13 @@ typedef struct { char accept[128]; char x_type_tag[64]; char x_capability[2048]; + char x_space[AMDUAT_ASL_POINTER_NAME_MAX + 1u]; size_t content_length; bool has_actor; amduat_octets_t actor; bool has_uid; uid_t uid; + const amduatd_space_t *effective_space; } amduatd_http_req_t; typedef struct { diff --git a/src/amduatd_space.c b/src/amduatd_space.c index c9b9e12..37c1839 100644 --- a/src/amduatd_space.c +++ b/src/amduatd_space.c @@ -272,3 +272,43 @@ bool amduatd_space_edges_index_head_name(const amduatd_space_t *sp, bool amduatd_space_should_migrate_unscoped_edges(const amduatd_space_t *sp) { return sp != NULL && sp->enabled && sp->migrate_unscoped_edges; } + +amduatd_space_resolve_status_t amduatd_space_resolve_effective( + const amduatd_space_t *default_space, + const char *request_space_id, + amduatd_space_t *out_space, + const amduatd_space_t **out_effective) { + bool migrate = false; + + if (out_space != NULL) { + memset(out_space, 0, sizeof(*out_space)); + } + if (out_effective != NULL) { + *out_effective = NULL; + } + + if (default_space != NULL) { + migrate = default_space->migrate_unscoped_edges; + } + + if (request_space_id != NULL) { + if (request_space_id[0] == '\0') { + return AMDUATD_SPACE_RESOLVE_ERR_INVALID; + } + if (out_space == NULL) { + return AMDUATD_SPACE_RESOLVE_ERR_INVALID; + } + if (!amduatd_space_init(out_space, request_space_id, migrate)) { + return AMDUATD_SPACE_RESOLVE_ERR_INVALID; + } + if (out_effective != NULL) { + *out_effective = out_space; + } + return AMDUATD_SPACE_RESOLVE_OK; + } + + if (out_effective != NULL) { + *out_effective = default_space; + } + return AMDUATD_SPACE_RESOLVE_OK; +} diff --git a/src/amduatd_space.h b/src/amduatd_space.h index c0238b5..4e72635 100644 --- a/src/amduatd_space.h +++ b/src/amduatd_space.h @@ -48,6 +48,17 @@ bool amduatd_space_edges_index_head_name(const amduatd_space_t *sp, bool amduatd_space_should_migrate_unscoped_edges(const amduatd_space_t *sp); +typedef enum { + AMDUATD_SPACE_RESOLVE_OK = 0, + AMDUATD_SPACE_RESOLVE_ERR_INVALID = 1 +} amduatd_space_resolve_status_t; + +amduatd_space_resolve_status_t amduatd_space_resolve_effective( + const amduatd_space_t *default_space, + const char *request_space_id, + amduatd_space_t *out_space, + const amduatd_space_t **out_effective); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/tests/test_amduatd_space_resolve.c b/tests/test_amduatd_space_resolve.c new file mode 100644 index 0000000..71443b8 --- /dev/null +++ b/tests/test_amduatd_space_resolve.c @@ -0,0 +1,83 @@ +#include "amduatd_space.h" + +#include +#include +#include + +static int failures = 0; + +static void expect(bool cond, const char *msg) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", msg); + failures++; + } +} + +static void expect_space_id(const amduatd_space_t *sp, + const char *expected, + const char *msg) { + if (expected == NULL) { + expect(sp == NULL || sp->space_id.data == NULL, msg); + return; + } + expect(sp != NULL && sp->space_id.data != NULL && + strcmp((const char *)sp->space_id.data, expected) == 0, + msg); +} + +int main(void) { + amduatd_space_t default_space; + amduatd_space_t resolved_space; + const amduatd_space_t *effective = NULL; + amduatd_space_resolve_status_t st; + + if (!amduatd_space_init(&default_space, "alpha", false)) { + fprintf(stderr, "FAIL: default space init\n"); + return 1; + } + + st = amduatd_space_resolve_effective(&default_space, + NULL, + &resolved_space, + &effective); + expect(st == AMDUATD_SPACE_RESOLVE_OK, "resolve default without header"); + expect(effective == &default_space, "uses default space"); + expect(effective != NULL && effective->enabled, "default enabled"); + expect_space_id(effective, "alpha", "default id"); + + st = amduatd_space_resolve_effective(&default_space, + "beta", + &resolved_space, + &effective); + expect(st == AMDUATD_SPACE_RESOLVE_OK, "resolve header override"); + expect(effective == &resolved_space, "header overrides default"); + expect(effective != NULL && effective->enabled, "header enabled"); + expect_space_id(effective, "beta", "header id"); + + st = amduatd_space_resolve_effective(&default_space, + "", + &resolved_space, + &effective); + expect(st == AMDUATD_SPACE_RESOLVE_ERR_INVALID, "reject empty header"); + + st = amduatd_space_resolve_effective(&default_space, + "bad/space", + &resolved_space, + &effective); + expect(st == AMDUATD_SPACE_RESOLVE_ERR_INVALID, "reject invalid header"); + + if (!amduatd_space_init(&default_space, NULL, false)) { + fprintf(stderr, "FAIL: unscoped default init\n"); + return 1; + } + st = amduatd_space_resolve_effective(&default_space, + NULL, + &resolved_space, + &effective); + expect(st == AMDUATD_SPACE_RESOLVE_OK, "resolve unscoped default"); + expect(effective == &default_space, "unscoped uses default"); + expect(effective != NULL && !effective->enabled, "unscoped disabled"); + expect_space_id(effective, NULL, "unscoped id"); + + return failures == 0 ? 0 : 1; +}