AI Connect/How to create/マルチテナントのAI SaaS

マルチテナントのAI SaaS

VinkiusについてAIに質問

B2B プラットフォームのすべての顧客にコネクタ付きの AI を届けましょう。各 external_id をテナントとユーザーでスコープ化し、単一のアプリケーションキーで各組織を分離したまま保ち、テナント単位の制御を追加しても、認証情報が境界を越えて露出することは決してありません。顧客はコネクティビティプラットフォームを手に入れ、土管の工事を渡されることはありません。

あなたのカテゴリーが待ち望んでいた AI SaaS を出荷しましょう。各顧客(テナント)にはユーザーのチームが与えられ、各ユーザーには自分の GitHub、Jira、Slack に到達する自分の AI が与えられます。各テナントはあなたの単一の Application key 上で分離され、各テナントのすべてのユーザーは、初日からの数千の AI 接続に支えられています。 あなたが構築する統合は一つもなく、あなたが保存するトークンは一つもなく、データベースの外に出るアイデンティティも一つもありません。

市場のすべての統合プラットフォームは、この問題がテナントごとの契約、テナントごとの請求書、あるいはエンジニアが何ヶ月もかけて分離を手作業で構築することで終わると言うでしょう。AI Connect SDK の答えは、競合がプロダクトを再設計しない限りコピーできないものです。統合プロジェクトではなく、AI 機能を提供する。このガイドでは、テナントからなるマーケットプレイス全体が単一のアプリケーションキーを共有しながら、境界を決して越えない実現方法を示します。

ここで「ユーザー」とはあなたの顧客の内部にいる個人です。したがって external_id はテナントと人間の両方を運びます。この一つの決断が分離モデルです。これが機能するとき、あなたのプロダクトは、プラットフォーム企業が普通なら何年もかけ、セキュリティチームを擁してようやく約束できることを実現します。すべての組織は島であり、すべてのユーザーはちょうど一つの島の住人であり、あなたは鍵一つで群島全体を統治します。Vinkius はあなたの実ユーザーに決して会いません。 彼らのメールも名前もプロフィールもあなたのデータベースから出ることはなく、プラットフォームが見るのは、あなたのバックエンドが渡すオペークな id だけです。

Vinkiusnever sees your usersYour application keyvk_app_*acmetenant · isolatedusersown toolsglobextenant · isolatedusersown tools404404initechtenant · isolatedusersown tools
Every tenant an island: users are citizens of exactly one island, a cross-tenant attempt is a 404, and your brand faces every customer while Vinkius stays invisible.

分離の契約

  • 単一の Application = あなたの製品。 すべてのテナントは通常、単一の appId を共有します。
  • external_id は住所をエンコードします。 cus_<tenant>_u_<user> が境界であり、ケイパビリティはその内部でのみ解決されます。
  • テナントをまたぐアクセスは 500 ではなく 404 です。 露出した、または誤ってルーティングされた id は他のテナントの接続を読み取れません。Vinkius はスコープ外として拒否します。
  • あなたのユーザーはあなたの管理下にあります。 メールもプロフィールも Vinkius には届きません。渡すのは不透明な id だけです。顧客との関係はあなたの管理下に留まります。

分離は正しく形成された external_id から導出されるため、セキュリティ上重要な入力として扱ってください。常に認証済みのテナントとユーザーのクレームから構築し、リクエストの生データから構築することは絶対にやめ、あるテナントが別のテナントの id を供給することも絶対に許さないでください。詳細な保証モデルについては、認証とスコープセキュリティ を参照してください。

1. テナント内部のユーザーをアドレスする

認証がすでに信頼している 2 つの id から、決定論的で URL セーフな id を組み立てます。

typescript
interface AuthedPrincipal { tenantId: string; userId: string } // あなたの JWT/セッションから

const externalIdFor = (p: AuthedPrincipal) =>
  `cus_${p.tenantId}_u_${p.userId}`; // "cus_acme_u_9f2c"

2. 単一の共有クライアント

typescript
// server/vinkius.ts
import { Vinkius } from '@vinkius/connect';

export const vinkius = new Vinkius({
  appId: process.env.VINKIUS_APP_ID!,
  apiKey: process.env.VINKIUS_APP_KEY!,
  maxRetries: 2,
});

