feat(cloud): Azure subscription binding via Service Principal (M2 phase 1)

Sprint 13. Lifts the resource-binding wizard's "Connect cloud account"
step from a disabled "Coming soon" button to a real binding flow,
scoped to Azure for now (AWS / GCP coming soon).

What ships:
  - New AzureCloudBindingSheet — Service Principal credentials form
    (subscription_id / tenant_id / client_id / client_secret + display
    name)
  - Creates a mcp-server ResourceBinding of type 'cloud_account' with
    provider=azure metadata, permission_scope=['azure:read'], status
    flips between 'active' (vault configured) and 'pending' (vault
    not yet wired)
  - Sheet shows a yellow warning when OpenBao isn't configured,
    explaining that client_secret will NOT be persisted server-side
    until vault is online — operators re-enter or rotate the secret
    once vault is up
  - Explicit "what Heicode will / will not do" footer card per
    product docs §13.9 — read-only ARM, never modify without desktop
    approval, never log client_secret
  - Cloud step "Connect" button now opens this sheet (was disabled)
  - Wizard summary description updated: AWS/GCP labelled coming soon
    instead of implying all three providers ship today

Phase 2 (Azure OAuth code flow) + phase 3 (ARM auto-discovery) need
Azure App Registration + OpenBao online first — separate sprints.

i18n localized en + zh.

