Skip to content

feat(adr0025): Phase 1 (1.1+1.2)#952

Merged
gtema merged 2 commits into
mainfrom
claude/adr-0025-implementation-plan-adh5ii
Jul 8, 2026
Merged

feat(adr0025): Phase 1 (1.1+1.2)#952
gtema merged 2 commits into
mainfrom
claude/adr-0025-implementation-plan-adh5ii

Conversation

@gtema

@gtema gtema commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator
  • feat(adr0025): Phase 1 - host function registration
  • feat(adr0025): Phase 1 - auth-method dispatch (PR 1.2)

gtema added 2 commits July 8, 2026 19:26
Wires provision_user/find_user (ADR 0025 §6 B/C) into the
WasmPluginRegistry loaded in Phase 0: extism::Function registration for
a plugin's granted capabilities, backed by a new HostFunctions trait
that openstack-keystone-core will implement (kept out of
dynamic-plugin-runtime to preserve the one-way core ->
dynamic-plugin-runtime dependency).

Since wasmtime requires every guest-declared import to resolve at
instantiation, both functions are always registered whenever a
HostFunctions provider is configured, and each closure independently
rejects a call its plugin's capabilities config didn't grant - a
per-capability import omission isn't viable against a single compiled
module, but the guest-visible result is identical: an ungranted
capability can never be successfully exercised.