3. 検証済みセッションからアクターを解決する

トークンから principal を導出し、SDK に渡します。externalIdFor がスコープ付けされるため、その下流にあるすべてもスコープ付けされます。

typescript
import { vinkius } from './vinkius';

export async function actorFor(req: Request) {
  const claims = await verifySession(req); // あなたの認証/認可
  if (!claims) throw new Error('unauthenticated');
  return vinkius.user(externalIdFor(claims));
}

4. 各ユーザーに自身のツールを接続させる

二つの企業の二人のユーザーが同じ GitHub 統合を接続すると、完全に別々の接続・認証情報・ケイパビリティが自動的に得られます。

typescript
// POST /connect  { connector, values }
async function connectForUser(claims: AuthedPrincipal, connector: string, values: Record<string, string>) {
  const handle = vinkius.user(externalIdFor(claims)).connector(connector);
  await handle.connect();
  await handle.credentials.set(values);
  return handle.status();
}

5. 共有キー上でのテナント別ポリシー

通常は、プランごとまたはテナントごとに異なるコネクタを提供する必要があります。アクターは名前空間で区切られているため、テナントレベルの設定は SDK の特別な概念なしにユーザーレベルの接続と組み合わせることができます。許可リストをテナントごとにデータベースに保持し、include として渡してください。

typescript
// server/policy.ts
export async function allowedConnectors(tenantId: string): Promise<string[]> {
  // 例:エンタープライズのテナントには 'salesforce' と 'snowflake' を付与
  return await billing.planAllows(tenantId);
}

async function capabilitiesFor(claims: AuthedPrincipal) {
  const allowed = await allowedConnectors(claims.tenantId);
  return vinkius
    .user(externalIdFor(claims))
    .capabilities({ include: allowed }); // 許可されたコネクタのみにファンアウトを削減
}

include はランタイムへの呼び出しの前にファンアウトを削減します。テナントのプランが禁止するコネクタは照会されないため、たとえその接続が存在しても、ユーザーがそれを見ることは決してありません。ポリシーと接続性は明快に両立します。

6. 異なるモデルランタイムに適切なアダプターを使う

異なるテナント(や異なる機能)は、異なるモデル上で動作する場合があります。CapabilitySet はフレームワーク非依存なので、一つのコードパスで全てに対応できます。最後の行で変換してください。

typescript
import { toOpenAITools } from '@vinkius/connect/openai';
import { toAnthropicTools } from '@vinkius/connect/anthropic';
import { toGeminiTools } from '@vinkius/connect/gemini';

const capabilities = await capabilitiesFor(claims);

const toolSpec =
  model === 'openai' ? toOpenAITools(capabilities)
  : model === 'anthropic' ? toAnthropicTools(capabilities)
  : model === 'gemini' ? toGeminiTools(capabilities)
  : capabilities; // 実行用に生のセットを保持する

実行には元の capabilities を保持してください。モデルに渡すのは変換後の定義だけです。アダプターのディスパットヘルパーが、選択されたツールを正しいユーザー接続へ再度ルーティングします。

7. 専用のアプリケーションを要求するエンタープライズのテナント

一部のエンタープライズの顧客は、あなたのキーを共有する代わりに、専用のテナントキーを要求します。それは単に別の Vinkius インスタンスをリクエストごとに選択するだけであり、externalIdFor のロジックは変わりません。

typescript
import { Vinkius } from '@vinkius/connect';

const shared = new Vinkius({
  appId: process.env.VINKIUS_APP_ID!,
  apiKey: process.env.VINKIUS_APP_KEY!,
});

const dedicated = new Map<string, Vinkius>(); // tenantId -> 専用アプリケーション

function clientFor(tenantId: string): Vinkius {
  return dedicated.get(tenantId) ?? shared;
}

8. 「誤ったテナント」のケースを明示的に処理する

多層防御:リクエストが承認できない id を参照する場合、NotFoundError を汎用的な 404 ではなくスコープの失敗として扱ってください。

typescript
import { NotFoundError, AuthError } from '@vinkius/connect';