Verification:
  - tsc --noEmit clean
  - no backend change — leverages existing mcp-server ResourceBinding
    endpoint which already supports cloud_account type

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:53:34 +08:00
co-authored by Claude Opus 4.7
parent cdcc864298
commit da81c57db2
4 changed files with 302 additions and 4 deletions
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Cloud, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
import { createResource } from '@/lib/heicode-mcp'
// M2 phase 1 — Azure cloud account binding via manual Service Principal
// credentials. Phase 2 (real OAuth code flow) and phase 3 (ARM resource
// auto-discovery) require Azure App Registration + OpenBao to be online
// first; until then, operators paste SP creds manually here. The sheet
// is deliberately explicit about the security caveat: when OpenBao
// isn't configured, the client_secret would be stored alongside the
// resource binding, NOT in a vault. Caller sees a yellow warning until
// vault is wired up.
//
// Field map → mcp-server ResourceBinding:
// type = 'cloud_account'
// name = display_name
// external_ref = subscription_id
// metadata = { provider, tenant_id, client_id }
// secret_ref = vault://<TBD> (intentionally empty during phase 1;
// server should refuse to materialise creds until
// OpenBao is wired up)
// constraints = {} (resource group filters land in phase 3)
// permission_scope= ['azure:read'] (read-only ARM access scope)
interface Props {
open: boolean
onOpenChange: (next: boolean) => void
vaultConfigured?: boolean
}
export function AzureCloudBindingSheet({ open, onOpenChange, vaultConfigured }: Props) {
const { t } = useTranslation()
const qc = useQueryClient()
const [form, setForm] = useState({
name: '',
subscription_id: '',
tenant_id: '',
client_id: '',
client_secret: '',
})
const mutation = useMutation({
mutationFn: () =>
createResource({
type: 'cloud_account',
name: form.name.trim(),
external_ref: form.subscription_id.trim(),
metadata: {
provider: 'azure',
tenant_id: form.tenant_id.trim(),
client_id: form.client_id.trim(),
// NOTE: client_secret is NOT included here. We rely on the
// server side to mint a secret_ref via Secret Broker when
// vault is online; until then the binding is created with
// empty secret_ref and a "needs vault" status. Phase 2
// wires up the actual cred-write step.
},
permission_scope: ['azure:read'],
constraints: {},
status: vaultConfigured ? 'active' : 'pending',
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['heicode', 'resources'] })
toast.success(t('Azure subscription bound'))
onOpenChange(false)
setForm({
name: '',
subscription_id: '',
tenant_id: '',
client_id: '',
client_secret: '',
})
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('Failed to bind Azure account'))
},
})
const canSubmit =
form.name.trim() !== '' &&
form.subscription_id.trim() !== '' &&
form.tenant_id.trim() !== '' &&
form.client_id.trim() !== '' &&
form.client_secret.trim() !== ''
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='w-[min(560px,96vw)] sm:max-w-none overflow-y-auto'>
<SheetHeader>
<SheetTitle className='flex items-center gap-2'>
<Cloud className='h-5 w-5 text-primary' />
{t('Connect Azure subscription')}
</SheetTitle>
<SheetDescription>
{t(
'Paste a Service Principal that has at least Reader role on the subscription. Heicode uses it to enumerate cloud resources (VMs, databases, AKS, storage) once auto-discovery lands. AWS and GCP are coming soon.'
)}
</SheetDescription>
</SheetHeader>
{!vaultConfigured && (
<div className='mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs leading-relaxed'>
<p className='flex items-center gap-1.5 font-semibold text-amber-400'>
<AlertTriangle className='h-3.5 w-3.5' />
{t('Secret vault not yet configured')}
</p>
<p className='mt-1.5 text-muted-foreground'>
{t(
'OpenBao is not wired up to the Manager yet (see System settings → Secret vault status). The binding will be created in pending state — the client_secret will NOT be persisted server-side until vault is configured. Re-enter or rotate the secret once vault is online.'
)}
</p>
</div>
)}
<div className='mt-4 grid gap-3 text-sm'>
<div>
<Label htmlFor='az-name'>{t('Display name')}</Label>
<Input
id='az-name'
value={form.name}
onChange={(e) => setForm((v) => ({ ...v, name: e.target.value }))}
placeholder='production-azure'
className='mt-1'
/>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div>
<Label htmlFor='az-sub'>{t('Subscription ID')}</Label>
<Input
id='az-sub'
value={form.subscription_id}
onChange={(e) =>
setForm((v) => ({ ...v, subscription_id: e.target.value }))
}
placeholder='12345678-1234-1234-1234-1234567890ab'
className='mt-1 font-mono text-xs'
/>
</div>
<div>
<Label htmlFor='az-tenant'>{t('Tenant ID')}</Label>
<Input
id='az-tenant'
value={form.tenant_id}
onChange={(e) =>
setForm((v) => ({ ...v, tenant_id: e.target.value }))
}
placeholder='12345678-1234-1234-1234-1234567890ab'
className='mt-1 font-mono text-xs'
/>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div>
<Label htmlFor='az-client'>{t('Client ID (Application ID)')}</Label>
<Input
id='az-client'
value={form.client_id}
onChange={(e) =>
setForm((v) => ({ ...v, client_id: e.target.value }))
}
placeholder='12345678-1234-1234-1234-1234567890ab'
className='mt-1 font-mono text-xs'
/>
</div>
<div>
<Label htmlFor='az-secret'>{t('Client secret')}</Label>
<Input
id='az-secret'
type='password'
value={form.client_secret}
onChange={(e) =>
setForm((v) => ({ ...v, client_secret: e.target.value }))
}
placeholder='********'
className='mt-1 font-mono text-xs'
/>
</div>
</div>
<div className='mt-2 rounded-lg border border-dashed bg-card/40 p-3 text-xs text-muted-foreground'>
<p className='flex items-center gap-1.5 font-medium text-foreground'>
<ShieldCheck className='h-3.5 w-3.5 text-primary' />
{t('What Heicode will and will not do')}
</p>
<ul className='mt-2 space-y-1'>
<li>· {t('Read-only ARM access (Reader role recommended)')}</li>
<li>· {t('Auto-discover VM / database / AKS / storage — coming soon')}</li>
<li>· {t('Never modify resources without explicit approval from the desktop client')}</li>
<li>· {t('Never write the client_secret into Manager logs or audit payloads')}</li>
</ul>
</div>
</div>
<div className='mt-4 flex justify-end gap-2'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={() => mutation.mutate()}
disabled={!canSubmit || mutation.isPending}
>
{mutation.isPending ? t('Binding...') : t('Bind subscription')}
</Button>
</div>
</SheetContent>
</Sheet>
)
}
+30 -2
View File
@@ -62,6 +62,7 @@ import {
type ResourceBinding,
type ResourceType,
} from '@/lib/heicode-mcp'
import { AzureCloudBindingSheet } from './azure-cloud-binding-sheet'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
import {
Sheet,
@@ -1256,6 +1257,10 @@ export function AgnetSKSourcesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [advancedOpen, setAdvancedOpen] = useState(false)
// M2 phase 1 — Azure cloud binding sheet. Separate from advancedOpen
// so the cloud step uses its own provider-specific form instead of
// the generic resource entry sheet (which is meant for git/sk/doc).
const [azureSheetOpen, setAzureSheetOpen] = useState(false)
const [resourceForm, setResourceForm] = useState<{
name: string
type: ResourceType
@@ -1378,11 +1383,11 @@ export function AgnetSKSourcesPage() {
summary:
cloudSources.length > 0
? t('{{n}} cloud resource connected', { n: cloudSources.length })
: t('Authorize Azure / AWS / GCP. Heicode auto-discovers resources.'),
: t('Authorize Azure (AWS / GCP coming soon). Resource auto-discovery later.'),
done: cloudSources.length > 0,
pendingHint:
cloudSources.length === 0
? t('Cloud auto-discovery — coming soon')
? t('Resource auto-discovery — coming soon')
: undefined,
},
{
@@ -1481,6 +1486,19 @@ export function AgnetSKSourcesPage() {
>
{t('Confirm and launch Agnet')}
</Button>
) : step.key === 'cloud' ? (
// M2 phase 1 — open the Azure-specific sheet. Auto-discovery
// ('ARM enumerate') still pending vault wiring; users
// manually paste SP creds for now.
<Button
type='button'
variant={step.done ? 'ghost' : 'default'}
size='sm'
className='shrink-0 rounded-xl'
onClick={() => setAzureSheetOpen(true)}
>
{step.done ? t('Manage') : t('Connect Azure')}
</Button>
) : (
<Button
type='button'
@@ -1744,6 +1762,16 @@ export function AgnetSKSourcesPage() {
skSources={skOrDocSources}
/>
)}
<AzureCloudBindingSheet
open={azureSheetOpen}
onOpenChange={setAzureSheetOpen}
// Vault wiring status comes from the system-settings panel.
// For now we pass `false` so the binding sheet shows the
// "vault not configured" warning. When OpenBao goes live a
// /api/secret-store/status query here flips the flag.
vaultConfigured={false}
/>
</PageSurface>
)
}
+22 -1
View File
@@ -4141,6 +4141,27 @@
"Your account or token lacks the required permission. Contact an administrator to request access.": "Your account or token lacks the required permission. Contact an administrator to request access.",
"Network unreachable": "Network unreachable",
"Could not reach the server. Check your internet connection and the Heicode Manager status, then retry.": "Could not reach the server. Check your internet connection and the Heicode Manager status, then retry.",
"Failed to load": "Failed to load"
"Failed to load": "Failed to load",
"Connect Azure subscription": "Connect Azure subscription",
"Paste a Service Principal that has at least Reader role on the subscription. Heicode uses it to enumerate cloud resources (VMs, databases, AKS, storage) once auto-discovery lands. AWS and GCP are coming soon.": "Paste a Service Principal that has at least Reader role on the subscription. Heicode uses it to enumerate cloud resources (VMs, databases, AKS, storage) once auto-discovery lands. AWS and GCP are coming soon.",
"Secret vault not yet configured": "Secret vault not yet configured",
"OpenBao is not wired up to the Manager yet (see System settings → Secret vault status). The binding will be created in pending state — the client_secret will NOT be persisted server-side until vault is configured. Re-enter or rotate the secret once vault is online.": "OpenBao is not wired up to the Manager yet (see System settings → Secret vault status). The binding will be created in pending state — the client_secret will NOT be persisted server-side until vault is configured. Re-enter or rotate the secret once vault is online.",
"Display name": "Display name",
"Subscription ID": "Subscription ID",
"Tenant ID": "Tenant ID",
"Client ID (Application ID)": "Client ID (Application ID)",
"Client secret": "Client secret",
"What Heicode will and will not do": "What Heicode will and will not do",
"Read-only ARM access (Reader role recommended)": "Read-only ARM access (Reader role recommended)",
"Auto-discover VM / database / AKS / storage — coming soon": "Auto-discover VM / database / AKS / storage — coming soon",
"Never modify resources without explicit approval from the desktop client": "Never modify resources without explicit approval from the desktop client",
"Never write the client_secret into Manager logs or audit payloads": "Never write the client_secret into Manager logs or audit payloads",
"Binding...": "Binding...",
"Bind subscription": "Bind subscription",
"Azure subscription bound": "Azure subscription bound",
"Failed to bind Azure account": "Failed to bind Azure account",
"Connect Azure": "Connect Azure",
"Authorize Azure (AWS / GCP coming soon). Resource auto-discovery later.": "Authorize Azure (AWS / GCP coming soon). Resource auto-discovery later.",
"Resource auto-discovery — coming soon": "Resource auto-discovery — coming soon"
}
}
+22 -1
View File
@@ -4141,6 +4141,27 @@
"Your account or token lacks the required permission. Contact an administrator to request access.": "你的账户或令牌缺少所需的权限。请联系管理员申请。",
"Network unreachable": "网络无法连接",
"Could not reach the server. Check your internet connection and the Heicode Manager status, then retry.": "无法连接服务器。请检查你的网络连接和 Heicode Manager 是否正常,然后重试。",
"Failed to load": "加载失败"
"Failed to load": "加载失败",
"Connect Azure subscription": "绑定 Azure 订阅",
"Paste a Service Principal that has at least Reader role on the subscription. Heicode uses it to enumerate cloud resources (VMs, databases, AKS, storage) once auto-discovery lands. AWS and GCP are coming soon.": "粘贴一个对该订阅至少拥有 Reader 角色的 Service Principal。等资源自动发现功能上线后,Heicode 会用它枚举云资源(虚拟机、数据库、AKS、存储)。AWS 和 GCP 即将支持。",
"Secret vault not yet configured": "密钥保管器尚未配置",
"OpenBao is not wired up to the Manager yet (see System settings → Secret vault status). The binding will be created in pending state — the client_secret will NOT be persisted server-side until vault is configured. Re-enter or rotate the secret once vault is online.": "Manager 还没接上 OpenBao(详见 系统设置 → 密钥保管器状态)。绑定会以 pending 状态创建——在密钥保管器接通之前,client_secret 不会持久化到服务端。等保管器上线后请重新输入或轮换密钥。",
"Display name": "显示名称",
"Subscription ID": "订阅 ID",
"Tenant ID": "租户 ID",
"Client ID (Application ID)": "Client ID(应用 ID)",
"Client secret": "Client secret",
"What Heicode will and will not do": "Heicode 会做什么 / 不会做什么",
"Read-only ARM access (Reader role recommended)": "只读 ARM 访问(推荐 Reader 角色)",
"Auto-discover VM / database / AKS / storage — coming soon": "自动发现 VM / 数据库 / AKS / 存储 — 即将上线",
"Never modify resources without explicit approval from the desktop client": "未经桌面客户端明确审批,不会修改任何资源",
"Never write the client_secret into Manager logs or audit payloads": "不会把 client_secret 写入 Manager 日志或审计记录",
"Binding...": "绑定中...",
"Bind subscription": "绑定订阅",
"Azure subscription bound": "Azure 订阅已绑定",
"Failed to bind Azure account": "Azure 账户绑定失败",
"Connect Azure": "绑定 Azure",
"Authorize Azure (AWS / GCP coming soon). Resource auto-discovery later.": "授权 Azure(AWS / GCP 即将支持)。资源自动发现稍后上线。",
"Resource auto-discovery — coming soon": "资源自动发现 — 即将上线"
}
}