diff --git a/.changeset/0000-sdk-pin-0-17-0.md b/.changeset/0000-sdk-pin-0-17-0.md new file mode 100644 index 0000000..f751a0e --- /dev/null +++ b/.changeset/0000-sdk-pin-0-17-0.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": patch +--- + +Bump `@cdot65/prisma-airs-sdk` to `^0.17.0`. No behavior change — the CLI does not yet touch any surface changed between 0.13.2 and 0.17.0; this unlocks upcoming AI Gateway and Red Team adapter commands. diff --git a/.changeset/0001-aigateway-workspace-read.md b/.changeset/0001-aigateway-workspace-read.md new file mode 100644 index 0000000..c6b6b83 --- /dev/null +++ b/.changeset/0001-aigateway-workspace-read.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Add the `airs aigateway` command group (AI Gateway, new SDK 0.14+ domain): `workspace list` (with `--plane data|admin`, `--status active|archived`, and `--all` to merge tenant-wide active + archived reads) and `workspace get `. New optional config fields `aiGwDataEndpoint`/`aiGwAdminEndpoint`/`aiGwTokenEndpoint` (`PANW_AI_GW_*` env vars; credentials shared with `PANW_MGMT_*`). `airs doctor` now reports AI Gateway reachability, and 403s explain which SCM grant is missing (workspace-scope vs tenant-root admin). diff --git a/.env.example b/.env.example index 1da5e65..fdea66d 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,10 @@ PANW_DLP_ENDPOINT= # PANW_MODEL_SEC_DATA_ENDPOINT= # PANW_MODEL_SEC_MGMT_ENDPOINT= # PANW_MODEL_SEC_TOKEN_ENDPOINT= + +# ── AI Gateway API (endpoint overrides; creds shared with PANW_MGMT_*) ─ +# Data plane (/ai_gw/v2) — scoped workspace reads + telemetry +# PANW_AI_GW_DATA_ENDPOINT= +# Admin plane (/ai_gw/admin/v2) — tenant-wide reads + all writes +# PANW_AI_GW_ADMIN_ENDPOINT= +# PANW_AI_GW_TOKEN_ENDPOINT= diff --git a/CLAUDE.md b/CLAUDE.md index 1addd83..03c8389 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ src/ │ │ ├── completion.ts # airs completion — shell completion scripts │ │ ├── runtime.ts # Runtime scanning + config management + topics (profiles) │ │ ├── redteam.ts # Red team operations (scan, targets CRUD + backup/restore, prompt-sets CRUD, prompts CRUD, properties) +│ │ ├── aigateway.ts # AI Gateway operations (workspace list/get; two-plane routing, --all merge) │ │ └── modelsecurity.ts # Model security operations (groups, rules, rule-instances, scans, labels, pypi-auth) │ ├── bulk-scan-state.ts # Validated item-centric v2 bulk state; atomic 0600 checkpoints for safe resume │ ├── parse-input.ts # Input file parsing — CSV (prompt column) or plain text (line-per-prompt) @@ -130,6 +131,7 @@ src/ │ ├── promptsets.ts # SdkPromptSetService — custom prompt set CRUD via RedTeamClient │ ├── dlp/ # DLP namespace: filtering-profiles, patterns, profiles, dictionaries SDK service wrappers │ ├── redteam.ts # SdkRedTeamService — red team scan CRUD, polling, reports +│ ├── aigateway.ts # SdkAiGatewayService — AI Gateway workspace reads + 403 grant hints │ ├── modelsecurity.ts # SdkModelSecurityService — security groups, rules, scans, labels │ └── types.ts # ScanResult, ProfileTopic, ScanService, ManagementService, PromptSetService, RedTeamService, ModelSecurityService ├── backup/ @@ -322,6 +324,9 @@ See `.env.example` for the full list. Config priority: CLI flags > env vars > `~ |----------|---------|---------| | `PANW_MGMT_ENDPOINT` | SDK default | Management API endpoint | | `PANW_MGMT_TOKEN_ENDPOINT` | SDK default | Management API token endpoint | +| `PANW_AI_GW_DATA_ENDPOINT` | SDK default | AI Gateway data-plane endpoint (`/ai_gw/v2`) | +| `PANW_AI_GW_ADMIN_ENDPOINT` | SDK default | AI Gateway admin-plane endpoint (`/ai_gw/admin/v2`) | +| `PANW_AI_GW_TOKEN_ENDPOINT` | mgmt token endpoint | AI Gateway token endpoint override | | `SCAN_CONCURRENCY` | `5` | Concurrent AIRS scans (1-20) | | `DATA_DIR` | `~/.prisma-airs/runs` | Run state persistence directory | diff --git a/docs-site/docs/cli/aigateway/_category_.json b/docs-site/docs/cli/aigateway/_category_.json new file mode 100644 index 0000000..63ca039 --- /dev/null +++ b/docs-site/docs/cli/aigateway/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "AI Gateway", + "position": 4 +} diff --git a/docs-site/docs/cli/aigateway/workspaces.md b/docs-site/docs/cli/aigateway/workspaces.md new file mode 100644 index 0000000..30145d1 --- /dev/null +++ b/docs-site/docs/cli/aigateway/workspaces.md @@ -0,0 +1,97 @@ +--- +sidebar_label: workspace +--- + +# aigateway workspace + +Manage **AI Gateway workspaces** — the unit almost every other AI Gateway +resource is keyed by. Credentials are shared with the management API +(`PANW_MGMT_*`); only the endpoints are AI Gateway-specific +(`PANW_AI_GW_DATA_ENDPOINT`, `PANW_AI_GW_ADMIN_ENDPOINT`, with +`PANW_AI_GW_TOKEN_ENDPOINT` falling back to `PANW_MGMT_TOKEN_ENDPOINT`). + +:::warning Two planes, and the default hides rows + +The AI Gateway spans two planes with **different SCM role scopes**: + +- **data** (`/ai_gw/v2`) — returns only workspaces your service account holds a + workspace-scope grant on. Needs `view_only_admin` or higher on the + `main_airs_workspace_` scope. +- **admin** (`/ai_gw/admin/v2`) — returns every workspace in the tenant. Needs + an admin role at tenant-root scope. + +A bare `list` is therefore **not** "all workspaces": it shows only *active* +workspaces *you are scoped to*. Use `--plane admin` for the whole tenant and +`--all` to also include archived rows. + +On a 403 the CLI tells you which grant is missing: `errorCode AB03` means the +workspace-scope grant (data plane); otherwise the tenant-root admin grant. Note +SCM's Access Management UI *edits* the existing role row by default — click +**Add Role** so the account ends up with both role rows. + +::: + +### aigateway workspace list + +List workspaces. + +```text +airs aigateway workspace list [options] +``` + +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--plane ` | No | `data` | Plane to read from: `data` (scoped) or `admin` (whole tenant) | +| `--status ` | No | `active` | Filter by lifecycle state: `active` or `archived` | +| `--all` | No | — | Merge active + archived admin-plane reads (whole tenant, both states) | +| `--output ` | No | `pretty` | Output format: pretty, table, csv, json, yaml | + +Without `--status`, archived workspaces are omitted entirely — there is no +single call returning both states, so `--all` merges two admin-plane reads. + +#### Examples + +```bash +airs aigateway workspace list +airs aigateway workspace list --plane admin +airs aigateway workspace list --plane admin --status archived +airs aigateway workspace list --all --output json +``` + +### aigateway workspace get + +Get one workspace by UUID **or** slug, including the settings blocks list rows +do not carry. + +```text +airs aigateway workspace get [options] +``` + +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--plane ` | No | `data` | Plane to read from: `data` (scoped) or `admin` (whole tenant) | +| `--output ` | No | `pretty` | Output format: pretty, json, yaml | + +A workspace outside your workspace scope answers `403 AB03` on the data plane +(not 404) — re-read it with `--plane admin`. An **archived** workspace answers +`404 AB08` for both its UUID and slug on either plane; inspect archived rows +via `list --status archived` instead. + +:::note Status can disagree between endpoints + +`list` reports `active` for workspaces whose `get` reports `null`. The CLI +renders a null status as `unknown` — treat it as unknown, never as inactive, +and prefer the list value. + +::: + +#### Examples + +```bash +airs aigateway workspace get ws-main-a-349e0e +airs aigateway workspace get 16f7e90d-382a-4e78-b577-1b01eb5f8297 --plane admin --output json +``` diff --git a/docs-site/docs/cli/index.md b/docs-site/docs/cli/index.md index 594fb62..fb394c4 100644 --- a/docs-site/docs/cli/index.md +++ b/docs-site/docs/cli/index.md @@ -8,6 +8,7 @@ slug: /cli/ Auto-generated from the `airs` command tree. Every command below lists its synopsis, options, and at least one input/output example. +- [`airs aigateway workspace`](aigateway/workspaces.md) - [`airs model-security groups`](model-security/groups.md) - [`airs model-security install`](model-security/install.md) - [`airs model-security labels`](model-security/labels.md) diff --git a/docs-site/docs/developers/api/functions/loadConfig.md b/docs-site/docs/developers/api/functions/loadConfig.md index 64ab5b9..8e3bde6 100644 --- a/docs-site/docs/developers/api/functions/loadConfig.md +++ b/docs-site/docs/developers/api/functions/loadConfig.md @@ -1,8 +1,8 @@ # Function: loadConfig() -> **loadConfig**(`cliOverrides?`, `configFilePath?`): `Promise`\<\{ `airsApiEndpoint?`: `string`; `airsApiKey?`: `string`; `airsApiToken?`: `string`; `airsNumRetries?`: `number`; `dataDir`: `string`; `dlpEndpoint?`: `string`; `mgmtClientId?`: `string`; `mgmtClientSecret?`: `string`; `mgmtEndpoint?`: `string`; `mgmtTokenEndpoint?`: `string`; `mgmtTsgId?`: `string`; `modelSecDataEndpoint?`: `string`; `modelSecMgmtEndpoint?`: `string`; `modelSecTokenEndpoint?`: `string`; `redTeamDataEndpoint?`: `string`; `redTeamMgmtEndpoint?`: `string`; `redTeamNetworkBrokerEndpoint?`: `string`; `redTeamTokenEndpoint?`: `string`; `scanConcurrency`: `number`; \}\> +> **loadConfig**(`cliOverrides?`, `configFilePath?`): `Promise`\<\{ `aiGwAdminEndpoint?`: `string`; `aiGwDataEndpoint?`: `string`; `aiGwTokenEndpoint?`: `string`; `airsApiEndpoint?`: `string`; `airsApiKey?`: `string`; `airsApiToken?`: `string`; `airsNumRetries?`: `number`; `dataDir`: `string`; `dlpEndpoint?`: `string`; `mgmtClientId?`: `string`; `mgmtClientSecret?`: `string`; `mgmtEndpoint?`: `string`; `mgmtTokenEndpoint?`: `string`; `mgmtTsgId?`: `string`; `modelSecDataEndpoint?`: `string`; `modelSecMgmtEndpoint?`: `string`; `modelSecTokenEndpoint?`: `string`; `redTeamDataEndpoint?`: `string`; `redTeamMgmtEndpoint?`: `string`; `redTeamNetworkBrokerEndpoint?`: `string`; `redTeamTokenEndpoint?`: `string`; `scanConcurrency`: `number`; \}\> -Defined in: [src/config/loader.ts:59](https://github.com/cdot65/prisma-airs-cli/blob/main/src/config/loader.ts#L59) +Defined in: [src/config/loader.ts:62](https://github.com/cdot65/prisma-airs-cli/blob/main/src/config/loader.ts#L62) ## Parameters @@ -16,4 +16,4 @@ Defined in: [src/config/loader.ts:59](https://github.com/cdot65/prisma-airs-cli/ ## Returns -`Promise`\<\{ `airsApiEndpoint?`: `string`; `airsApiKey?`: `string`; `airsApiToken?`: `string`; `airsNumRetries?`: `number`; `dataDir`: `string`; `dlpEndpoint?`: `string`; `mgmtClientId?`: `string`; `mgmtClientSecret?`: `string`; `mgmtEndpoint?`: `string`; `mgmtTokenEndpoint?`: `string`; `mgmtTsgId?`: `string`; `modelSecDataEndpoint?`: `string`; `modelSecMgmtEndpoint?`: `string`; `modelSecTokenEndpoint?`: `string`; `redTeamDataEndpoint?`: `string`; `redTeamMgmtEndpoint?`: `string`; `redTeamNetworkBrokerEndpoint?`: `string`; `redTeamTokenEndpoint?`: `string`; `scanConcurrency`: `number`; \}\> +`Promise`\<\{ `aiGwAdminEndpoint?`: `string`; `aiGwDataEndpoint?`: `string`; `aiGwTokenEndpoint?`: `string`; `airsApiEndpoint?`: `string`; `airsApiKey?`: `string`; `airsApiToken?`: `string`; `airsNumRetries?`: `number`; `dataDir`: `string`; `dlpEndpoint?`: `string`; `mgmtClientId?`: `string`; `mgmtClientSecret?`: `string`; `mgmtEndpoint?`: `string`; `mgmtTokenEndpoint?`: `string`; `mgmtTsgId?`: `string`; `modelSecDataEndpoint?`: `string`; `modelSecMgmtEndpoint?`: `string`; `modelSecTokenEndpoint?`: `string`; `redTeamDataEndpoint?`: `string`; `redTeamMgmtEndpoint?`: `string`; `redTeamNetworkBrokerEndpoint?`: `string`; `redTeamTokenEndpoint?`: `string`; `scanConcurrency`: `number`; \}\> diff --git a/package.json b/package.json index 42b7a96..28793ae 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "license": "MIT", "dependencies": { - "@cdot65/prisma-airs-sdk": "^0.13.2", + "@cdot65/prisma-airs-sdk": "^0.17.0", "@inquirer/prompts": "^8.3.0", "chalk": "^5.6.2", "commander": "^14.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98bf391..c9f5a14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cdot65/prisma-airs-sdk': - specifier: ^0.13.2 - version: 0.13.2 + specifier: ^0.17.0 + version: 0.17.0 '@inquirer/prompts': specifier: ^8.3.0 version: 8.3.0(@types/node@22.19.13) @@ -167,8 +167,8 @@ packages: cpu: [x64] os: [win32] - '@cdot65/prisma-airs-sdk@0.13.2': - resolution: {integrity: sha512-7k585SKHNP1xBpxONK6ptgQeF6Uff1ynkxGBH0X7fUwqEWgQDPogV5eNBu7jNI3g2dMQ8yDXmuC6JiGCEXH5pg==} + '@cdot65/prisma-airs-sdk@0.17.0': + resolution: {integrity: sha512-4ujcg7twRrTTFEEGAhansUHvW7iREFgEtkAB24b/MmnQsWqg8MC9ik72jGgw5SChojdzW2aDXwjC6YYP3dWLog==} engines: {node: '>=18'} '@changesets/apply-release-plan@7.1.1': @@ -2099,7 +2099,7 @@ snapshots: '@biomejs/cli-win32-x64@2.4.5': optional: true - '@cdot65/prisma-airs-sdk@0.13.2': + '@cdot65/prisma-airs-sdk@0.17.0': dependencies: zod: 3.25.76 diff --git a/src/airs/aigateway.ts b/src/airs/aigateway.ts new file mode 100644 index 0000000..93e3f0b --- /dev/null +++ b/src/airs/aigateway.ts @@ -0,0 +1,109 @@ +import { AIGatewayClient, type AIGatewayClientOptions } from '@cdot65/prisma-airs-sdk'; +import type { + AiGatewayService, + AiGatewayWorkspace, + AiGatewayWorkspaceDetail, + AiGatewayWorkspaceGetOptions, + AiGatewayWorkspaceListOptions, +} from './types.js'; + +/** + * `usage_limits`/`rate_limits` are `array | record | null` on the wire — the + * array of policy objects is canonical, but the legacy single-object form is + * still accepted upstream. Normalize everything to an array. + */ +function toLimitArray(value: unknown): Array> { + if (Array.isArray(value)) return value as Array>; + if (value !== null && typeof value === 'object') return [value as Record]; + return []; +} + +/** Normalize an SDK workspace list row into an AiGatewayWorkspace. */ +function normalizeWorkspace(raw: Record): AiGatewayWorkspace { + return { + id: raw.id as string, + slug: raw.slug as string, + name: raw.name as string, + icon: raw.icon as string | null | undefined, + description: raw.description as string | null | undefined, + createdAt: raw.created_at as string | undefined, + lastUpdatedAt: raw.last_updated_at as string | undefined, + isDefault: Boolean(raw.is_default), + status: raw.status as string | null | undefined, + scopeName: raw.scope_name as string | undefined, + }; +} + +/** Normalize an SDK workspace detail into an AiGatewayWorkspaceDetail. */ +function normalizeWorkspaceDetail(raw: Record): AiGatewayWorkspaceDetail { + return { + ...normalizeWorkspace(raw), + defaults: raw.defaults as Record | null | undefined, + usageLimits: toLimitArray(raw.usage_limits), + rateLimits: toLimitArray(raw.rate_limits), + securitySettings: raw.security_settings as Record | undefined, + dataPlaneSecuritySettings: raw.data_plane_security_settings as + | Record + | undefined, + settings: raw.settings as Record | undefined, + }; +} + +/** + * A 403 from the AI Gateway is a grant problem, and which grant depends on the + * plane: `errorCode AB03` means the workspace-scope grant is missing (data + * plane); otherwise the tenant-root admin grant is (admin plane). Returns a + * user-facing hint, or undefined for non-403 errors. + */ +export function aiGatewayGrantHint(err: unknown): string | undefined { + const status = + (err as { status?: number; statusCode?: number })?.status ?? + (err as { statusCode?: number })?.statusCode; + if (status !== 403) return undefined; + const message = err instanceof Error ? err.message : String(err); + const grant = message.includes('AB03') + ? 'the service account is missing a workspace-scope grant (data plane, /ai_gw/v2)' + : 'the service account is missing a tenant-root admin grant (admin plane, /ai_gw/admin/v2)'; + return ( + `${grant}. SCM Access Management edits the existing role row by default — ` + + `use "Add Role" so the account ends up with both role rows, not one row moved.` + ); +} + +/** Service wrapper over the SDK AIGatewayClient. */ +export class SdkAiGatewayService implements AiGatewayService { + private client: AIGatewayClient; + + constructor(opts?: AIGatewayClientOptions) { + this.client = new AIGatewayClient(opts); + } + + async listWorkspaces(options?: AiGatewayWorkspaceListOptions): Promise { + const response = await this.client.workspaces.list(options); + return (response.data as Array>).map(normalizeWorkspace); + } + + async listAllWorkspaces(): Promise { + // No single call returns both states: the API filters to active unless + // asked otherwise, and only the admin plane sees the whole tenant. + const [active, archived] = await Promise.all([ + this.client.workspaces.list({ plane: 'admin' }), + this.client.workspaces.list({ plane: 'admin', status: 'archived' }), + ]); + return [ + ...(active.data as Array>), + ...(archived.data as Array>), + ].map(normalizeWorkspace); + } + + async getWorkspace( + workspaceRef: string, + options?: AiGatewayWorkspaceGetOptions, + ): Promise { + const raw = (await this.client.workspaces.get(workspaceRef, options)) as Record< + string, + unknown + >; + return normalizeWorkspaceDetail(raw); + } +} diff --git a/src/airs/types.ts b/src/airs/types.ts index 389aace..a12fe12 100644 --- a/src/airs/types.ts +++ b/src/airs/types.ts @@ -1275,3 +1275,66 @@ export interface ManagementService { // Scan logs queryScanLogs(opts: ScanLogQueryOptions): Promise; } + +// --------------------------------------------------------------------------- +// AI Gateway +// --------------------------------------------------------------------------- + +/** + * Which plane to route an AI Gateway workspace read through. + * `data` returns only workspaces the service account holds a workspace-scope + * grant on; `admin` returns every workspace in the tenant. + */ +export type AiGatewayPlane = 'data' | 'admin'; + +/** Normalized AI Gateway workspace list row. */ +export interface AiGatewayWorkspace { + id: string; + slug: string; + name: string; + icon?: string | null; + description?: string | null; + createdAt?: string; + lastUpdatedAt?: string; + isDefault: boolean; + /** + * Lifecycle state. `get` can report `null` for a workspace `list` calls + * `active` — treat `null` as "unknown", never "inactive". + */ + status?: string | null; + /** SCM role scope granting data-plane access to this workspace. */ + scopeName?: string; +} + +/** Normalized AI Gateway workspace detail (list row + settings blocks). */ +export interface AiGatewayWorkspaceDetail extends AiGatewayWorkspace { + defaults?: Record | null; + /** Usage-limit policies, always an array (legacy single-object form is wrapped). */ + usageLimits: Array>; + /** Rate-limit policies, always an array (legacy single-object form is wrapped). */ + rateLimits: Array>; + securitySettings?: Record; + dataPlaneSecuritySettings?: Record; + settings?: Record; +} + +export interface AiGatewayWorkspaceListOptions { + plane?: AiGatewayPlane; + /** Omitting this returns active workspaces only — archived rows are hidden. */ + status?: 'active' | 'archived'; +} + +export interface AiGatewayWorkspaceGetOptions { + plane?: AiGatewayPlane; +} + +/** Service interface for AI Gateway operations used by the CLI. */ +export interface AiGatewayService { + listWorkspaces(opts?: AiGatewayWorkspaceListOptions): Promise; + /** Merge an active and an archived admin-plane read — no single call returns both. */ + listAllWorkspaces(): Promise; + getWorkspace( + workspaceRef: string, + opts?: AiGatewayWorkspaceGetOptions, + ): Promise; +} diff --git a/src/cli/commands/aigateway.ts b/src/cli/commands/aigateway.ts new file mode 100644 index 0000000..5d31056 --- /dev/null +++ b/src/cli/commands/aigateway.ts @@ -0,0 +1,121 @@ +import type { Command } from 'commander'; +import { aiGatewayGrantHint, SdkAiGatewayService } from '../../airs/aigateway.js'; +import type { AiGatewayPlane } from '../../airs/types.js'; +import { aiGatewayClientOptions } from '../../config/client-options.js'; +import { loadConfig } from '../../config/loader.js'; +import { examples } from '../examples.js'; +import { + fail, + type OutputFormat, + renderAiGatewayHeader, + renderWorkspaceDetail, + renderWorkspaceList, + ui, + usageError, +} from '../renderer/index.js'; + +/** Create an SdkAiGatewayService from config. */ +async function createService() { + const config = await loadConfig(); + return new SdkAiGatewayService(aiGatewayClientOptions(config)); +} + +/** fail(), prefixed with the grant hint when the error is an AI Gateway 403. */ +function failWithGrantHint(err: unknown): never { + const hint = aiGatewayGrantHint(err); + if (hint) ui.warn(`403: ${hint}`); + fail(err); +} + +function parsePlane(value: string | undefined): AiGatewayPlane | undefined { + if (value === undefined) return undefined; + if (value !== 'data' && value !== 'admin') { + usageError(`Invalid --plane '${value}'. Valid planes: data, admin`); + } + return value; +} + +function parseStatus(value: string | undefined): 'active' | 'archived' | undefined { + if (value === undefined) return undefined; + if (value !== 'active' && value !== 'archived') { + usageError(`Invalid --status '${value}'. Valid statuses: active, archived`); + } + return value; +} + +/** Register the `aigateway` command group. */ +export function registerAiGatewayCommand(program: Command): void { + const aigateway = program.command('aigateway').description('AI Gateway operations'); + + const workspace = aigateway.command('workspace').description('Manage AI Gateway workspaces'); + + workspace + .command('list') + .description('List workspaces (default: active workspaces you are scoped to)') + .option('--plane ', 'Plane to read from: data (scoped) or admin (whole tenant)') + .option('--status ', 'Filter by lifecycle state: active or archived') + .option('--all', 'Merge active + archived admin-plane reads (whole tenant, both states)') + .option('--output ', 'Output format: pretty, table, csv, json, yaml', 'pretty') + .addHelpText( + 'after', + examples( + 'airs aigateway workspace list', + 'airs aigateway workspace list --plane admin', + 'airs aigateway workspace list --plane admin --status archived', + 'airs aigateway workspace list --all --output json', + ), + ) + .action(async (opts) => { + try { + const fmt = opts.output as OutputFormat; + if (fmt === 'pretty') renderAiGatewayHeader(); + const plane = parsePlane(opts.plane); + const status = parseStatus(opts.status); + if (opts.all && (plane !== undefined || status !== undefined)) { + usageError('--all already merges admin-plane active + archived; drop --plane/--status'); + } + const service = await createService(); + const workspaces = opts.all + ? await service.listAllWorkspaces() + : await service.listWorkspaces( + plane !== undefined || status !== undefined ? { plane, status } : undefined, + ); + renderWorkspaceList(workspaces, fmt); + if (fmt === 'pretty' && !opts.all && plane !== 'admin') { + ui.status( + 'Data-plane list shows only active workspaces you are scoped to — use --plane admin or --all for the whole tenant.', + ); + } + } catch (err) { + failWithGrantHint(err); + } + }); + + workspace + .command('get ') + .description('Get one workspace by UUID or slug (includes settings blocks)') + .option('--plane ', 'Plane to read from: data (scoped) or admin (whole tenant)') + .option('--output ', 'Output format: pretty, json, yaml', 'pretty') + .addHelpText( + 'after', + examples( + 'airs aigateway workspace get ws-main-a-349e0e', + 'airs aigateway workspace get 16f7e90d-382a-4e78-b577-1b01eb5f8297 --plane admin --output json', + ), + ) + .action(async (ref: string, opts) => { + try { + const fmt = opts.output as OutputFormat; + if (fmt === 'pretty') renderAiGatewayHeader(); + const plane = parsePlane(opts.plane); + const service = await createService(); + const workspace = await service.getWorkspace( + ref, + plane !== undefined ? { plane } : undefined, + ); + renderWorkspaceDetail(workspace, fmt); + } catch (err) { + failWithGrantHint(err); + } + }); +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 543225c..adc12ba 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -2,8 +2,9 @@ import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { init, Scanner } from '@cdot65/prisma-airs-sdk'; import type { Command } from 'commander'; +import { aiGatewayGrantHint, SdkAiGatewayService } from '../../airs/aigateway.js'; import { SdkManagementService } from '../../airs/management.js'; -import { runtimeInitOptions } from '../../config/client-options.js'; +import { aiGatewayClientOptions, runtimeInitOptions } from '../../config/client-options.js'; import { type ConfigEntry, inspectConfig, @@ -272,6 +273,68 @@ export async function checkManagementAuth( } } +/** + * Check 6: AI Gateway reachability via the cheapest authenticated data-plane + * read (workspace list). Shares management credentials, so it is skipped when + * those are missing. A 403 is a grant problem — surface which grant via + * {@link aiGatewayGrantHint}. + */ +export async function checkAiGatewayApi( + probe: () => Promise, + hasCreds: boolean, + timeoutMs: number = DOCTOR_TIMEOUT_MS, +): Promise { + const name = 'AI Gateway API'; + if (!hasCreds) { + return { + name, + status: 'warn', + detail: 'skipped — management credentials not configured', + hint: 'Set PANW_MGMT_CLIENT_ID, PANW_MGMT_CLIENT_SECRET, PANW_MGMT_TSG_ID', + }; + } + try { + const result = await withTimeout(probe(), timeoutMs); + if (result === TIMED_OUT) { + return { + name, + status: 'fail', + detail: `timed out after ${timeoutMs}ms — network unreachable or endpoint not responding`, + hint: 'Check network connectivity and PANW_AI_GW_DATA_ENDPOINT', + }; + } + return { + name, + status: 'pass', + detail: `endpoint reachable (${result} workspace${result === 1 ? '' : 's'} in scope)`, + }; + } catch (err) { + const status = httpStatus(err); + const message = errMessage(err); + if (status === 403) { + // Permission boundary, not a broken environment: the endpoint answered + // and OAuth succeeded — the account just lacks an AI Gateway grant. + // Warn (exit 0) so preflights don't fail for setups not using the + // AI Gateway, and say exactly which grant is missing. + return { + name, + status: 'warn', + detail: `endpoint reachable, but access denied (HTTP 403): ${message}`, + hint: aiGatewayGrantHint(err), + }; + } + return { + name, + status: 'fail', + detail: + status !== undefined + ? `AI Gateway API error (HTTP ${status}): ${message}` + : `network unreachable: ${message}`, + hint: 'Verify credentials and PANW_AI_GW_DATA_ENDPOINT', + }; + } +} + // --------------------------------------------------------------------------- // Orchestration // --------------------------------------------------------------------------- @@ -284,6 +347,8 @@ export interface DoctorDeps { scannerProbe?: () => Promise; /** Minimal management call returning a count; built from config when omitted. */ mgmtProbe?: () => Promise; + /** Minimal AI Gateway call returning a workspace count; built from config when omitted. */ + aiGwProbe?: () => Promise; timeoutMs?: number; } @@ -316,6 +381,14 @@ async function defaultMgmtProbe(): Promise { return topics.length; } +/** Default AI Gateway probe: data-plane workspace list — smallest authenticated GET. */ +async function defaultAiGwProbe(): Promise { + const config = await loadConfig(); + const service = new SdkAiGatewayService(aiGatewayClientOptions(config)); + const workspaces = await service.listWorkspaces(); + return workspaces.length; +} + /** Run all checks in order. Never throws. */ export async function runDoctor(deps: DoctorDeps = {}): Promise { const configFilePath = deps.configFilePath ?? resolveConfigFilePath(); @@ -346,8 +419,13 @@ export async function runDoctor(deps: DoctorDeps = {}): Promise { mgmtCreds.status === 'pass', timeoutMs, ); + const aiGwApi = await checkAiGatewayApi( + deps.aiGwProbe ?? defaultAiGwProbe, + mgmtCreds.status === 'pass', + timeoutMs, + ); - return [node, configFile, scannerCreds, mgmtCreds, scannerApi, mgmtAuth]; + return [node, configFile, scannerCreds, mgmtCreds, scannerApi, mgmtAuth, aiGwApi]; } /** Exit-code logic: warns are fine, any fail means exit 1. */ diff --git a/src/cli/program.ts b/src/cli/program.ts index 4ae63ed..d9ecb3a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; +import { registerAiGatewayCommand } from './commands/aigateway.js'; import { registerCompletionCommand } from './commands/completion.js'; import { registerConfigCommand } from './commands/config.js'; import { registerDoctorCommand } from './commands/doctor.js'; @@ -48,6 +49,7 @@ export function buildProgram(): Command { registerRuntimeCommand(program); registerRedteamCommand(program); registerModelSecurityCommand(program); + registerAiGatewayCommand(program); registerConfigCommand(program); registerDoctorCommand(program); registerCompletionCommand(program); diff --git a/src/cli/renderer/aigateway.ts b/src/cli/renderer/aigateway.ts new file mode 100644 index 0000000..8e2b81d --- /dev/null +++ b/src/cli/renderer/aigateway.ts @@ -0,0 +1,117 @@ +import chalk from 'chalk'; +import { dump as yamlDump } from 'js-yaml'; +import type { AiGatewayWorkspace, AiGatewayWorkspaceDetail } from '../../airs/types.js'; +import { formatOutput, type OutputFormat } from './common.js'; +import { ui } from './ui.js'; + +/** Standard header for aigateway commands. */ +export function renderAiGatewayHeader(): void { + ui.header('Prisma AIRS — AI Gateway', 'Gateway workspace operations'); +} + +type ChalkFn = (text: string) => string; + +function statusColor(status: string): ChalkFn { + switch (status.toLowerCase()) { + case 'active': + return chalk.green; + case 'archived': + return chalk.yellow; + default: + return chalk.dim; + } +} + +/** + * `get` can report a null status for a workspace `list` calls active — render + * it as "unknown", never as inactive. + */ +function statusLabel(status: string | null | undefined): string { + return status ?? 'unknown'; +} + +/** Render a workspace list in the requested format. */ +export function renderWorkspaceList( + workspaces: AiGatewayWorkspace[], + format: OutputFormat = 'pretty', +): void { + if (workspaces.length === 0) { + ui.emptyList('workspaces'); + return; + } + if (format !== 'pretty') { + const rows = workspaces.map((w) => ({ + id: w.id, + slug: w.slug, + name: w.name, + status: statusLabel(w.status), + isDefault: w.isDefault, + scopeName: w.scopeName ?? '', + })); + console.log( + formatOutput( + rows, + [ + { key: 'id', label: 'ID' }, + { key: 'slug', label: 'Slug' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'isDefault', label: 'Default' }, + { key: 'scopeName', label: 'Scope' }, + ], + format, + ), + ); + return; + } + ui.section('AI Gateway Workspaces:'); + for (const w of workspaces) { + ui.dim(w.id); + const status = statusColor(statusLabel(w.status))(statusLabel(w.status)); + const dflt = w.isDefault ? chalk.cyan(' default') : ''; + console.log(` ${w.name} ${chalk.dim(w.slug)} ${status}${dflt}`); + if (w.scopeName) console.log(` ${chalk.dim(`scope: ${w.scopeName}`)}`); + console.log(); + } +} + +/** Render a workspace detail in the requested format. */ +export function renderWorkspaceDetail( + workspace: AiGatewayWorkspaceDetail, + format: OutputFormat = 'pretty', +): void { + if (format !== 'pretty') { + console.log(format === 'json' ? JSON.stringify(workspace, null, 2) : yamlDump(workspace)); + return; + } + ui.section('Workspace Detail:'); + const pairs: Array<[string, unknown]> = [ + ['ID', workspace.id], + ['Slug', workspace.slug], + ['Name', workspace.name], + ['Status', statusColor(statusLabel(workspace.status))(statusLabel(workspace.status))], + ['Default', workspace.isDefault ? 'yes' : 'no'], + ]; + if (workspace.description != null) pairs.push(['Description', workspace.description]); + if (workspace.scopeName != null) pairs.push(['Scope', workspace.scopeName]); + if (workspace.createdAt != null) pairs.push(['Created', workspace.createdAt]); + if (workspace.lastUpdatedAt != null) pairs.push(['Updated', workspace.lastUpdatedAt]); + ui.keyValue(pairs); + if (workspace.defaults && Object.keys(workspace.defaults).length > 0) { + ui.section('Defaults:'); + console.log(chalk.dim(JSON.stringify(workspace.defaults, null, 2))); + } + if (workspace.usageLimits.length > 0) { + ui.section('Usage Limits:'); + console.log(chalk.dim(JSON.stringify(workspace.usageLimits, null, 2))); + } + if (workspace.rateLimits.length > 0) { + ui.section('Rate Limits:'); + console.log(chalk.dim(JSON.stringify(workspace.rateLimits, null, 2))); + } + if (workspace.securitySettings && Object.keys(workspace.securitySettings).length > 0) { + ui.section('Security Settings:'); + ui.keyValue(Object.entries(workspace.securitySettings).map(([k, v]) => [k, v])); + } + console.log(); +} diff --git a/src/cli/renderer/index.ts b/src/cli/renderer/index.ts index df8d80f..30a0362 100644 --- a/src/cli/renderer/index.ts +++ b/src/cli/renderer/index.ts @@ -1,3 +1,4 @@ +export * from './aigateway.js'; export * from './backup.js'; export * from './common.js'; export * from './dlp.js'; diff --git a/src/config/client-options.ts b/src/config/client-options.ts index c6325df..84079cb 100644 --- a/src/config/client-options.ts +++ b/src/config/client-options.ts @@ -1,4 +1,5 @@ import type { + AIGatewayClientOptions, InitOptions, ModelSecurityClientOptions, RedTeamClientOptions, @@ -28,6 +29,18 @@ export function redTeamClientOptions(config: Config): RedTeamClientOptions { }; } +/** Build AIGatewayClient options from CLI config. Creds are shared with mgmt*. */ +export function aiGatewayClientOptions(config: Config): AIGatewayClientOptions { + return { + clientId: config.mgmtClientId, + clientSecret: config.mgmtClientSecret, + tsgId: config.mgmtTsgId, + dataEndpoint: config.aiGwDataEndpoint, + adminEndpoint: config.aiGwAdminEndpoint, + tokenEndpoint: config.aiGwTokenEndpoint ?? config.mgmtTokenEndpoint, + }; +} + /** Build ModelSecurityClient options from CLI config. Creds are shared with mgmt*. */ export function modelSecurityClientOptions(config: Config): ModelSecurityClientOptions { return { diff --git a/src/config/loader.ts b/src/config/loader.ts index 3655441..b3cff94 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -27,6 +27,9 @@ function fromEnv(): Record { modelSecDataEndpoint: env.PANW_MODEL_SEC_DATA_ENDPOINT, modelSecMgmtEndpoint: env.PANW_MODEL_SEC_MGMT_ENDPOINT, modelSecTokenEndpoint: env.PANW_MODEL_SEC_TOKEN_ENDPOINT, + aiGwDataEndpoint: env.PANW_AI_GW_DATA_ENDPOINT, + aiGwAdminEndpoint: env.PANW_AI_GW_ADMIN_ENDPOINT, + aiGwTokenEndpoint: env.PANW_AI_GW_TOKEN_ENDPOINT, scanConcurrency: env.SCAN_CONCURRENCY, dataDir: env.DATA_DIR, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 1b140f7..d323121 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -26,6 +26,11 @@ export const ConfigSchema = z.object({ modelSecMgmtEndpoint: z.string().optional(), modelSecTokenEndpoint: z.string().optional(), + // AI Gateway (endpoints only; creds shared with mgmt*) + aiGwDataEndpoint: z.string().optional(), + aiGwAdminEndpoint: z.string().optional(), + aiGwTokenEndpoint: z.string().optional(), + // Tuning scanConcurrency: z.coerce.number().int().min(1).max(20).default(5), diff --git a/tests/unit/airs/aigateway.spec.ts b/tests/unit/airs/aigateway.spec.ts new file mode 100644 index 0000000..e163faa --- /dev/null +++ b/tests/unit/airs/aigateway.spec.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { aiGatewayGrantHint, SdkAiGatewayService } from '../../../src/airs/aigateway.js'; + +const mockWorkspacesList = vi.fn(); +const mockWorkspacesGet = vi.fn(); + +function makeMockClient() { + return { + workspaces: { + list: mockWorkspacesList, + get: mockWorkspacesGet, + }, + }; +} + +vi.mock('@cdot65/prisma-airs-sdk', () => ({ + AIGatewayClient: vi.fn().mockImplementation(() => makeMockClient()), +})); + +const listRow = { + id: 'ws-uuid-1', + slug: 'ws-main-a-349e0e', + name: 'Main', + icon: null, + description: null, + created_at: '2026-07-01T00:00:00Z', + last_updated_at: '2026-07-02T00:00:00Z', + is_default: 1, + status: 'active', + scope_name: 'main_airs_workspace_1852583913', + object: 'workspace', +}; + +describe('SdkAiGatewayService', () => { + let service: SdkAiGatewayService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new SdkAiGatewayService(); + }); + + describe('listWorkspaces', () => { + it('normalizes list rows to camelCase with boolean isDefault', async () => { + mockWorkspacesList.mockResolvedValue({ data: [listRow] }); + const workspaces = await service.listWorkspaces(); + expect(mockWorkspacesList).toHaveBeenCalledWith(undefined); + expect(workspaces).toEqual([ + { + id: 'ws-uuid-1', + slug: 'ws-main-a-349e0e', + name: 'Main', + icon: null, + description: null, + createdAt: '2026-07-01T00:00:00Z', + lastUpdatedAt: '2026-07-02T00:00:00Z', + isDefault: true, + status: 'active', + scopeName: 'main_airs_workspace_1852583913', + }, + ]); + }); + + it('passes plane and status through to the SDK', async () => { + mockWorkspacesList.mockResolvedValue({ data: [] }); + await service.listWorkspaces({ plane: 'admin', status: 'archived' }); + expect(mockWorkspacesList).toHaveBeenCalledWith({ plane: 'admin', status: 'archived' }); + }); + + it('normalizes is_default 0 to false', async () => { + mockWorkspacesList.mockResolvedValue({ data: [{ ...listRow, is_default: 0 }] }); + const [ws] = await service.listWorkspaces(); + expect(ws.isDefault).toBe(false); + }); + }); + + describe('listAllWorkspaces', () => { + it('merges an active and an archived admin-plane read', async () => { + mockWorkspacesList + .mockResolvedValueOnce({ data: [listRow] }) + .mockResolvedValueOnce({ data: [{ ...listRow, id: 'ws-uuid-2', status: 'archived' }] }); + const workspaces = await service.listAllWorkspaces(); + expect(mockWorkspacesList).toHaveBeenCalledTimes(2); + expect(mockWorkspacesList).toHaveBeenCalledWith({ plane: 'admin' }); + expect(mockWorkspacesList).toHaveBeenCalledWith({ plane: 'admin', status: 'archived' }); + expect(workspaces.map((w) => w.id)).toEqual(['ws-uuid-1', 'ws-uuid-2']); + expect(workspaces[1].status).toBe('archived'); + }); + }); + + describe('getWorkspace', () => { + const detail = { + id: 'ws-uuid-1', + name: 'Main', + description: null, + created_at: '2026-07-01T00:00:00Z', + last_updated_at: '2026-07-02T00:00:00Z', + is_default: 0, + slug: 'ws-main-a-349e0e', + icon: null, + defaults: { metadata: { env: 'production' } }, + usage_limits: [{ type: 'cost', credit_limit: 10000 }], + rate_limits: null, + security_settings: { membersViewLogs: true }, + status: null, + }; + + it('normalizes detail and preserves null status (unknown, not inactive)', async () => { + mockWorkspacesGet.mockResolvedValue(detail); + const ws = await service.getWorkspace('ws-main-a-349e0e'); + expect(mockWorkspacesGet).toHaveBeenCalledWith('ws-main-a-349e0e', undefined); + expect(ws.status).toBeNull(); + expect(ws.defaults).toEqual({ metadata: { env: 'production' } }); + expect(ws.securitySettings).toEqual({ membersViewLogs: true }); + }); + + it('passes plane through to the SDK', async () => { + mockWorkspacesGet.mockResolvedValue(detail); + await service.getWorkspace('ws-uuid-1', { plane: 'admin' }); + expect(mockWorkspacesGet).toHaveBeenCalledWith('ws-uuid-1', { plane: 'admin' }); + }); + + it('keeps array usage_limits and normalizes null limits to empty arrays', async () => { + mockWorkspacesGet.mockResolvedValue(detail); + const ws = await service.getWorkspace('ws-uuid-1'); + expect(ws.usageLimits).toEqual([{ type: 'cost', credit_limit: 10000 }]); + expect(ws.rateLimits).toEqual([]); + }); + + it('wraps legacy single-object limits into an array', async () => { + mockWorkspacesGet.mockResolvedValue({ + ...detail, + usage_limits: { type: 'cost', credit_limit: 5 }, + rate_limits: { type: 'requests', unit: 'rpm', value: 100 }, + }); + const ws = await service.getWorkspace('ws-uuid-1'); + expect(ws.usageLimits).toEqual([{ type: 'cost', credit_limit: 5 }]); + expect(ws.rateLimits).toEqual([{ type: 'requests', unit: 'rpm', value: 100 }]); + }); + }); +}); + +describe('aiGatewayGrantHint', () => { + it('returns undefined for non-403 errors', () => { + expect( + aiGatewayGrantHint(Object.assign(new Error('boom'), { statusCode: 500 })), + ).toBeUndefined(); + expect(aiGatewayGrantHint(new Error('plain'))).toBeUndefined(); + }); + + it('maps 403 AB03 to the missing workspace-scope grant hint', () => { + const err = Object.assign(new Error('Forbidden: errorCode AB03'), { statusCode: 403 }); + const hint = aiGatewayGrantHint(err); + expect(hint).toContain('workspace-scope'); + expect(hint).toContain('Add Role'); + }); + + it('maps other 403s to the tenant-root admin grant hint', () => { + const err = Object.assign(new Error('Forbidden'), { statusCode: 403 }); + const hint = aiGatewayGrantHint(err); + expect(hint).toContain('tenant-root'); + expect(hint).toContain('Add Role'); + }); +}); diff --git a/tests/unit/cli/aigateway-renderer.spec.ts b/tests/unit/cli/aigateway-renderer.spec.ts new file mode 100644 index 0000000..7d079bc --- /dev/null +++ b/tests/unit/cli/aigateway-renderer.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AiGatewayWorkspace, AiGatewayWorkspaceDetail } from '../../../src/airs/types.js'; +import { renderWorkspaceDetail, renderWorkspaceList } from '../../../src/cli/renderer/aigateway.js'; + +const workspace: AiGatewayWorkspace = { + id: 'ws-uuid-1', + slug: 'ws-main-a-349e0e', + name: 'Main', + icon: null, + description: null, + createdAt: '2026-07-01T00:00:00Z', + lastUpdatedAt: '2026-07-02T00:00:00Z', + isDefault: true, + status: 'active', + scopeName: 'main_airs_workspace_1852583913', +}; + +const detail: AiGatewayWorkspaceDetail = { + ...workspace, + isDefault: false, + status: null, + defaults: { metadata: { env: 'production' } }, + usageLimits: [{ type: 'cost', credit_limit: 10000 }], + rateLimits: [], + securitySettings: { membersViewLogs: true }, +}; + +let logSpy: ReturnType; + +function captured(): string { + return logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); +} + +beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy.mockRestore(); +}); + +describe('renderWorkspaceList', () => { + it('renders json rows with slug, status, and scope', () => { + renderWorkspaceList([workspace], 'json'); + const rows = JSON.parse(captured()); + expect(rows).toHaveLength(1); + expect(rows[0].slug).toBe('ws-main-a-349e0e'); + expect(rows[0].status).toBe('active'); + expect(rows[0].scopeName).toBe('main_airs_workspace_1852583913'); + }); + + it('renders a null status as unknown in pretty output', () => { + renderWorkspaceList([{ ...workspace, status: null }], 'pretty'); + expect(captured()).toContain('unknown'); + }); + + it('renders csv with a header row', () => { + renderWorkspaceList([workspace], 'csv'); + const [header] = captured().split('\n'); + expect(header).toContain('Slug'); + }); +}); + +describe('renderWorkspaceDetail', () => { + it('emits full record as json', () => { + renderWorkspaceDetail(detail, 'json'); + const parsed = JSON.parse(captured()); + expect(parsed.usageLimits).toEqual([{ type: 'cost', credit_limit: 10000 }]); + }); + + it('renders null status as unknown, never inactive', () => { + renderWorkspaceDetail(detail, 'pretty'); + const out = captured(); + expect(out).toContain('unknown'); + expect(out.toLowerCase()).not.toContain('inactive'); + }); +}); diff --git a/tests/unit/cli/doctor.spec.ts b/tests/unit/cli/doctor.spec.ts index d6c28dd..e6a4ad3 100644 --- a/tests/unit/cli/doctor.spec.ts +++ b/tests/unit/cli/doctor.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + checkAiGatewayApi, checkConfigFile, checkManagementAuth, checkManagementCredentials, @@ -214,6 +215,41 @@ describe('doctor command', () => { }); }); + describe('checkAiGatewayApi', () => { + it('warns (skips) when management creds are missing', async () => { + const check = await checkAiGatewayApi(never, false, 50); + expect(check.status).toBe('warn'); + expect(check.detail).toContain('skipped'); + }); + + it('passes when the probe resolves with a workspace count', async () => { + const check = await checkAiGatewayApi(() => Promise.resolve(3), true, 50); + expect(check.status).toBe('pass'); + expect(check.name).toBe('AI Gateway API'); + expect(check.detail).toContain('3'); + }); + + it('warns (not fails) on a 403 — permission boundary, endpoint reachable', async () => { + const err = Object.assign(new Error('Forbidden: errorCode AB03'), { statusCode: 403 }); + const check = await checkAiGatewayApi(() => Promise.reject(err), true, 50); + expect(check.status).toBe('warn'); + expect(check.detail).toContain('reachable'); + expect(check.hint).toContain('workspace-scope'); + }); + + it('still fails on non-403 HTTP errors', async () => { + const err = Object.assign(new Error('boom'), { statusCode: 500 }); + const check = await checkAiGatewayApi(() => Promise.reject(err), true, 50); + expect(check.status).toBe('fail'); + }); + + it('fails when the probe hangs past the timeout', async () => { + const check = await checkAiGatewayApi(never, true, 20); + expect(check.status).toBe('fail'); + expect(check.detail).toContain('timed out'); + }); + }); + describe('runDoctor', () => { let tempDir: string; @@ -225,7 +261,7 @@ describe('doctor command', () => { await rm(tempDir, { recursive: true, force: true }); }); - it('runs all six checks and never throws, even when probes reject', async () => { + it('runs all seven checks and never throws, even when probes reject', async () => { const checks = await runDoctor({ nodeVersion: 'v22.0.0', configFilePath: join(tempDir, 'absent.json'), @@ -238,10 +274,11 @@ describe('doctor command', () => { }), scannerProbe: () => Promise.reject(new TypeError('fetch failed')), mgmtProbe: () => Promise.reject(Object.assign(new Error('boom'), { status: 500 })), + aiGwProbe: () => Promise.reject(Object.assign(new Error('boom'), { status: 500 })), timeoutMs: 50, }); - expect(checks).toHaveLength(6); + expect(checks).toHaveLength(7); expect(checks.map((c) => c.name)).toEqual([ 'Node.js version', 'Config file', @@ -249,6 +286,7 @@ describe('doctor command', () => { 'Management credentials', 'Scanner API', 'Management OAuth', + 'AI Gateway API', ]); for (const c of checks) { expect(['pass', 'warn', 'fail']).toContain(c.status); @@ -263,12 +301,14 @@ describe('doctor command', () => { inspect: async () => inspected(), scannerProbe: never, mgmtProbe: never, + aiGwProbe: never, timeoutMs: 20, }); const byName = Object.fromEntries(checks.map((c) => [c.name, c])); expect(byName['Scanner API'].status).toBe('warn'); expect(byName['Management OAuth'].status).toBe('warn'); + expect(byName['AI Gateway API'].status).toBe('warn'); // creds checks themselves fail expect(byName['Scanner credentials'].status).toBe('fail'); expect(byName['Management credentials'].status).toBe('fail'); @@ -289,6 +329,7 @@ describe('doctor command', () => { }), scannerProbe: () => Promise.resolve([]), mgmtProbe: () => Promise.resolve(2), + aiGwProbe: () => Promise.resolve(1), timeoutMs: 50, }); @@ -302,6 +343,7 @@ describe('doctor command', () => { inspect: async () => inspected(), scannerProbe: never, mgmtProbe: never, + aiGwProbe: never, timeoutMs: 20, }); diff --git a/tests/unit/config/client-options.spec.ts b/tests/unit/config/client-options.spec.ts index af374a3..98cadd0 100644 --- a/tests/unit/config/client-options.spec.ts +++ b/tests/unit/config/client-options.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + aiGatewayClientOptions, modelSecurityClientOptions, redTeamClientOptions, runtimeInitOptions, @@ -96,3 +97,39 @@ describe('modelSecurityClientOptions', () => { expect(modelSecurityClientOptions(config).tokenEndpoint).toBe('https://mgmt-token.example.com'); }); }); + +describe('aiGatewayClientOptions', () => { + it('maps mgmt creds and aiGw* endpoints', () => { + const config = ConfigSchema.parse({ + mgmtClientId: 'cid', + mgmtClientSecret: 'sec', + mgmtTsgId: 'tsg', + aiGwDataEndpoint: 'https://gw-data.example.com/ai_gw/v2', + aiGwAdminEndpoint: 'https://gw-admin.example.com/ai_gw/admin/v2', + aiGwTokenEndpoint: 'https://gw-token.example.com', + }); + expect(aiGatewayClientOptions(config)).toEqual({ + clientId: 'cid', + clientSecret: 'sec', + tsgId: 'tsg', + dataEndpoint: 'https://gw-data.example.com/ai_gw/v2', + adminEndpoint: 'https://gw-admin.example.com/ai_gw/admin/v2', + tokenEndpoint: 'https://gw-token.example.com', + }); + }); + + it('falls back to mgmtTokenEndpoint when aiGwTokenEndpoint unset', () => { + const config = ConfigSchema.parse({ + mgmtTokenEndpoint: 'https://mgmt-token.example.com', + }); + expect(aiGatewayClientOptions(config).tokenEndpoint).toBe('https://mgmt-token.example.com'); + }); + + it('prefers aiGwTokenEndpoint over mgmtTokenEndpoint', () => { + const config = ConfigSchema.parse({ + mgmtTokenEndpoint: 'https://mgmt-token.example.com', + aiGwTokenEndpoint: 'https://gw-token.example.com', + }); + expect(aiGatewayClientOptions(config).tokenEndpoint).toBe('https://gw-token.example.com'); + }); +}); diff --git a/tests/unit/config/loader.spec.ts b/tests/unit/config/loader.spec.ts index 89046f2..bc934fa 100644 --- a/tests/unit/config/loader.spec.ts +++ b/tests/unit/config/loader.spec.ts @@ -29,6 +29,9 @@ describe('loadConfig', () => { vi.stubEnv('PANW_MODEL_SEC_DATA_ENDPOINT', ''); vi.stubEnv('PANW_MODEL_SEC_MGMT_ENDPOINT', ''); vi.stubEnv('PANW_MODEL_SEC_TOKEN_ENDPOINT', ''); + vi.stubEnv('PANW_AI_GW_DATA_ENDPOINT', ''); + vi.stubEnv('PANW_AI_GW_ADMIN_ENDPOINT', ''); + vi.stubEnv('PANW_AI_GW_TOKEN_ENDPOINT', ''); }); afterEach(async () => { @@ -85,7 +88,14 @@ describe('loadConfig', () => { vi.stubEnv('PANW_MODEL_SEC_MGMT_ENDPOINT', 'https://ms-mgmt.example.com'); vi.stubEnv('PANW_MODEL_SEC_TOKEN_ENDPOINT', 'https://ms-token.example.com'); + vi.stubEnv('PANW_AI_GW_DATA_ENDPOINT', 'https://gw-data.example.com/ai_gw/v2'); + vi.stubEnv('PANW_AI_GW_ADMIN_ENDPOINT', 'https://gw-admin.example.com/ai_gw/admin/v2'); + vi.stubEnv('PANW_AI_GW_TOKEN_ENDPOINT', 'https://gw-token.example.com'); + const config = await loadConfig({}, configPath); + expect(config.aiGwDataEndpoint).toBe('https://gw-data.example.com/ai_gw/v2'); + expect(config.aiGwAdminEndpoint).toBe('https://gw-admin.example.com/ai_gw/admin/v2'); + expect(config.aiGwTokenEndpoint).toBe('https://gw-token.example.com'); expect(config.airsApiToken).toBe('tok-env'); expect(config.airsApiEndpoint).toBe('https://airs.example.com'); expect(config.airsNumRetries).toBe(2); diff --git a/tests/unit/config/schema.spec.ts b/tests/unit/config/schema.spec.ts index be66389..5eac5a0 100644 --- a/tests/unit/config/schema.spec.ts +++ b/tests/unit/config/schema.spec.ts @@ -72,4 +72,22 @@ describe('ConfigSchema', () => { expect(config.mgmtClientId).toBe('client-1'); expect(config.mgmtTsgId).toBe('tsg-1'); }); + + it('preserves AI Gateway endpoint fields when provided', () => { + const config = ConfigSchema.parse({ + aiGwDataEndpoint: 'https://gw-data.example.com/ai_gw/v2', + aiGwAdminEndpoint: 'https://gw-admin.example.com/ai_gw/admin/v2', + aiGwTokenEndpoint: 'https://gw-token.example.com', + }); + expect(config.aiGwDataEndpoint).toBe('https://gw-data.example.com/ai_gw/v2'); + expect(config.aiGwAdminEndpoint).toBe('https://gw-admin.example.com/ai_gw/admin/v2'); + expect(config.aiGwTokenEndpoint).toBe('https://gw-token.example.com'); + }); + + it('defaults AI Gateway endpoint fields to undefined (SDK falls back to env/defaults)', () => { + const config = ConfigSchema.parse({}); + expect(config.aiGwDataEndpoint).toBeUndefined(); + expect(config.aiGwAdminEndpoint).toBeUndefined(); + expect(config.aiGwTokenEndpoint).toBeUndefined(); + }); });