The reference plugin fixture gains call_provision_user/call_find_user
guest exports (via extism-pdk's #[host_fn] extern "ExtismHost" block) so
the wiring is proven end-to-end against a real compiled .wasm module,
not just a mock.

- New (plugin_name, external_id) -> user_id mapping table in
  identity-driver-sql, atomically upserted via ON CONFLICT DO NOTHING
plus a re-select (idempotent under concurrent first-logins, per SS6.B).
Two new IdentityBackend/IdentityApi methods expose it.
- CoreHostFunctions enforces
  provision_domain_id/allowed_provision_domains before ever reaching the
identity backend, is idempotent (repeat provisioning resolves the
existing mapping, never a duplicate user), and self-heals a lost
provisioning race by deleting the orphaned user and resolving to the
winner.
- ResolvedIdentityHandle is an HMAC-SHA256-signed token over
  (plugin_name, user_id, domain_id) rather than an in-memory handle ->
identity map: extism bakes host functions into the *compiled* module,
shared across every concurrent invocation, so there is no
per-Store-scoped place to keep such a map safely isolated between
concurrent requests. The signed token gives the same guarantee
(unforgeable, resolves only to what this plugin's own
provision_user/find_user actually returned) without shared mutable
state.
- find_user re-validates the user's live domain on every call, not only
  at provisioning time, so a user moved outside the plugin's configured
domain(s) is immediately unreachable (SS4 "Admin-Authorized External
Identity Linking").
- Mandatory CADF audit (SS6.E) via AuditDispatcher::dispatch_critical
  (fail-closed) on every provision_user/find_user call, using the
existing pre-authentication Initiator precedent
(build_initiator_unknown) since these calls happen before any
ValidatedSecurityContext exists.

assign_role (SS6.D):
- Bounded on all three axes host-side: role name (assign_role_allowed
  allowlist), target project/domain (reuses provision_user's
provision_domain_id/allowed_provision_domains restriction - a project
target resolves its owning domain via ResourceApi::get_project first),
and scope type (RoleAssignmentTarget has no System variant at all, so
there is no code path to request one, not just a runtime check).
- The actor is always the identity CoreHostFunctions::verify_handle
  resolves from the caller's own resolved_identity handle - the same
anti-impersonation constraint as provision_user/find_user.

http_fetch (SS6.A), the SSRF-hardened outbound HTTP bridge:
- allowed_hosts is a hostname allowlist, but every candidate address is
  re-resolved and validated at connect time
(private/loopback/link-local/ multicast/cloud-metadata ranges rejected,
including 169.254.169.254), and reqwest::ClientBuilder::resolve pins the
connection to that exact validated IpAddr - the socket never lets the
HTTP client re-resolve the hostname a second time, closing the standard
DNS-rebinding bypass of a hostname-only allowlist.
- No redirects followed by default; http_fetch_follow_redirects re-
  validates host + connects-to-validated-IP on every hop, bounded to 5.
- The host-injected auth header/secret (http_fetch_auth_header +
  http_fetch_auth_secret_env) is attached after the guest's own headers,
so it can never be seen or overridden by guest code.
- A hardcoded 4 MiB response-body cap (checked against Content-Length up
  front, and against the actual buffered size, since a chunked response
may omit Content-Length) - not itself a config field per the ADR, called
out in the module doc as a fixed constant.

Assisted-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
Wire mode=full_auth dynamic plugins into POST /v3/auth/tokens: an
unrecognized identity.<method> name now dispatches to a loaded
plugin's authenticate entry point instead of silently no-opping.
CoreHostFunctions/WasmPluginRegistry existed since PR 1.1 but had
no production call site until now.

- add AuthenticationContext::WasmPlugin{plugin_name, plugin_sha256,
  claims, token}; auth_type() returns Cow<str> to carry the
  plugin-specific method name
- add Credentials.plugin_claims, nested by plugin_name, so a
  plugin's claims land under plugin_claims.<plugin_name>.* in OPA
  input and can never shadow a top-level field
- add core::dynamic_plugin_auth::authenticate_via_wasm_plugin:
  registry lookup, mode check, exposed_headers allowlist +
  HARD_DENYLISTED_HEADERS re-check, trusted-proxy remote_addr
  resolution (new [dynamic_plugins].trusted_proxies, separate from
  api_key's), invoke, response-bounds decode, identity-binding
  verification, CADF audit - fails closed on every path except an
  unmatched/wrong-mode plugin, which degrades like any other
  unmatched method
- extract crate::net::resolve_client_ip so dispatch and the
  existing api_key path share one XFF-resolution implementation
- populate Service.dynamic_plugin_registry/core_host_functions
  post-construction (load_dynamic_plugins), mirroring
  subscribe_event_hooks's existing chicken-and-egg fix for wiring
  that needs an already-built ServiceState
- add Identity.extra catch-all (serde flatten) so an unrecognized
  method's request body survives deserialization
- rewrite the reference plugin fixture to the real
  AuthPluginRequest/AuthPluginResponse contract, exercising
  provision_user and a bad_handle test hook

Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦢 Load Test Results

Goose Attack Report

Plan Overview

Action Started Stopped Elapsed Users
Increasing 26-07-08 17:46:50 26-07-08 17:47:07 00:00:17 0 → 30
Maintaining 26-07-08 17:47:07 26-07-08 17:47:37 00:00:30 30
Decreasing 26-07-08 17:47:37 26-07-08 17:47:50 00:00:13 0 ← 30

Request Metrics

Method Name # Requests # Fails Average (ms) Min (ms) Max (ms) RPS Failures/s
DELETE DELETE /v3/auth/tokens 781 0 56.41 6 153 26.03 0.00
DELETE DELETE /v3/projects/:id (teardown) 2 0 29.50 25 34 0.07 0.00
DELETE DELETE /v3/users/:id (teardown) 3 0 32.67 27 41 0.10 0.00
GET 8957 0 52.14 35 104 298.57 0.00
GET GET /v3/auth/tokens (validate new) 779 1 132.32 45 60001 25.97 0.03
GET GET /v3/projects/:id 1398 0 42.55 34 64 46.60 0.00
GET GET /v3/projects/:id (catalog) 1387 0 42.66 33 63 46.23 0.00
GET GET /v3/users/:id 1951 0 45.81 39 64 65.03 0.00
GET GET /v3/users/:id (catalog) 1533 0 45.66 38 63 51.10 0.00
POST POST /v3/auth/tokens 776 0 41.65 33 60 25.87 0.00
Aggregated 17567 1 52.63 6 60001 585.57 0.03

Response Time Metrics

Method Name 50%ile (ms) 60%ile (ms) 70%ile (ms) 80%ile (ms) 90%ile (ms) 95%ile (ms) 99%ile (ms) 100%ile (ms)
DELETE DELETE /v3/auth/tokens 56 57 57 59 60 63 75 150
DELETE DELETE /v3/projects/:id (teardown) 25 25 25 34 34 34 34 34
DELETE DELETE /v3/users/:id (teardown) 30 30 30 30 41 41 41 41
GET 48 50 52 56 80 83 88 100
GET GET /v3/auth/tokens (validate new) 55 56 57 58 59 61 64 60,000
GET GET /v3/projects/:id 42 43 44 45 46 48 51 64
GET GET /v3/projects/:id (catalog) 42 43 44 45 47 48 52 63
GET GET /v3/users/:id 46 46 47 48 50 51 55 64
GET GET /v3/users/:id (catalog) 45 46 47 48 49 51 56 63
POST POST /v3/auth/tokens 41 42 43 44 45 46 49 60
Aggregated 46 48 50 53 59 80 86 60,000

Status Code Metrics

Method Name Status Codes
DELETE DELETE /v3/auth/tokens 781 [204]
DELETE DELETE /v3/projects/:id (teardown) 2 [204]
DELETE DELETE /v3/users/:id (teardown) 3 [204]
GET 8,957 [200]
GET GET /v3/auth/tokens (validate new) 778 [200], 1 [0]
GET GET /v3/projects/:id 1,398 [200]
GET GET /v3/projects/:id (catalog) 1,387 [200]
GET GET /v3/users/:id 1,951 [200]
GET GET /v3/users/:id (catalog) 1,533 [200]
POST POST /v3/auth/tokens 776 [200]
Aggregated 16,780 [200], 1 [0], 786 [204]

Transaction Metrics

Transaction # Times Run # Fails Average (ms) Min (ms) Max (ms) RPS Failures/s
ReadHeavy
0.0 1 0 10.00 10 10 0.03 0.00
0.1 1520 0 51.70 44 74 50.67 0.00
0.2 1517 0 42.85 35 59 50.57 0.00
0.3 1519 0 42.68 36 61 50.63 0.00
TokenLifecycle
1.0 0 0 0.00 0 0 0.00 0.00
1.1 781 0 231.26 117 60011 26.03 0.00
ValidateToken
2.0 0 0 0.00 0 0 0.00 0.00
2.1 1481 0 80.94 60 104 49.37 0.00
UserCRUD
3.0 0 0 0.00 0 0 0.00 0.00
3.1 0 0 0.00 0 0 0.00 0.00
3.2 1951 0 45.83 39 64 65.03 0.00
3.3 3 0 32.67 27 41 0.10 0.00
ProjectCRUD
4.0 0 0 0.00 0 0 0.00 0.00
4.1 0 0 0.00 0 0 0.00 0.00
4.2 1398 0 42.58 34 64 46.60 0.00
4.3 2 0 29.50 25 34 0.07 0.00
UserRead
5.0 0 0 0.00 0 0 0.00 0.00
5.1 1534 0 51.54 44 70 51.13 0.00
5.2 1533 0 45.68 38 63 51.10 0.00
ProjectRead
6.0 0 0 0.00 0 0 0.00 0.00
6.1 1386 0 43.19 36 59 46.20 0.00
6.2 1387 0 42.69 33 63 46.23 0.00
Aggregated 16013 0 57.74 10 60011 533.77 0.00

Scenario Metrics

Transaction # Users # Times Run Average (ms) Min (ms) Max (ms) Scenarios/s Iterations
ReadHeavy 7 1518 138.21 124 164 50.60 216.86
TokenLifecycle 4 776 154.74 140 256 25.87 194.00
ValidateToken 4 1477 80.98 69 104 49.23 369.25
UserCRUD 3 1948 45.84 39 64 64.93 649.33
ProjectCRUD 2 1396 42.58 34 64 46.53 698.00
UserRead 5 1531 97.72 86 119 51.03 306.20
ProjectRead 4 1384 86.36 75 107 46.13 346.00
Aggregated 29 10030 86.47 34 256 334.33 2779.64

Error Metrics

Method Name # Error
GET GET /v3/auth/tokens (validate new) 1 error sending request GET /v3/auth/tokens (validate new): operation timed out

View full report

@gtema gtema merged commit 79433ef into main Jul 8, 2026
33 checks passed
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchclaude/adr-0025-implementation-plan-adh5ii
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
nanoseconds (ns)
(Result Δ%)
Upper Boundary
nanoseconds (ns)
(Limit %)
Command_Serde/apply/remove📈 view plot
🚷 view threshold
127,120.00 ns
(-49.70%)Baseline: 252,705.03 ns
1,513,172.56 ns
(8.40%)
Command_Serde/apply/set📈 view plot
🚷 view threshold
134,830.00 ns
(-37.82%)Baseline: 216,834.14 ns
813,164.52 ns
(16.58%)
Command_Serde/pack/delete📈 view plot
🚷 view threshold
125.18 ns
(+2.02%)Baseline: 122.70 ns
144.44 ns
(86.67%)
Command_Serde/pack/delete_index📈 view plot
🚷 view threshold
111.38 ns
(+0.39%)Baseline: 110.94 ns
130.43 ns
(85.39%)
Command_Serde/pack/set📈 view plot
🚷 view threshold
203.64 ns
(+2.85%)Baseline: 198.00 ns
236.59 ns
(86.07%)
Command_Serde/pack/set_index📈 view plot
🚷 view threshold
112.03 ns
(+1.20%)Baseline: 110.70 ns
129.65 ns
(86.41%)
Command_Serde/unpack/delete📈 view plot
🚷 view threshold
193.30 ns
(-0.22%)Baseline: 193.72 ns
236.88 ns
(81.60%)
Command_Serde/unpack/delete_index📈 view plot
🚷 view threshold
157.59 ns
(-2.32%)Baseline: 161.33 ns
200.85 ns
(78.46%)
Command_Serde/unpack/set📈 view plot
🚷 view threshold
269.92 ns
(-0.85%)Baseline: 272.23 ns
327.75 ns
(82.36%)
Command_Serde/unpack/set_index📈 view plot
🚷 view threshold
160.21 ns
(+0.15%)Baseline: 159.97 ns
197.27 ns
(81.21%)
Payload_encryption/pack/remove_cmd📈 view plot
🚷 view threshold
115.43 ns
(-1.13%)Baseline: 116.75 ns
140.01 ns
(82.44%)
Payload_encryption/pack/set_cmd📈 view plot
🚷 view threshold
205.43 ns
(-0.52%)Baseline: 206.50 ns
262.71 ns
(78.20%)
Payload_encryption/unpack/remove_cmd📈 view plot
🚷 view threshold
205.45 ns
(-0.10%)Baseline: 205.65 ns
257.60 ns
(79.75%)
Payload_encryption/unpack/set_cmd📈 view plot
🚷 view threshold
288.89 ns
(+1.69%)Baseline: 284.09 ns
351.20 ns
(82.26%)
Raft_1Node_Latency/prefix/1node📈 view plot
🚷 view threshold
2,490,000.00 ns
(-12.24%)Baseline: 2,837,377.34 ns
6,283,722.79 ns
(39.63%)
Raft_1Node_Latency/read/1node📈 view plot
🚷 view threshold
42,962.00 ns
(+55.47%)Baseline: 27,633.34 ns
73,323.32 ns
(58.59%)
Raft_1Node_Latency/remove/1node📈 view plot
🚷 view threshold
372,680.00 ns
(-25.74%)Baseline: 501,828.75 ns
1,555,659.01 ns
(23.96%)
Raft_1Node_Latency/write/1node📈 view plot
🚷 view threshold
386,060.00 ns
(-29.64%)Baseline: 548,688.44 ns
1,866,721.97 ns
(20.68%)
build_snapshot/default📈 view plot
🚷 view threshold
110,210.00 ns
(+0.15%)Baseline: 110,039.50 ns
130,594.55 ns
(84.39%)
fernet token/project📈 view plot
🚷 view threshold
1,441.10 ns
(+3.20%)Baseline: 1,396.43 ns
1,625.11 ns
(88.68%)
get_data_keyspace📈 view plot
🚷 view threshold
0.31 ns
(-1.12%)Baseline: 0.32 ns
0.36 ns
(85.46%)
get_db📈 view plot
🚷 view threshold
0.31 ns
(-1.15%)Baseline: 0.32 ns
0.36 ns
(85.43%)
get_fernet_token_timestamp/project📈 view plot
🚷 view threshold
144.49 ns
(+1.27%)Baseline: 142.68 ns
175.67 ns
(82.25%)
get_keyspace📈 view plot
🚷 view threshold
4.40 ns
(-12.16%)Baseline: 5.01 ns
10.66 ns
(41.29%)
🐰 View full continuous benchmarking report in Bencher

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant