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:
+228
@@ -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
@@ -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
@@ -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
@@ -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": "资源自动发现 — 即将上线"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user