Skip to content

feat(admin)!: remove the Admin API resource write path - #915

Merged
moonming merged 5 commits into
mainfrom
feat/remove-admin-write-path
Aug 11, 2026
Merged

feat(admin)!: remove the Admin API resource write path#915
moonming merged 5 commits into
mainfrom
feat/remove-admin-write-path

Conversation

@moonming

@moonming moonming commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Removes the Admin API resource write path. The admin listener (:3001) keeps its read surface — lists/gets for all 8 resource kinds (including the former apikeys spelling), /admin/v1/models/status, /admin/v1/health, OpenAPI + Scalar UI, playground, /livez, /readyz — but resources are now managed exclusively through the declarative paths: a resources_file (resources.yaml, reloaded on SIGHUP) or direct etcd writes.

This is the final step of the deprecation announced in v0.4.0 (RFC 9745 Deprecation headers) and executes the removal scheduled for v0.5.0+ after #848 lifted the write-path-exclusive validations into the canonical schemas so the declarative paths enforce them.

⚠️ Breaking changes

Before After
POST /admin/v1/<kind>, PUT/DELETE /admin/v1/<kind>/{id} (deprecated, functional) 405 with Allow: GET
POST /admin/v1/api_keys/{id}/rotate (and apikeys spelling) 404 — the route is gone
File mode: writes rejected with 409 naming the resources file 405, same as every other mode
Rotate returned a fresh plaintext key No DP-side plaintext rotation. Rotate declaratively: write the same resource id with a new key_hash — the old plaintext stops authenticating as soon as the write propagates (pinned in apikey-lifecycle-e2e)

What to update:

  • Scripts that created/updated/deleted resources via :3001 → write resources.yaml (validate offline with aisix validate --resources <file>, reload with SIGHUP) or write entity-value JSON to etcd at {prefix}/{kind}/{id}.
  • Scripts that rotated caller keys via /rotate → hash the new secret client-side and update the resource's key_hash.
  • The published OpenAPI (/admin/openapi.json) no longer documents write operations; the write-only component schemas (ApiKeyRequest, ApiKeyEntry, ApiKeyRotateResponse, DeleteResponse) are gone from components.schemas.

What changed

Router / handlers — every /admin/v1/* resource route serves get(...) only; both rotate routes deleted; the file-managed write guard and the RFC 9745 deprecation-header middleware deleted (nothing left to mark). 43 write/rotate/uniqueness handler fns removed across the 8 handler modules.

Store layerConfigStore is now a read-only trait (16 put_*/delete_* methods removed, StoreError::ReadOnly gone). EtcdConfigStore keeps only reads (module doc rewritten: resources reach etcd through the declarative paths). FileManagedStore::new(snapshot) drops the path parameter — read-only by construction. InMemoryStore keeps #[cfg(test)] inherent write methods used by unit-test seeding.

OpenAPI — 24 write operations and the rotate path removed from the base document; the no-op write-deprecation marker pass deleted; unreachable component schemas pruned via a reachability walk from paths. New gate test pins that the published reference documents zero non-GET operations under /admin/v1/ and zero deprecated marks anywhere.

