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
30 changes: 22 additions & 8 deletions crates/aisix-proxy/src/passthrough.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,31 @@ async fn dispatch(
// Find a model for this provider so we can borrow its provider_key.
let provider_lower = provider.to_lowercase();
let all_models = snapshot.models.entries();
let matches_provider = |e: &aisix_core::resource::ResourceEntry<aisix_core::Model>| {
e.value
.provider
.as_deref()
.map(|p| p.eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
};
let provider_has_model = all_models.iter().any(|e| matches_provider(e));
// Enforce the authenticated key's model ACL before lending this
// provider's credentials through generic passthrough: pick the first
// model of the provider the key is actually allowed to access.
// Without this any valid key could reach any configured provider's
// upstream credentials (#449). Mirrors LiteLLM, which enforces the
// key's model access on passthrough when a target is identifiable.
let model_entry = all_models
.into_iter()
.find(|e| {
e.value
.provider
.as_deref()
.map(|p| p.eq_ignore_ascii_case(&provider_lower))
.unwrap_or(false)
})
.find(|e| matches_provider(e) && auth.key().can_access(&e.value.display_name))
.ok_or_else(|| {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
if provider_has_model {
ProxyError::ModelForbidden(format!(
"api key is not authorized for any model of provider `{provider}`"
))
} else {
ProxyError::ModelNotFound(format!("no model found for provider `{provider}`"))
}
})?;

let model = &model_entry.value;
Expand Down
80 changes: 80 additions & 0 deletions tests/e2e/src/cases/passthrough-model-acl-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createHash } from "node:crypto";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
AdminClient,
EtcdClient,
spawnApp,
startOpenAiUpstream,
waitConfigPropagation,
type OpenAiUpstream,
type SpawnedApp,
} from "../harness/index.js";

// E2E: generic passthrough enforces the caller's model ACL (#449).
// Pre-fix, /passthrough/{provider}/* picked the first Model matching the
// provider and lent its credentials to ANY valid API key, regardless of
// the key's allowed_models — so a low-privilege key could reach a
// provider's upstream credentials it was never granted. The gateway now
// requires the key to be allowed to access a model of that provider
// before injecting the provider credential.

const sha = (s: string) => createHash("sha256").update(s).digest("hex");
const ALLOWED = "sk-pt-acl-allowed";
const DENIED = "sk-pt-acl-denied";

describe("passthrough model ACL (#449)", () => {
let app: SpawnedApp | undefined;
let upstream: OpenAiUpstream | undefined;
let etcdReachable = false;

beforeAll(async () => {
etcdReachable = await new EtcdClient().ping();
if (!etcdReachable) return;
upstream = await startOpenAiUpstream({});
app = await spawnApp();
const admin = new AdminClient(app.adminUrl, app.adminKey);
const pk = await admin.createProviderKey({
display_name: "pt-acl-pk",
secret: "sk-openai-mock",
api_base: upstream.baseUrl,
});
await admin.createModel({
display_name: "pt-acl-model",
provider: "openai",
model_name: "gpt-x",
provider_key_id: pk.id,
});
// ALLOWED key may use the openai model; DENIED key may only use an
// unrelated model name (no openai model in its ACL).
await admin.createApiKey({ key_hash: sha(ALLOWED), allowed_models: ["pt-acl-model"] });
await admin.createApiKey({ key_hash: sha(DENIED), allowed_models: ["unrelated-model"] });
});

afterAll(async () => {
await app?.exit();
await upstream?.close();
});

const callPassthrough = (key: string) =>
fetch(`${app!.proxyUrl}/passthrough/openai/v1/files`, {
method: "GET",
headers: { authorization: `Bearer ${key}` },
});

test("key without access to a provider model is rejected (#449)", async (ctx) => {
if (!etcdReachable || !app || !upstream) {
ctx.skip();
return;
}
await waitConfigPropagation(async () => (await callPassthrough(ALLOWED)).ok);

const denied = await callPassthrough(DENIED);
expect(
denied.status,
"key with no openai model in its ACL must not reach openai passthrough creds",
).toBe(403);

const allowed = await callPassthrough(ALLOWED);
expect(allowed.status, "key allowed for an openai model may use openai passthrough").toBe(200);
});
});
Loading