try {
  await capabilitiesFor(claims);
} catch (error) {
  if (error instanceof AuthError) return respond(401);
  if (error instanceof NotFoundError) return respond(403, 'out of scope'); // テナント間
  throw error;
}

すべてのテナントが信頼する二つの保証

露出したキーも境界は越えられない

あなたのプラットフォームが保持する秘密は単一の vk_app_sk_* のみです。その影響範囲は設計時点で限定されています。露出による侵害は一つのアプリケーションまでであり、そのアプリケーション外部のリソースに触れようとする試みはすべて 404 を返します。他のテナントのデータが露出することも、リソースの存在を確定させる 500 が返ることも決してありません。誤ってルーティングされた external_id は安全側に失敗します。エンタープライズのセキュリティレビューが求めるのはまさにこの性質です。

一つの名前空間付き名前、多数のモデルルール

デフォルトの名前空間 connector__name は、テナントをルーティングし得るすべてのランタイムで有効とは限りません。toGeminiTools はハイフンを即座に拒否します(google-calendar のようなコネクタスラッグは Gemini の ^[a-zA-Z_][a-zA-Z0-9_]*$ ルールを違反します)。また toOpenAITools は名前を 64 文字に制限します。いずれも推論時にプロバイダーの 400 を返すのではなく、変換時に ConfigError をスローします。一度正規化すれば、Gemini 上で動作するテナントも OpenAI 上のテナントと同じくらいスムーズに動作します。

typescript
new Vinkius({
  appId,
  apiKey,
  // 下線のみ・長さ上限あり:OpenAI、Anthropic、Gemini のすべてで有効
  namespaceCapability: (connector, name) =>
    `${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});

本番チェックリスト

  • [ ] external_id は常に認証済みのテナントとユーザーのクレームから組み立て、生入力から組み立てることは決してしない。
  • [ ] プラットフォーム全体で単一の Application キーを維持し、専用 Vinkius インスタンスはそれを要求するテナントにのみ追加する。
  • [ ] capabilities({ include }) でテナント別のプランを強制し、課金テーブルで裏付ける。
  • [ ] 404 = スコープ外という挙動に頼る。他のテナントのリソースを合成することは決して試みない。
  • [ ] モデル呼び出しのときに限りアダプターで変換し、保持しておいた CapabilitySet 上で実行する。
  • [ ] オペレーションごとに idempotencyKey を使い、テナントのリトライが交差したり重複したりしないようにする。

これで、単一の AI プラットフォームを運用できます。すべての顧客と、その内部のすべてのユーザーに、それぞれ分離された接続と認証情報を介して応え、あなた自身のアプリケーションキーとテナント別のポリシーレイヤーで支えます。顧客のアイデンティティがあなたの管理範囲の外に出ることは決してありません。競合はこの能力を金で買うか、何年もかけて構築するしかありません。あなたは Application を作ったその日に手に入れ、その先行者利益は契約するテナントごとに複利で膨らんでいきます。

What you just got

Not a pitch: the properties this build inherits automatically.

Isolation by construction

Connections and capabilities resolve only inside one external_id. No cross-actor leakage is possible, and you wrote none of that enforcement.

Write-only credentials

Your server stores secrets and can read back which fields are configured, never the values. Not your code, the model, or a dashboard can exfiltrate them.

Metered, revocable spend

Every connection owns a vk_live_* token, so cost and revocation are per connection. One call to disconnect() is a complete, auditable stop.

Any model runtime

One CapabilitySet converts to OpenAI, Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Workers AI or neutral JSON Schema. Only the last line changes.

Production safety built in

idempotencyKey, timeoutMs and AbortSignal per call; automatic full-jitter retries on transient failures; typed VinkiusError branches. No bespoke harness.

Enterprise-grade tenancy

One app key, every customer isolated by address; a cross-tenant attempt is a 404. A customer can even get their own Vinkius instance, same code.

Give it to your AI agent

An Agent Skill (SKILL.md) for this build. Preview the first lines below, then copy or download it into your repo under .claude/skills/: Claude Code, Cursor or any Agent-Skills-compatible agent follows it to implement this pattern correctly.

Download SKILL.md6 · Available in your language
What a multi-tenant SaaS inherits, plus the SKILL.md, in your language, for your coding agent.

次のステップ