Tests — write-path unit tests (CRUD flows, rotate atomicity, write-auth, write-validation) deleted; read tests re-seeded through InMemoryStore; new contract tests pin 405 + Allow: GET on every collection/:id route (auth'd and unauthenticated) and 404 on both rotate spellings. The etcd integration test now seeds via direct etcd_client puts — the path operators actually use.

e2eAdminClient write helpers removed (reads stay; SeedClient is the write front door). file-resource-source-e2e pins the new file-mode contract (reads serve the file, writes 405, rotate 404, unauthenticated write 405 with no file-path leak). apikey-lifecycle-e2e's rotate coverage became a declarative secret-swap test (old plaintext dies immediately, id unchanged). Deleted: seed-vs-admin-characterization-e2e (its own comment scheduled retirement once the seed migration completed), apikey-budget-e2e (tested write-path 400s), the sdk-compat deprecation-header test, and forward-compat's admin strict-write test.

Docs — README, config.example.yaml, crate module docs updated to the read-only story.

Non-goals / follow-ups (filed internally)

  • 3 OpenAPI-parsing validations (mcp validate_spec, HeaderName typing, duplicate tool names) still lack declarative-path enforcement — blocked on a dependency-direction extraction, tracked internally.
  • etcd watch keeps last-good on an invalid PUT while resync drops the row — policy decision tracked internally.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — all green.
  • Real binary, file mode: boots with resources.yaml, GET list serves the file, POST /admin/v1/models → 405 + Allow: GET, rotate → 404, /admin/openapi.json documents no admin writes.
  • Local e2e stack run before push.

Summary by CodeRabbit

  • Changes
    • The Admin API is now read-only for managed resources.
    • Resource listing and detail views remain available through GET requests.
    • Creation, updates, deletion, and API-key rotation through the Admin API are no longer supported.
    • Unsupported write requests return 405 responses, while rotation routes return 404.
    • Manage resources through resources_file reloads or direct etcd writes.
    • Updated configuration, validation guidance, and end-to-end coverage to reflect the read-only administration model.

Copilot AI balanced review requested due to automatic review settings August 10, 2026 05:09
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9acb9ae1-7d4d-42ce-83f0-61de46c03b1a

📥 Commits

Reviewing files that changed from the base of the PR and between f9c10cc and f43eab2.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (25)
  • README.md
  • config.example.yaml
  • crates/aisix-admin/src/a2a_agents_handlers.rs
  • crates/aisix-admin/src/apikeys_handlers.rs
  • crates/aisix-admin/src/cache_policies_handlers.rs
  • crates/aisix-admin/src/guardrails_handlers.rs
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-admin/src/mcp_servers_handlers.rs
  • crates/aisix-admin/src/models_handlers.rs
  • crates/aisix-admin/src/observability_exporters_handlers.rs
  • crates/aisix-admin/src/openapi.rs
  • crates/aisix-admin/src/provider_keys_handlers.rs
  • crates/aisix-admin/src/store.rs
  • crates/aisix-admin/tests/etcd_integration.rs
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-core/src/models/mod.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-server/src/main.rs
  • schemas/README.md
  • tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts
  • tests/e2e/src/cases/config-forward-compat-e2e.test.ts
  • tests/e2e/src/cases/openai-sdk-compat.test.ts
  • tests/e2e/src/cases/smoke.test.ts
  • tests/e2e/src/harness/app.ts
  • tests/e2e/src/harness/seed.ts
💤 Files with no reviewable changes (1)
  • tests/e2e/src/cases/smoke.test.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/aisix-server/src/main.rs
  • config.example.yaml
  • crates/aisix-admin/src/provider_keys_handlers.rs
  • crates/aisix-admin/src/models_handlers.rs
  • schemas/README.md
  • crates/aisix-core/src/bin/dump-schema.rs
  • tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts
  • tests/e2e/src/cases/config-forward-compat-e2e.test.ts
  • crates/aisix-admin/src/apikeys_handlers.rs
  • README.md
  • crates/aisix-admin/tests/etcd_integration.rs
  • crates/aisix-admin/src/lib.rs

📝 Walkthrough

Walkthrough

The admin listener now exposes read-only resource routes. Resource writes use resources_file reloads, direct etcd writes, or declarative seed helpers. Stores, server wiring, tests, and documentation were updated.

Changes

Read-only admin resource surface

Layer / File(s) Summary
Read-only store contracts
crates/aisix-admin/src/store.rs, crates/aisix-admin/src/etcd_store.rs, crates/aisix-admin/src/file_store.rs, crates/aisix-admin/src/error.rs, crates/aisix-admin/src/state.rs
Store traits and implementations now support resource reads only.
GET-only admin routes
crates/aisix-admin/src/lib.rs, crates/aisix-admin/src/*_handlers.rs
Create, update, delete, and API-key rotation routes were removed.
Read-only server wiring
crates/aisix-server/src/main.rs
The server constructs read-only file-backed admin state and removes the file-managed write guard.
Direct etcd setup and integration coverage
crates/aisix-admin/tests/etcd_integration.rs, crates/aisix-admin/src/etcd_store.rs
Tests seed canonical documents directly in etcd and verify read-only behavior.
Declarative E2E flows and documentation
tests/e2e/src/cases/*, tests/e2e/src/harness/*, README.md, config.example.yaml, schemas/README.md
E2E setup and documentation now use declarative resource-management paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EtcdClient
  participant AdminAPI
  participant ConfigLoader
  EtcdClient->>EtcdClient: write canonical resource document
  AdminAPI->>EtcdClient: list/get resource
  EtcdClient-->>AdminAPI: resource document
  ConfigLoader->>EtcdClient: load canonical resource documents
  EtcdClient-->>ConfigLoader: configuration data
Loading

Possibly related PRs

  • api7/aisix#792: Related through direct etcd seeding and readiness migration.
  • api7/aisix#800: Related through removal of Admin API writes and E2E harness changes.
  • api7/aisix#848: Related through A2A and MCP admin-handler changes.

Suggested reviewers: jarvis9443


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security Check ❌ Error Category 1: admin GETs serialize ProviderKey.api_key, McpServer.secret, and A2aAgent.secret; PublicApiKey also returns key_hash without redaction. Return redacted DTOs for every secret-bearing resource, omit key_hash and credential fields, and replace backend-detail error responses with generic messages.
E2e Test Quality Review ⚠️ Warning E2E quality issue: config-forward-compat-e2e.test.ts test 3 deletes yellowKeyId created only by test 1, with no ordering declaration, creating a hidden test dependency. Make each test self-contained by seeding and cleaning its own rows, or explicitly declare and document sequential execution; also keep route-matrix coverage at the process level if all aliases are contractual.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main breaking change: removing the Admin API resource write path.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/remove-admin-write-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/aisix-admin/tests/etcd_integration.rs (1)

383-489: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add the missing a2a_agents canonical document.

The PR retains eight resource kinds, but writes contains seven entries. It omits a2a_agents. Add a valid A2A agent document, assert stats.accepted == 8, and assert snap.a2a_agents.len() == 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-admin/tests/etcd_integration.rs` around lines 383 - 489, Extend
the writes array with a valid a2a_agents canonical document using the existing
seed flow, then update the accepted-entry assertion from 7 to 8. Add a
corresponding snap.a2a_agents length assertion expecting exactly one loaded
agent, leaving the other resource assertions unchanged.
🧹 Nitpick comments (1)
crates/aisix-admin/src/lib.rs (1)

1131-1198: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Read coverage now spans only two of eight resource kinds. Deleting the write handlers also deleted each handler module's test module. The replacement read tests in lib.rs seed only models and api_keys. The other six kinds — provider_keys, guardrails, cache_policies, observability_exporters, mcp_servers, a2a_agents — have 405 write-refusal coverage but no test proving that GET serves a seeded entry. These handler bodies are hand-written per module, not macro-generated, so a wrong store call or a wrong response shape in one of the six would pass CI.

  • crates/aisix-admin/src/lib.rs#L1131-L1198: extend build_seedable_state with seed helpers for the remaining six kinds, then add list and get-by-id assertions for each, mirroring list_models_returns_seeded_entries and get_model_serves_seeded_entry.
  • crates/aisix-admin/src/a2a_agents_handlers.rs#L12: add a test that list_a2a_agents and get_a2a_agent return a seeded A2aAgent, or confirm the new lib.rs tests cover this module.
  • crates/aisix-admin/src/mcp_servers_handlers.rs#L9: add the same list/get coverage for McpServer, or confirm the new lib.rs tests cover this module.

Note that InMemoryStore currently exposes only put_model and put_apikey as #[cfg(test)] helpers, so seeding the other six kinds requires adding matching helpers in crates/aisix-admin/src/store.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-admin/src/lib.rs` around lines 1131 - 1198, Extend
crates/aisix-admin/src/store.rs with cfg(test) put helpers for provider_keys,
guardrails, cache_policies, observability_exporters, mcp_servers, and
a2a_agents, then update build_seedable_state and the tests in
crates/aisix-admin/src/lib.rs#L1131-L1198 to seed each kind and assert both list
and get-by-id responses, mirroring the model tests. Ensure
crates/aisix-admin/src/a2a_agents_handlers.rs#L12 and
crates/aisix-admin/src/mcp_servers_handlers.rs#L9 are covered by these lib.rs
tests; no direct handler changes are required unless coverage cannot exercise
their list/get functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-admin/src/lib.rs`:
- Around line 1200-1230: Remove the stale rotation-coverage comment immediately
before openapi_apikey_schema_excludes_max_budget_usd, and delete the empty
CachePolicy CRUD and Health endpoint section comments. Preserve the
guardrail_payload function, the ObservabilityExporter CRUD comment, and all
surrounding tests and formatting.

In `@crates/aisix-admin/tests/etcd_integration.rs`:
- Around line 317-354: Extend the resource-route assertions in the integration
test to issue both PUT and DELETE requests to /admin/v1/models, asserting each
returns METHOD_NOT_ALLOWED and includes GET in the Allow header. Keep the
existing POST check and final etcd emptiness assertion so all refused write
methods verify that no data is written.

In `@crates/aisix-server/src/main.rs`:
- Around line 898-902: Update the file-source match arm in the admin_store
initialization to bind the second tuple element as Some(_) instead of
Some(path), preserving the existing condition and FileManagedStore construction.

In `@tests/e2e/src/cases/file-resource-source-e2e.test.ts`:
- Around line 179-213: Update the rejected-write assertions in the e2e test to
require the exact Allow header value "GET" instead of merely containing GET,
covering both the authenticated POST/DELETE responses and the unauthenticated
POST response. Preserve the existing status and response-body assertions.

In `@tests/e2e/src/cases/openai-sdk-compat.test.ts`:
- Around line 54-84: After seeding the API key in the test setup, add an
independent readiness poll using the seeded caller credentials against
authenticated GET /v1/models, continuing until it returns 200. Remove the
client.chat.completions.create-based propagation gate and keep that call
exclusively for the SDK chat behavior under test.

---

Outside diff comments:
In `@crates/aisix-admin/tests/etcd_integration.rs`:
- Around line 383-489: Extend the writes array with a valid a2a_agents canonical
document using the existing seed flow, then update the accepted-entry assertion
from 7 to 8. Add a corresponding snap.a2a_agents length assertion expecting
exactly one loaded agent, leaving the other resource assertions unchanged.

---

Nitpick comments:
In `@crates/aisix-admin/src/lib.rs`:
- Around line 1131-1198: Extend crates/aisix-admin/src/store.rs with cfg(test)
put helpers for provider_keys, guardrails, cache_policies,
observability_exporters, mcp_servers, and a2a_agents, then update
build_seedable_state and the tests in crates/aisix-admin/src/lib.rs#L1131-L1198
to seed each kind and assert both list and get-by-id responses, mirroring the
model tests. Ensure crates/aisix-admin/src/a2a_agents_handlers.rs#L12 and
crates/aisix-admin/src/mcp_servers_handlers.rs#L9 are covered by these lib.rs
tests; no direct handler changes are required unless coverage cannot exercise
their list/get functions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f3cea19a-3b55-4ad2-b83e-bc29037270c5

📥 Commits

Reviewing files that changed from the base of the PR and between b77f1a6 and 152c57d.

📒 Files selected for processing (26)
  • README.md
  • config.example.yaml
  • crates/aisix-admin/src/a2a_agents_handlers.rs
  • crates/aisix-admin/src/apikeys_handlers.rs
  • crates/aisix-admin/src/cache_policies_handlers.rs
  • crates/aisix-admin/src/error.rs
  • crates/aisix-admin/src/etcd_store.rs
  • crates/aisix-admin/src/file_store.rs
  • crates/aisix-admin/src/guardrails_handlers.rs
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-admin/src/mcp_servers_handlers.rs
  • crates/aisix-admin/src/models_handlers.rs
  • crates/aisix-admin/src/observability_exporters_handlers.rs
  • crates/aisix-admin/src/openapi.rs
  • crates/aisix-admin/src/provider_keys_handlers.rs
  • crates/aisix-admin/src/state.rs
  • crates/aisix-admin/src/store.rs
  • crates/aisix-admin/tests/etcd_integration.rs
  • crates/aisix-server/src/main.rs
  • tests/e2e/src/cases/apikey-budget-e2e.test.ts
  • tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts
  • tests/e2e/src/cases/config-forward-compat-e2e.test.ts
  • tests/e2e/src/cases/file-resource-source-e2e.test.ts
  • tests/e2e/src/cases/openai-sdk-compat.test.ts
  • tests/e2e/src/cases/seed-vs-admin-characterization-e2e.test.ts
  • tests/e2e/src/harness/admin.ts
💤 Files with no reviewable changes (8)
  • tests/e2e/src/cases/apikey-budget-e2e.test.ts
  • tests/e2e/src/cases/seed-vs-admin-characterization-e2e.test.ts
  • crates/aisix-admin/src/state.rs
  • crates/aisix-admin/src/observability_exporters_handlers.rs
  • crates/aisix-admin/src/cache_policies_handlers.rs
  • crates/aisix-admin/src/models_handlers.rs
  • crates/aisix-admin/src/guardrails_handlers.rs
  • crates/aisix-admin/src/provider_keys_handlers.rs

Comment thread crates/aisix-admin/src/lib.rs Outdated
Comment thread crates/aisix-admin/tests/etcd_integration.rs Outdated
Comment thread crates/aisix-server/src/main.rs
Comment on lines +179 to +213
expect(postRes.status).toBe(405);
expect(postRes.headers.get("allow")).toContain("GET");
await postRes.text();

// The refused write did not change the resource set.
const relist = await fetch(`${app.adminUrl}/admin/v1/models`, { headers: auth });
expect(((await relist.json()) as unknown[]).length).toBe(2);

// DELETE and rotate are covered by the same guard.
const delRes = await fetch(`${app.adminUrl}/admin/v1/models/any-id`, {
method: "DELETE",
headers: auth,
});
expect(delRes.status).toBe(409);
expect(delRes.status).toBe(405);
expect(delRes.headers.get("allow")).toContain("GET");
await delRes.text();

// The rotate route was removed outright (it was POST-only), so the
// path no longer exists at all.
const rotateRes = await fetch(`${app.adminUrl}/admin/v1/api_keys/any-id/rotate`, {
method: "POST",
headers: auth,
});
expect(rotateRes.status).toBe(409);
expect(rotateRes.status).toBe(404);
await rotateRes.text();

// Auth ordering: an UNAUTHENTICATED write still gets 401, and the
// 401 body must not leak the resources-file path (that detail is
// only for authenticated admins).
// Method routing answers before auth: an unauthenticated write gets
// the same 405 — there is no write endpoint left to protect, and
// the 405 body carries no resources-file detail to leak.
const unauthed = await fetch(`${app.adminUrl}/admin/v1/models`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ display_name: "nope" }),
});
expect(unauthed.status).toBe(401);
const unauthedBody = (await unauthed.json()) as { error_msg: string };
expect(unauthedBody.error_msg).not.toContain(app.resourcesPath!);
expect(unauthed.status).toBe(405);
expect((await unauthed.text())).not.toContain(app.resourcesPath!);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the exact Allow header value.

Use toBe("GET") for each rejected write. Add the same assertion for the unauthenticated request. toContain("GET") also accepts an invalid value such as GET, POST.

Based on PR objectives, rejected resource writes must return 405 with Allow: GET.

Proposed test update
-    expect(postRes.headers.get("allow")).toContain("GET");
+    expect(postRes.headers.get("allow")).toBe("GET");
...
-    expect(delRes.headers.get("allow")).toContain("GET");
+    expect(delRes.headers.get("allow")).toBe("GET");
...
     expect(unauthed.status).toBe(405);
+    expect(unauthed.headers.get("allow")).toBe("GET");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(postRes.status).toBe(405);
expect(postRes.headers.get("allow")).toContain("GET");
await postRes.text();
// The refused write did not change the resource set.
const relist = await fetch(`${app.adminUrl}/admin/v1/models`, { headers: auth });
expect(((await relist.json()) as unknown[]).length).toBe(2);
// DELETE and rotate are covered by the same guard.
const delRes = await fetch(`${app.adminUrl}/admin/v1/models/any-id`, {
method: "DELETE",
headers: auth,
});
expect(delRes.status).toBe(409);
expect(delRes.status).toBe(405);
expect(delRes.headers.get("allow")).toContain("GET");
await delRes.text();
// The rotate route was removed outright (it was POST-only), so the
// path no longer exists at all.
const rotateRes = await fetch(`${app.adminUrl}/admin/v1/api_keys/any-id/rotate`, {
method: "POST",
headers: auth,
});
expect(rotateRes.status).toBe(409);
expect(rotateRes.status).toBe(404);
await rotateRes.text();
// Auth ordering: an UNAUTHENTICATED write still gets 401, and the
// 401 body must not leak the resources-file path (that detail is
// only for authenticated admins).
// Method routing answers before auth: an unauthenticated write gets
// the same 405 — there is no write endpoint left to protect, and
// the 405 body carries no resources-file detail to leak.
const unauthed = await fetch(`${app.adminUrl}/admin/v1/models`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ display_name: "nope" }),
});
expect(unauthed.status).toBe(401);
const unauthedBody = (await unauthed.json()) as { error_msg: string };
expect(unauthedBody.error_msg).not.toContain(app.resourcesPath!);
expect(unauthed.status).toBe(405);
expect((await unauthed.text())).not.toContain(app.resourcesPath!);
expect(postRes.status).toBe(405);
expect(postRes.headers.get("allow")).toBe("GET");
await postRes.text();
// The refused write did not change the resource set.
const relist = await fetch(`${app.adminUrl}/admin/v1/models`, { headers: auth });
expect(((await relist.json()) as unknown[]).length).toBe(2);
const delRes = await fetch(`${app.adminUrl}/admin/v1/models/any-id`, {
method: "DELETE",
headers: auth,
});
expect(delRes.status).toBe(405);
expect(delRes.headers.get("allow")).toBe("GET");
await delRes.text();
// The rotate route was removed outright (it was POST-only), so the
// path no longer exists at all.
const rotateRes = await fetch(`${app.adminUrl}/admin/v1/api_keys/any-id/rotate`, {
method: "POST",
headers: auth,
});
expect(rotateRes.status).toBe(404);
await rotateRes.text();
// Method routing answers before auth: an unauthenticated write gets
// the same 405 — there is no write endpoint left to protect, and
// the 405 body carries no resources-file detail to leak.
const unauthed = await fetch(`${app.adminUrl}/admin/v1/models`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ display_name: "nope" }),
});
expect(unauthed.status).toBe(405);
expect(unauthed.headers.get("allow")).toBe("GET");
expect((await unauthed.text())).not.toContain(app.resourcesPath!);
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/file-resource-source-e2e.test.ts` around lines 179 - 213,
Update the rejected-write assertions in the e2e test to require the exact Allow
header value "GET" instead of merely containing GET, covering both the
authenticated POST/DELETE responses and the unauthenticated POST response.
Preserve the existing status and response-body assertions.

Comment thread tests/e2e/src/cases/openai-sdk-compat.test.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Removes Admin API resource writes, leaving read-only resource endpoints and moving management to declarative file or etcd paths.

Changes:

  • Removes write handlers, routes, store operations, rotation, and deprecation middleware.
  • Updates OpenAPI, documentation, and tests for the read-only contract.
  • Migrates test setup to direct etcd seeding.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
README.md Documents the read-only Admin API.
config.example.yaml Clarifies declarative resource management.
crates/aisix-server/src/main.rs Wires read-only admin stores.
crates/aisix-admin/src/lib.rs Removes write routes and middleware.
crates/aisix-admin/src/openapi.rs Removes write operations and schemas.
crates/aisix-admin/src/store.rs Makes ConfigStore read-only.
crates/aisix-admin/src/state.rs Removes file-write guard state.
crates/aisix-admin/src/error.rs Removes write-related errors.
crates/aisix-admin/src/file_store.rs Retains snapshot reads only.
crates/aisix-admin/src/etcd_store.rs Retains etcd reads only.
crates/aisix-admin/src/models_handlers.rs Removes model writes.
crates/aisix-admin/src/apikeys_handlers.rs Removes API-key writes and rotation.
crates/aisix-admin/src/provider_keys_handlers.rs Removes provider-key writes.
crates/aisix-admin/src/guardrails_handlers.rs Removes guardrail writes.
crates/aisix-admin/src/cache_policies_handlers.rs Removes cache-policy writes.
crates/aisix-admin/src/observability_exporters_handlers.rs Removes exporter writes.
crates/aisix-admin/src/mcp_servers_handlers.rs Removes MCP-server writes.
crates/aisix-admin/src/a2a_agents_handlers.rs Removes A2A-agent writes.
crates/aisix-admin/tests/etcd_integration.rs Tests direct-etcd writes and admin reads.
tests/e2e/src/harness/admin.ts Removes Admin API write helpers.
tests/e2e/src/cases/seed-vs-admin-characterization-e2e.test.ts Removes obsolete path comparison tests.
tests/e2e/src/cases/openai-sdk-compat.test.ts Migrates setup to etcd seeding.
tests/e2e/src/cases/file-resource-source-e2e.test.ts Tests file-mode read-only behavior.
tests/e2e/src/cases/config-forward-compat-e2e.test.ts Removes Admin write validation coverage.
tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts Replaces rotation with declarative secret swapping.
tests/e2e/src/cases/apikey-budget-e2e.test.ts Removes obsolete write-path validation test.
Suppressed comments (1)

crates/aisix-admin/src/mcp_servers_handlers.rs:9

  • This removal also eliminates the only production call to aisix_mcp::validate_spec. The file and etcd loaders only run the core JSON Schema, which does not reject specs with zero generatable operations or colliding sanitized tool names, so the newly exclusive declarative paths accept configurations the former write API rejected. Add the semantic validation to both loaders before removing this path.
use aisix_core::McpServer;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/aisix-server/src/main.rs Outdated
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use serde::Serialize;
Comment thread crates/aisix-server/src/main.rs Outdated
Comment on lines 9 to 10
//! replaces the `key` field with a freshly-generated `sk-*` value and
//! bumps the revision, invalidating the old credential.
Comment on lines 8 to 9
//!
//! ids are UUID v4s generated on POST; PUT preserves the existing id.
@@ -38,7 +38,7 @@ const OPENAPI_JSON_BASE: &str = r##"{
"info": {
"title": "AISIX Admin API",
"version": "dev",
"description": "The AISIX Admin API configures an open-source AISIX gateway at runtime. Use it when you operate the gateway directly and need to create or update models, caller API keys, provider credentials, guardrails, cache policies, and observability exporters.\n\nThe write endpoints (POST, PUT, DELETE) are deprecated in favor of declarative configuration: load resources from a `resources_file` (`resources.yaml`) or write them to etcd directly. Write endpoints remain functional, and every mutating response carries a `Deprecation` header (RFC 9745) plus a `Link` header with `rel=\"deprecation\"` pointing at the migration documentation. Read endpoints are not deprecated.\n\nGateways connected to AISIX Cloud do not expose this listener. Configure them through AISIX Cloud."
"description": "The AISIX Admin API is the read-only operational surface of an open-source AISIX gateway: list and inspect the loaded models, caller API keys, provider credentials, guardrails, MCP servers, A2A agents, cache policies, and observability exporters, check per-model upstream health, and drive the playground.\n\nResource write endpoints were removed in favor of declarative configuration: declare resources in a `resources_file` (`resources.yaml`) and reload with SIGHUP, or write them to etcd directly. See the resources file reference at https://docs.api7.ai/ai-gateway/reference/resources-file.\n\nGateways connected to AISIX Cloud do not expose this listener. Configure them through AISIX Cloud."
Comment on lines 4 to 5
//! validate against the JSON schema, reject duplicate names (409),
//! generate a uuid v4 on POST, bump revision on PUT.
Comment on lines 4 to 5
//! validate against the JSON schema, reject duplicate names (409),
//! generate a uuid v4 on POST, bump revision on PUT.
Comment on lines 5 to 6
//! PUT. Additionally rejects a name containing the reserved tool-namespace
//! separator `__`, since the name prefixes the server's tools.
Comment on lines 8 to 9
//! every configuration path rejects an incomplete credential set; the checks
//! below are defense in depth.
@moonming moonming self-assigned this Aug 10, 2026
@moonming

Copy link
Copy Markdown
Collaborator Author

Review triage — every inline comment dispositioned; fixes landed in f9c10cc and e34694b.

Fixed

  • 7 handler module docs rewritten to the read-only contract; stale rotate/CRUD section comments removed (e34694b)
  • OpenAPI Caller API Keys tag no longer says "and key rotation"; path/Entry descriptions no longer claim ids are generated by the Admin API or that update/rotate bumps revisions (f9c10cc, e34694b)
  • main.rs: unused Some(path) binding → Some(_); canonical product name; removed a comment referencing write rejections (e34694b)
  • etcd integration: refused-writes test now covers PUT and DELETE with Allow assertions, and the rotate-absence checks GET the rotate URIs too — POST-only 404 couldn't distinguish a deleted route from the old handler's unknown-id 404 (f9c10cc, e34694b)
  • sdk-compat e2e: readiness gate switched from the SDK chat path to an independent authenticated GET /v1/models probe per the harness gate rules (e34694b)
  • config.example.yaml: dead docs/api-admin.md link and Admin-API management claims replaced with the declarative sources (f9c10cc)

Not adopted, with reasons

  • Assert Allow with toBe("GET"): the real binary answers Allow: GET,HEAD (axum's MethodRouter advertises HEAD alongside GET), so an exact "GET" match fails against actual behavior. toContain("GET") plus the 405 status is the contract; the 405 itself already proves no write method is routed.
  • Duplicate key_hash uniqueness on the direct-etcd path (assert_unique_key removal): real gap, but pre-existing — the admin-side check never guarded direct etcd writes, which existed before this PR and are used by the control plane (which enforces uniqueness org-side) and the file source (which has its own per-kind identity check). Filed internally for loader-side conflict detection with /status/config visibility; tracked as a follow-up rather than blocking the removal.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts (1)

276-301: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use an independent readiness check for key setup.

seedKey gates propagation with a POST /v1/chat/completions request. The rotation and deletion flows also use the chat authorization path for their assertions. A chat failure can stop the test before it checks the key transition.

Seed the caller keys, then verify readiness with GET /v1/models and require 200. Keep chat requests for the actual rotation and revocation assertions.

As per coding guidelines, E2E readiness gates must use an independent condition, and caller API keys must be checked with GET /v1/models returning 200.

Also applies to: 311-312

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts` around lines 276 - 301,
Update the key setup readiness flow around seedKey and the related
rotation/deletion cases to use an independent GET /v1/models request requiring
HTTP 200, rather than POST /v1/chat/completions. Keep chat requests in the
secret-swap and revocation assertions only, so readiness failures cannot mask
key-transition checks.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-core/src/bin/dump-schema.rs`:
- Around line 44-47: Update the comment near the STRICT shape documentation to
qualify unknown-field rejection as applying only where the resource schema is
closed. Preserve the existing distinction between declarative write contracts
and lenient etcd reads, while acknowledging resource-specific exceptions such as
open fields and custom guardrail validation documented in the schema guidance.

In `@tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts`:
- Around line 304-307: Update the combined setup guard in the affected E2E test
to also check that otlp is available, skipping and returning when any shared
setup value—including etcdReachable, app, seed, or otlp—is missing.

---

Outside diff comments:
In `@tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts`:
- Around line 276-301: Update the key setup readiness flow around seedKey and
the related rotation/deletion cases to use an independent GET /v1/models request
requiring HTTP 200, rather than POST /v1/chat/completions. Keep chat requests in
the secret-swap and revocation assertions only, so readiness failures cannot
mask key-transition checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aa61ea23-b8c7-4503-b1c7-e50f6378ee7d

📥 Commits

Reviewing files that changed from the base of the PR and between 152c57d and f9c10cc.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • README.md
  • config.example.yaml
  • crates/aisix-admin/Cargo.toml
  • crates/aisix-admin/src/apikeys_handlers.rs
  • crates/aisix-admin/src/error.rs
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-admin/src/openapi.rs
  • crates/aisix-admin/tests/etcd_integration.rs
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-etcd/src/provider.rs
  • schemas/README.md
  • tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts
💤 Files with no reviewable changes (2)
  • crates/aisix-admin/Cargo.toml
  • crates/aisix-admin/src/error.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • config.example.yaml
  • README.md
  • crates/aisix-admin/src/apikeys_handlers.rs
  • crates/aisix-admin/src/lib.rs

Comment thread crates/aisix-core/src/bin/dump-schema.rs
Comment on lines +304 to 307
if (!etcdReachable || !app || !seed) {
ctx.skip();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Include otlp in the setup guard.

The guard omits otlp. Add it to the combined setup check so the test does not run with incomplete shared setup.

Based on learnings, E2E cases must preserve if (!etcdReachable || !app || !seed || !otlp) { ctx.skip(); return; }.

Proposed fix
-    if (!etcdReachable || !app || !seed) {
+    if (!etcdReachable || !app || !seed || !otlp) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!etcdReachable || !app || !seed) {
ctx.skip();
return;
}
if (!etcdReachable || !app || !seed || !otlp) {
ctx.skip();
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/src/cases/apikey-lifecycle-e2e.test.ts` around lines 304 - 307,
Update the combined setup guard in the affected E2E test to also check that otlp
is available, skipping and returning when any shared setup value—including
etcdReachable, app, seed, or otlp—is missing.

Source: Learnings

The admin listener keeps its read surface (lists/gets for all 8
resource kinds incl. the former apikeys spelling, models/status,
health, OpenAPI + Scalar, playground, livez/readyz); resources are
managed exclusively through the declarative paths — resources_file
(SIGHUP reload) or direct etcd writes.

BREAKING CHANGE: POST/PUT/DELETE on /admin/v1/* answer 405 with
Allow: GET (409 file-managed rejection included); the api-key rotate
route is gone (404) — rotate declaratively by writing the same
resource id with a new key_hash. The published OpenAPI documents no
write operations; write-only component schemas (ApiKeyRequest,
ApiKeyEntry, ApiKeyRotateResponse, DeleteResponse) are removed.

- router: every /admin/v1/* resource route serves get() only; rotate
  routes, file-managed write guard, and RFC 9745 deprecation-header
  middleware deleted; 43 write/rotate/uniqueness handler fns removed
- store: ConfigStore is read-only (16 put_*/delete_* methods and
  StoreError::ReadOnly removed); EtcdConfigStore keeps reads only;
  FileManagedStore::new(snapshot) drops the path param; InMemoryStore
  keeps #[cfg(test)] inherent writes for unit-test seeding
- openapi: 24 write ops + rotate path removed from the base document;
  no-op deprecation-marker pass deleted; unreachable component
  schemas pruned; new gate pins GET-only /admin/v1/* and zero
  deprecated marks
- tests: write-path suites deleted; read tests seed via InMemoryStore;
  new 405/Allow + rotate-404 contract tests; etcd integration tests
  seed via direct etcd writes (the declarative front door) and pin
  that refused writes never touch etcd
- e2e: AdminClient write helpers removed (SeedClient is the write
  front door); file-resource-source pins the new read-only contract;
  apikey-lifecycle rotate coverage became a declarative secret-swap
  test; obsolete write-path cases deleted
- docs: README, config.example.yaml, crate module docs updated
Second-auditor findings on the removal PR, all test/doc-level (no
runtime changes):

- rotate-404 tests now GET the rotate URIs too — POST-only 404 could
  not distinguish a deleted route from the old handler's unknown-id
  404; GET answers 405 on a surviving POST-only route
- e2e: deleting a key's etcd entry revokes an in-use bearer
  (fail-closed, unknown-token 401, other keys unaffected) — the
  deletion branch had lost its only end-to-end proof
- etcd integration: a2a_agents round-trip + loader coverage (7 -> 8
  kinds)
- apikeys read test pins the full PublicApiKey projection
  (allowed_tools/disabled/expires_at), not just the id
- OpenAPI descriptions stop claiming ids are generated by the Admin
  API and revisions increment on update/rotate
- stale write-path narrative removed: config.example.yaml (dead
  docs/api-admin.md link), schemas/README, dump-schema comment,
  apikeys_handlers module doc, aisix-etcd provider doc, README RBAC
  row + e2e counts
- dead code: AdminError::{BadRequest,Conflict,Schema} variants and
  the aisix-mcp/uuid dependencies left over from the write path
Per-comment review triage:

- 7 handler module docs rewritten from CRUD-era text to the surviving
  read-only contract; stale rotate/CRUD section comments in the
  aisix-admin test module removed
- OpenAPI 'Caller API Keys' tag no longer advertises key rotation
- main.rs: unused match binding -> Some(_); canonical product name;
  dropped a comment referencing the removed write-rejection path
- etcd integration: refused-writes test now covers PUT and DELETE
  (405 + Allow), not just POST
- sdk-compat e2e: readiness gate switched from the SDK chat path (the
  behavior under test) to an independent authenticated GET /v1/models
  probe, per the harness gate rules
Cold-audit closeout:

- removed_resource_writes_answer_405_with_allow_get now generates the
  FULL matrix (9 route spellings x POST/PUT/DELETE) instead of a
  sampled subset — a partial revert (e.g. PUT re-added on one {id}
  route) previously passed the whole suite
- last stale write-path narrative: store.rs module doc (read-only
  trait), aisix-core schema.rs/models docs (declarative writers, not
  'Admin API ... 400'), e2e smoke/seed/app/forward-compat headers no
  longer cite deleted characterization or held-back write cases,
  openapi.rs base-doc comment names a surviving schema
Round-two auditor findings:

- OpenAPI: the two remaining Entry revision descriptions (McpServer,
  A2aAgent) stop describing create/update lifecycle; a regression
  assertion now rejects write-lifecycle prose on any documented
  revision field
- schemas/README + schema.rs + dump-schema: scope the strict-contract
  claim to the in-repo writers (aisix validate, file source) — the
  control plane validates its own API schema, a raw direct etcd put
  gets no synchronous validation (lenient read only), and
  unknown-field rejection applies only where a resource closes fields;
  the previous rewrite overclaimed all three
- deletion-revocation e2e: propagation barrier is now a fresh key
  seeded after the delete (later etcd revision), so the revocation
  assertion is a real assertion instead of a gate poll; a regression
  fails the assert, not a 30s timeout
- apikeys projection test seeds rate_limit + allowed_agents and pins
  the list entry's projection too; module doc describes PublicApiKey
  as an explicit allowlist (not 'minus nothing')
- lifecycle prose: secret-swap invalidates 'as soon as the write
  propagates' (not 'immediately'); README e2e counts scoped to
  scenario files (183/496)
@moonming
moonming force-pushed the feat/remove-admin-write-path branch from 74b0db5 to f43eab2 Compare August 11, 2026 01:59
@moonming
moonming merged commit b61d270 into main Aug 11, 2026
30 of 32 checks passed
@moonming
moonming deleted the feat/remove-admin-write-path branch August 11, 2026 02:20
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.

2 participants