wip(console2): agent admin-console IAM/KMS savepoint (proxy+gate+branding, in progress)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Server-gated IAM admin proxy — the ONLY way the browser reaches IAM admin ops.
|
||||
*
|
||||
* The browser holds no IAM credential and never calls IAM directly. It calls this
|
||||
* SAME-ORIGIN route with just its session cookie; the handler resolves the user,
|
||||
* enforces the brand-admin gate (`getAdminGate`: verified @<adminDomain> email AND
|
||||
* IAM admin), then forwards to `${IAM_URL}/v1/iam/<segment>` as the user (a
|
||||
* short-lived user-bound bearer) so IAM enforces tenant isolation from the
|
||||
* verified `owner` claim. The IAM `{status,msg,data,data2}` envelope is returned
|
||||
* verbatim with its status code.
|
||||
*
|
||||
* Least privilege: only an explicit allow-list of admin segments is reachable
|
||||
* (GET reads / POST mutations), and a non-global admin may reference ONLY their
|
||||
* own org — closing the casdoor `GetUsers(owner)` cross-tenant read gap, which is
|
||||
* NOT scoped by the caller server-side.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, iamBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Read segments — reachable via GET only. */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-applications',
|
||||
'get-application',
|
||||
'get-providers',
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
])
|
||||
|
||||
/** Mutation segments — reachable via POST only (JSON body forwarded). */
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
'add-application',
|
||||
'update-application',
|
||||
'delete-application',
|
||||
'add-provider',
|
||||
'update-provider',
|
||||
'delete-provider',
|
||||
])
|
||||
|
||||
/**
|
||||
* Organization objects are owned by casdoor's built-in `admin`, and the org
|
||||
* list/get endpoints scope results to the caller's org server-side — so `admin`
|
||||
* is an acceptable owner THERE. It is never acceptable for tenant data (users,
|
||||
* roles, ...), where the owner must be the caller's own org.
|
||||
*/
|
||||
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
/** The `<owner>` in an `<owner>/<name>` id, or null. */
|
||||
function idOwner(id: string | null): string | null {
|
||||
if (!id) return null
|
||||
const slash = id.indexOf('/')
|
||||
return slash > 0 ? id.slice(0, slash) : id
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-global admin may reference ONLY their own org. casdoor's read endpoints
|
||||
* (GetUsers/GetUser) do not enforce this, so the proxy must — global admin → any
|
||||
* org; org admin → `orgScope`; the `admin`/`built-in` metadata owner is allowed
|
||||
* only on the org list/get endpoints (which scope to the caller themselves).
|
||||
*/
|
||||
function ownerAllowed(segment: string, owner: string | null, gate: AdminGate): boolean {
|
||||
if (!owner) return true
|
||||
if (gate.user.isGlobalAdmin) return true
|
||||
if (ORG_ENDPOINTS.has(segment) && (owner === 'admin' || owner === 'built-in')) return true
|
||||
return owner === gate.orgScope
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, segment: string, allowed: Set<string>): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (!allowed.has(segment)) return notFound()
|
||||
|
||||
const url = req.nextUrl
|
||||
if (!ownerAllowed(segment, url.searchParams.get('owner'), gate)) return forbidden()
|
||||
if (!ownerAllowed(segment, idOwner(url.searchParams.get('id')), gate)) return forbidden()
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(gate.user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (req.method === 'POST') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${iamBaseUrl()}/v1/iam/${segment}${url.search}`, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: `IAM unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path.join('/'), GET_SEGMENTS)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path.join('/'), POST_SEGMENTS)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
|
||||
*
|
||||
* Same trust boundary as the IAM proxy: the browser sends only its session
|
||||
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
|
||||
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
|
||||
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
|
||||
* default; a global admin may target another org with `?org=`.
|
||||
*
|
||||
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
|
||||
* body, and never derives or stores key material — it is a faithful pass-through
|
||||
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
|
||||
* verb + query select the operation:
|
||||
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
|
||||
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
|
||||
* POST {path,name,env,value} → create/upsert → POST .../secrets
|
||||
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
|
||||
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
|
||||
*
|
||||
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
|
||||
* renders as an honest "listing requires kmsd ≥ next release" state.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
|
||||
function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a global admin passes ?org=. */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
const q = req.nextUrl.searchParams.get('org')
|
||||
return gate.user.isGlobalAdmin && q ? q : gate.orgScope
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
const org = orgFor(gate, req)
|
||||
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
|
||||
const q = req.nextUrl.searchParams
|
||||
const name = q.get('name') ?? ''
|
||||
const path = q.get('path') ?? ''
|
||||
const env = q.get('env') ?? ''
|
||||
|
||||
let target: string
|
||||
let body: string | undefined
|
||||
if (req.method === 'GET') {
|
||||
if (name) {
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
const params = new URLSearchParams()
|
||||
const prefix = q.get('prefix')
|
||||
if (prefix) params.set('prefix', prefix)
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}${params.toString() ? `?${params}` : ''}`
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
target = base
|
||||
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'PATCH') {
|
||||
if (!name) return notFound()
|
||||
target = `${base}/${secretRest(path, name)}`
|
||||
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'DELETE') {
|
||||
if (!name) return notFound()
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(gate.user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
init.body = body
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(target, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
// Surface only the transport failure — never the request body/value.
|
||||
return NextResponse.json(
|
||||
{ message: `KMS unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM application create + edit. `name === 'new'` is the create form; any other
|
||||
* name loads the application (`get-application`) and edits its display name +
|
||||
* description. Read-only fields (clientId) are shown for reference. Delete is
|
||||
* available in edit mode. Mirrors the ProviderEditView/ApplicationEditView shape.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, IamAdminApi, type IamApplication } from '~/lib/api'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { newApplication } from './logic'
|
||||
|
||||
export function AppEditView({ org, name, onDone }: { org: string; name: string; onDone: () => void }) {
|
||||
const toast = useToast()
|
||||
const creating = name === 'new'
|
||||
|
||||
const [app, setApp] = useState<IamApplication | null>(creating ? newApplication(org) : null)
|
||||
const [loading, setLoading] = useState(!creating)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) return
|
||||
let live = true
|
||||
setLoading(true)
|
||||
IamAdminApi.application(`admin/${name}`)
|
||||
.then((a) => {
|
||||
if (live) {
|
||||
setApp(a)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load application')
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [name, creating])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
if (error && !app) {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<Text color="$color12">{error}</Text>
|
||||
<Button self="flex-start" onPress={onDone}>
|
||||
Back
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
if (!app) return null
|
||||
|
||||
const a = app
|
||||
const set = (patch: Partial<IamApplication>) => setApp({ ...a, ...patch })
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (creating) {
|
||||
if (!a.name.trim()) {
|
||||
setError('An application name is required.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
await IamAdminApi.addApplication(a)
|
||||
toast.success(`Created ${a.name}`)
|
||||
} else {
|
||||
await IamAdminApi.updateApplication(`admin/${name}`, a)
|
||||
toast.success(`Saved ${a.name}`)
|
||||
}
|
||||
onDone()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : 'Failed to save application'
|
||||
setError(msg)
|
||||
toast.error(creating ? 'Could not create application' : 'Could not save application', msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete application "${a.name}"?`)) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await IamAdminApi.deleteApplication(a)
|
||||
toast.success(`Deleted ${a.name}`)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : 'Failed to delete application'
|
||||
setError(msg)
|
||||
toast.error('Could not delete application', msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={creating ? 'New application' : 'Edit application'}
|
||||
subtitle={creating ? org : a.name}
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button onPress={onDone}>Back</Button>
|
||||
<Button theme="light" disabled={saving} onPress={() => void save()}>
|
||||
{creating ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
{!creating ? (
|
||||
<Button theme="red" icon={<Trash size={15} />} disabled={saving} onPress={() => void remove()}>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
{error ? <Text color="$color12">{error}</Text> : null}
|
||||
|
||||
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
|
||||
<FieldRow label="Name">
|
||||
<FieldText value={a.name} onChange={(v) => set({ name: v })} disabled={!creating} placeholder="my-app" />
|
||||
</FieldRow>
|
||||
<FieldRow label="Display name">
|
||||
<FieldText value={a.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Organization">
|
||||
<FieldText value={a.organization ?? org} onChange={(v) => set({ organization: v })} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Description">
|
||||
<FieldText value={a.description ?? ''} onChange={(v) => set({ description: v })} />
|
||||
</FieldRow>
|
||||
{!creating && a.clientId ? (
|
||||
<FieldRow label="Client ID">
|
||||
<FieldText value={a.clientId} onChange={() => undefined} disabled />
|
||||
</FieldRow>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM organization detail = its MEMBERSHIPS — the org's users with role/isAdmin
|
||||
* and 2FA, read via `get-users?owner=<org>` through the gated proxy (a non-global
|
||||
* admin may only open their own org). Organization create/delete is intentionally
|
||||
* NOT here: the admin proxy allow-lists user/application/provider mutations only,
|
||||
* so org lifecycle stays in the full IAM console (the header deep-link) where it
|
||||
* is a global-admin operation.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Text, XStack } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, IamAdminApi, type IamUser } from '~/lib/api'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { mfaLabel, roleLabel } from './logic'
|
||||
|
||||
const RoleTag = ({ u }: { u: IamUser }) => (
|
||||
<Text
|
||||
fontSize="$2"
|
||||
px="$2"
|
||||
py="$1"
|
||||
rounded="$2"
|
||||
bg={u.isAdmin ? '$color5' : '$color3'}
|
||||
color={u.isAdmin ? '$color12' : '$color11'}
|
||||
>
|
||||
{roleLabel(u)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
const memberColumns: Column<IamUser>[] = [
|
||||
{ key: 'name', header: 'Name', render: (u) => <Text fontSize="$3" fontWeight="600">{u.name}</Text> },
|
||||
{ key: 'email', header: 'Email', render: (u) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{u.email || '—'}</Text> },
|
||||
{ key: 'role', header: 'Role', width: 110, render: (u) => <RoleTag u={u} /> },
|
||||
{ key: 'mfa', header: '2FA', width: 90, render: (u) => <Text fontSize="$3" color="$color11">{mfaLabel(u)}</Text> },
|
||||
]
|
||||
|
||||
type LoadState = { phase: 'loading' } | { phase: 'error'; err: ApiError } | { phase: 'ready'; rows: IamUser[] }
|
||||
|
||||
export function OrgView({ name, onDone }: { name: string; onDone: () => void }) {
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
IamAdminApi.users(name)
|
||||
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
|
||||
.catch((e) => setState({ phase: 'error', err: e instanceof ApiError ? e : new ApiError(String(e)) }))
|
||||
}, [name])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={name}
|
||||
subtitle="Organization memberships"
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button icon={<RefreshCw size={15} />} onPress={load}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onPress={onDone}>Back</Button>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
{state.phase === 'error' ? (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Could not load memberships
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{state.err.message}
|
||||
</Text>
|
||||
<Button size="$2" self="flex-start" onPress={load}>
|
||||
Retry
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={memberColumns}
|
||||
rows={state.phase === 'ready' ? state.rows : []}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={(u) => `${u.owner}/${u.name}`}
|
||||
empty="No members in this organization."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM user create + edit. `name === 'new'` is the create form (name / display
|
||||
* name / email / password); any other name loads the user (`get-user`, incl. the
|
||||
* authoritative MFA fields) and edits role (isAdmin), enabled state (isForbidden),
|
||||
* and display name. Mirrors the ProviderEditView/ApplicationEditView shape; every
|
||||
* mutation reports through the shared toast, every result honest.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, IamAdminApi, type IamUser } from '~/lib/api'
|
||||
import { FieldRow, FieldText, FieldSwitch } from '~/components/ui/Field'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { useToast } from '~/components/ui/Toast'
|
||||
import { newUser, mfaLabel } from './logic'
|
||||
|
||||
export function UserEditView({ org, name, onDone }: { org: string; name: string; onDone: () => void }) {
|
||||
const toast = useToast()
|
||||
const creating = name === 'new'
|
||||
|
||||
const [user, setUser] = useState<IamUser | null>(creating ? newUser(org) : null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(!creating)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) return
|
||||
let live = true
|
||||
setLoading(true)
|
||||
IamAdminApi.getUser(`${org}/${name}`)
|
||||
.then((u) => {
|
||||
if (live) {
|
||||
setUser(u)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load user')
|
||||
})
|
||||
.finally(() => {
|
||||
if (live) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [org, name, creating])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
if (error && !user) {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<Text color="$color12">{error}</Text>
|
||||
<Button self="flex-start" onPress={onDone}>
|
||||
Back
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
if (!user) return null
|
||||
|
||||
const u = user
|
||||
const set = (patch: Partial<IamUser>) => setUser({ ...u, ...patch })
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (creating) {
|
||||
if (!u.name.trim()) {
|
||||
setError('A username is required.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
await IamAdminApi.addUser({ ...u, ...(password ? { password } : {}) })
|
||||
toast.success(`Created ${u.name}`)
|
||||
} else {
|
||||
await IamAdminApi.updateUser(`${org}/${name}`, u)
|
||||
toast.success(`Saved ${u.name}`)
|
||||
}
|
||||
onDone()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : 'Failed to save user'
|
||||
setError(msg)
|
||||
toast.error(creating ? 'Could not create user' : 'Could not save user', msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete user "${u.name}"?`)) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await IamAdminApi.deleteUser(u)
|
||||
toast.success(`Deleted ${u.name}`)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : 'Failed to delete user'
|
||||
setError(msg)
|
||||
toast.error('Could not delete user', msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={creating ? 'New user' : 'Edit user'}
|
||||
subtitle={creating ? org : `${org}/${name}`}
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button onPress={onDone}>Back</Button>
|
||||
<Button theme="light" disabled={saving} onPress={() => void save()}>
|
||||
{creating ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
{!creating ? (
|
||||
<Button theme="red" icon={<Trash size={15} />} disabled={saving} onPress={() => void remove()}>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
{error ? <Text color="$color12">{error}</Text> : null}
|
||||
|
||||
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
|
||||
<FieldRow label="Username">
|
||||
<FieldText value={u.name} onChange={(v) => set({ name: v })} disabled={!creating} placeholder="jane" />
|
||||
</FieldRow>
|
||||
<FieldRow label="Display name">
|
||||
<FieldText value={u.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Email">
|
||||
<FieldText value={u.email ?? ''} onChange={(v) => set({ email: v })} placeholder="jane@example.com" />
|
||||
</FieldRow>
|
||||
{creating ? (
|
||||
<FieldRow label="Password">
|
||||
<FieldText value={password} onChange={setPassword} secure placeholder="Set an initial password" />
|
||||
</FieldRow>
|
||||
) : null}
|
||||
<FieldRow label="Admin role">
|
||||
<FieldSwitch checked={!!u.isAdmin} onChange={(v) => set({ isAdmin: v })} />
|
||||
</FieldRow>
|
||||
{!creating ? (
|
||||
<>
|
||||
<FieldRow label="Enabled">
|
||||
<FieldSwitch checked={!u.isForbidden} onChange={(v) => set({ isForbidden: !v })} />
|
||||
</FieldRow>
|
||||
<FieldRow label="2FA">
|
||||
<Text fontSize="$3" color="$color11" pt="$2">
|
||||
{mfaLabel(u)}
|
||||
</Text>
|
||||
</FieldRow>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Pure IAM admin helpers — new-record templates + display mappings. No I/O, no
|
||||
* React, so the views stay declarative and the labels stay in one place.
|
||||
*/
|
||||
import type { IamUser, IamApplication } from '~/lib/api'
|
||||
|
||||
/** A blank org-scoped user (password is collected separately in the create form). */
|
||||
export function newUser(owner: string): IamUser {
|
||||
return { owner, name: '', displayName: '', email: '', type: 'normal-user', isAdmin: false }
|
||||
}
|
||||
|
||||
/** A blank application — casdoor apps are owned by the built-in `admin`, scoped to the org. */
|
||||
export function newApplication(owner: string): IamApplication {
|
||||
return { owner: 'admin', name: '', displayName: '', organization: owner, description: '' }
|
||||
}
|
||||
|
||||
/** Human-readable 2FA channel — "TOTP" / "SMS" / "Email" / "none". */
|
||||
export function mfaLabel(u: Pick<IamUser, 'preferredMfaType' | 'mfaPhoneEnabled' | 'mfaEmailEnabled'>): string {
|
||||
const t = (u.preferredMfaType || '').toLowerCase()
|
||||
if (t === 'app' || t === 'totp') return 'TOTP'
|
||||
if (t === 'sms') return 'SMS'
|
||||
if (t === 'email') return 'Email'
|
||||
if (u.mfaPhoneEnabled) return 'SMS'
|
||||
if (u.mfaEmailEnabled) return 'Email'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
/** Role label for the Users/Memberships table. */
|
||||
export const roleLabel = (u: Pick<IamUser, 'isAdmin' | 'type'>): string =>
|
||||
u.isAdmin ? 'admin' : u.type || 'member'
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Brand logo for the console chrome — the selected org's logo when it has one
|
||||
* (IAM `organization.logo`), otherwise the Hanzo "H" mark. Resolved once per org
|
||||
* per session (cached) and fails safe to the H mark if IAM can't be read.
|
||||
* Brand logo for the console chrome — TWO orthogonal layers:
|
||||
* 1. default: the host-derived BRAND mark (inline SVG, currentColor) + wordmark.
|
||||
* White-label by hostname — a lux/zoo/pars host NEVER renders the Hanzo mark.
|
||||
* 2. override: the selected org's own logo (IAM `organization.logo`) when set,
|
||||
* resolved once per org per session (cached) and failing safe to the brand
|
||||
* mark if IAM can't be read (e.g. a non-admin session 403s the gated proxy).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text, XStack } from '@hanzo/gui'
|
||||
@@ -11,11 +14,30 @@ import { Text, XStack } from '@hanzo/gui'
|
||||
import { config } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { IamAdminApi } from '~/lib/api'
|
||||
import { HanzoMark } from './HanzoMark'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
|
||||
/** Per-org logo cache for the session ('' = checked, none). */
|
||||
const orgLogoCache = new Map<string, string>()
|
||||
|
||||
/** The host-derived brand mark — inline, build-time-trusted SVG (currentColor). */
|
||||
function BrandMark({ size }: { size: number }) {
|
||||
const brand = getBrand()
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={brand.logoViewBox}
|
||||
role="img"
|
||||
aria-label={brand.brandName}
|
||||
fill="currentColor"
|
||||
style={{ display: 'block', flexShrink: 0 }}
|
||||
// logoContent is a hardcoded constant in src/lib/branding/brands.ts — never
|
||||
// user input — so inlining it as SVG markup is safe.
|
||||
dangerouslySetInnerHTML={{ __html: brand.logoContent }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrandLogo({ size = 22, wordmark = true }: { size?: number; wordmark?: boolean }) {
|
||||
const { account } = useSession()
|
||||
const orgName = account?.organization || account?.owner || config.iamOrgName
|
||||
@@ -42,6 +64,8 @@ export function BrandLogo({ size = 22, wordmark = true }: { size?: number; wordm
|
||||
}
|
||||
}, [orgName])
|
||||
|
||||
const wordmarkText = `${getBrand().brandName.replace(' Cloud', '')} Console`
|
||||
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
{logo ? (
|
||||
@@ -54,11 +78,11 @@ export function BrandLogo({ size = 22, wordmark = true }: { size?: number; wordm
|
||||
style={{ height: size, width: 'auto', maxWidth: size * 5, objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<HanzoMark size={size} />
|
||||
<BrandMark size={size} />
|
||||
)}
|
||||
{wordmark ? (
|
||||
<Text fontWeight="800" fontSize="$5" color="$color12">
|
||||
Console
|
||||
{wordmarkText}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
* IAM admin API) without duplicating the structure.
|
||||
*/
|
||||
import { Button, Card, Text, XStack } from '@hanzo/gui'
|
||||
import { TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
import { TriangleAlert, Lock } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
|
||||
/** Surface-specific overrides for the 404/unauthorized explanations. */
|
||||
export type HonestCopy = { notFound?: string; unauthorized?: string }
|
||||
@@ -46,6 +48,34 @@ export function honestError(err: ApiError, copy: HonestCopy = {}): { title: stri
|
||||
export const asApiError = (e: unknown): ApiError =>
|
||||
e instanceof ApiError ? e : new ApiError(e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** True for the gate's `ApiError('forbidden', 403)` — the operator-access panel. */
|
||||
export const isForbidden = (err: ApiError): boolean => err.status === 403
|
||||
|
||||
/**
|
||||
* The operator-access-required panel — the honest UX on top of the authoritative
|
||||
* server-side admin gate. Shown when the IAM/KMS gated proxies return 403: the
|
||||
* caller is signed in but not authorized for THIS brand's admin console.
|
||||
*/
|
||||
export function OperatorAccessRequired() {
|
||||
const { account } = useSession()
|
||||
const brand = getBrand()
|
||||
const who = account?.email || account?.name || 'This account'
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
|
||||
<XStack gap="$2" items="center">
|
||||
<Lock size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Operator access required
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{who} is not authorized for the {brand.brandName} admin console. This console requires an
|
||||
@{brand.adminDomain} account with an admin role.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** The honest error card — title, explanation, and an optional retry. */
|
||||
export function ErrorState({
|
||||
err,
|
||||
|
||||
+236
-20
@@ -1,20 +1,21 @@
|
||||
/**
|
||||
* Admin API — identity & access, served by Hanzo IAM (casdoor) under `/v1/iam/*`.
|
||||
* Admin API — identity & access (IAM) and secrets (KMS), via the console's OWN
|
||||
* server-gated proxies, NOT the cloud `/v1` backend.
|
||||
*
|
||||
* IAM is the canonical authority for organizations, users, roles, and the audit
|
||||
* trail (HIP-0111). The gateway routes `/v1/iam/*` to it, and it speaks the same
|
||||
* `{status,msg,data,data2}` envelope as the cloud backend, so these go through
|
||||
* the SAME cookie `/v1` client (`getList`) as every other module — one transport.
|
||||
* The browser holds no IAM/KMS credential. Every call here is SAME-ORIGIN to
|
||||
* `/admin/iam/*` or `/admin/kms/*`, sending only the first-party session cookie;
|
||||
* the server route (`app/admin/{iam,kms}/[...path]/route.ts`) enforces the brand
|
||||
* admin gate and forwards to IAM / KMS as the user. IAM speaks the casdoor
|
||||
* `{status,msg,data,data2}` envelope (unwrapped here); KMS speaks plain JSON.
|
||||
*
|
||||
* Tenancy is server-side: the gateway injects the org from the validated session.
|
||||
* We still pass `owner` where casdoor requires it (users/roles are org-scoped;
|
||||
* organizations are owned by the built-in `admin`). If a deployment doesn't proxy
|
||||
* `/v1/iam` yet, the list 404s and the module shows an honest empty state.
|
||||
* Honest errors: a 403 from the gate becomes `ApiError('forbidden', 403)` so the
|
||||
* modules can render the operator-access-required panel; 404 (not routed / no
|
||||
* endpoint yet), 501, and network failures map to typed `ApiError`s as well.
|
||||
*/
|
||||
import { get, getList, idOf } from './client'
|
||||
import { ApiError } from './client'
|
||||
import { listQuery, type ListParams } from './types'
|
||||
|
||||
/** A casdoor organization (`/v1/iam/get-organizations`). */
|
||||
/** A casdoor organization (`get-organizations`). */
|
||||
export type Organization = {
|
||||
owner: string
|
||||
name: string
|
||||
@@ -28,7 +29,7 @@ export type Organization = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** A casdoor user (`/v1/iam/get-users`). */
|
||||
/** A casdoor user (`get-users`), including the MFA surface (`get-user`). */
|
||||
export type IamUser = {
|
||||
owner: string
|
||||
name: string
|
||||
@@ -40,10 +41,37 @@ export type IamUser = {
|
||||
isForbidden?: boolean
|
||||
isDeleted?: boolean
|
||||
type?: string
|
||||
/** Preferred 2FA channel ("", "app", "sms", "email"). */
|
||||
preferredMfaType?: string
|
||||
mfaPhoneEnabled?: boolean
|
||||
mfaEmailEnabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** A casdoor role — the RBAC grant (`/v1/iam/get-roles`). */
|
||||
/** A casdoor application (`get-applications`). */
|
||||
export type IamApplication = {
|
||||
owner: string
|
||||
name: string
|
||||
displayName?: string
|
||||
organization?: string
|
||||
createdTime?: string
|
||||
clientId?: string
|
||||
description?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** A casdoor identity provider (`get-providers`). */
|
||||
export type IamProvider = {
|
||||
owner: string
|
||||
name: string
|
||||
displayName?: string
|
||||
category?: string
|
||||
type?: string
|
||||
createdTime?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** A casdoor role — the RBAC grant (`get-roles`). */
|
||||
export type Role = {
|
||||
owner: string
|
||||
name: string
|
||||
@@ -57,7 +85,7 @@ export type Role = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** A casdoor audit record (`/v1/iam/get-records`). */
|
||||
/** A casdoor audit record (`get-records`). */
|
||||
export type AuditRecord = {
|
||||
id?: string | number
|
||||
owner?: string
|
||||
@@ -77,21 +105,209 @@ export type Paged<T> = { rows: T[]; total: number }
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined | null>
|
||||
|
||||
/** `?a=b&c=d` for a query map, skipping undefined/null. '' when empty. */
|
||||
function qs(query?: Query): string {
|
||||
if (!query) return ''
|
||||
const sp = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) sp.set(k, String(v))
|
||||
}
|
||||
const s = sp.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
// ── IAM admin (casdoor envelope over /admin/iam/*) ───────────────────────────
|
||||
|
||||
type Envelope<T> = { status?: string; msg?: string; data?: T; data2?: unknown }
|
||||
|
||||
async function iamReq<T>(
|
||||
method: 'GET' | 'POST',
|
||||
segment: string,
|
||||
opts: { query?: Query; body?: unknown } = {},
|
||||
): Promise<Envelope<T>> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/admin/iam/${segment}${qs(opts.query)}`, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: opts.body !== undefined
|
||||
? { 'Content-Type': 'application/json', Accept: 'application/json' }
|
||||
: { Accept: 'application/json' },
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new ApiError(e instanceof Error ? e.message : 'Network request failed')
|
||||
}
|
||||
if (res.status === 403) throw new ApiError('forbidden', 403)
|
||||
const json = (await res.json().catch(() => null)) as Envelope<T> | null
|
||||
if (!res.ok || !json || json.status !== 'ok') {
|
||||
throw new ApiError(json?.msg || `Request failed (HTTP ${res.status})`, res.status)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
async function iamList<T>(segment: string, query: Query): Promise<Paged<T>> {
|
||||
const r = await iamReq<T[]>('GET', segment, { query })
|
||||
const rows = Array.isArray(r.data) ? r.data : []
|
||||
const total = typeof r.data2 === 'number' ? r.data2 : rows.length
|
||||
return { rows, total }
|
||||
}
|
||||
|
||||
async function iamOne<T>(segment: string, query: Query): Promise<T> {
|
||||
const r = await iamReq<T>('GET', segment, { query })
|
||||
if (r.data === undefined || r.data === null) throw new ApiError('Not found', 404)
|
||||
return r.data
|
||||
}
|
||||
|
||||
const iamMutate = (segment: string, body: unknown, query?: Query): Promise<void> =>
|
||||
iamReq<unknown>('POST', segment, { body, query }).then(() => undefined)
|
||||
|
||||
export const IamAdminApi = {
|
||||
/** Organizations are owned by the built-in `admin` account in casdoor. */
|
||||
/** Organizations are owned by the built-in `admin`; casdoor scopes to the caller. */
|
||||
organizations: (params: ListParams = {}): Promise<Paged<Organization>> =>
|
||||
getList<Organization[]>('iam/get-organizations', listQuery({ owner: 'admin', pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
iamList<Organization>('get-organizations', listQuery({ owner: 'admin', pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
/** A single organization by name (casdoor orgs are owned by `admin`). */
|
||||
organization: (name: string): Promise<Organization> =>
|
||||
get<Organization>('iam/get-organization', { id: idOf('admin', name) }),
|
||||
iamOne<Organization>('get-organization', { id: `admin/${name}` }),
|
||||
|
||||
users: (owner: string, params: ListParams = {}): Promise<Paged<IamUser>> =>
|
||||
getList<IamUser[]>('iam/get-users', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
iamList<IamUser>('get-users', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
/** A single user by id (`owner/name`) — includes the MFA fields. */
|
||||
getUser: (id: string): Promise<IamUser> => iamOne<IamUser>('get-user', { id }),
|
||||
|
||||
applications: (owner: string, params: ListParams = {}): Promise<Paged<IamApplication>> =>
|
||||
iamList<IamApplication>('get-applications', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
application: (id: string): Promise<IamApplication> =>
|
||||
iamOne<IamApplication>('get-application', { id }),
|
||||
|
||||
providers: (owner: string, params: ListParams = {}): Promise<Paged<IamProvider>> =>
|
||||
iamList<IamProvider>('get-providers', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
roles: (owner: string, params: ListParams = {}): Promise<Paged<Role>> =>
|
||||
getList<Role[]>('iam/get-roles', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
iamList<Role>('get-roles', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
records: (owner: string, params: ListParams = {}): Promise<Paged<AuditRecord>> =>
|
||||
getList<AuditRecord[]>('iam/get-records', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
iamList<AuditRecord>('get-records', listQuery({ owner, pageSize: DEFAULT_PAGE_SIZE, ...params })),
|
||||
|
||||
// Mutations — casdoor takes the object as the JSON body; updates take `?id`.
|
||||
addUser: (user: IamUser): Promise<void> => iamMutate('add-user', user),
|
||||
updateUser: (id: string, user: IamUser): Promise<void> => iamMutate('update-user', user, { id }),
|
||||
deleteUser: (user: IamUser): Promise<void> => iamMutate('delete-user', user),
|
||||
|
||||
addApplication: (app: IamApplication): Promise<void> => iamMutate('add-application', app),
|
||||
updateApplication: (id: string, app: IamApplication): Promise<void> => iamMutate('update-application', app, { id }),
|
||||
deleteApplication: (app: IamApplication): Promise<void> => iamMutate('delete-application', app),
|
||||
|
||||
addProvider: (p: IamProvider): Promise<void> => iamMutate('add-provider', p),
|
||||
updateProvider: (id: string, p: IamProvider): Promise<void> => iamMutate('update-provider', p, { id }),
|
||||
deleteProvider: (p: IamProvider): Promise<void> => iamMutate('delete-provider', p),
|
||||
}
|
||||
|
||||
// ── KMS admin (plain JSON over /admin/kms/secrets) ───────────────────────────
|
||||
|
||||
/** Secret metadata row (no value) — what a future kmsd list endpoint returns. */
|
||||
export type KmsSecretMeta = {
|
||||
path: string
|
||||
name: string
|
||||
env: string
|
||||
version: number
|
||||
updatedTime?: string
|
||||
}
|
||||
|
||||
/** A revealed secret — value shown once, never stored. */
|
||||
export type KmsSecretValue = { value: string; version: number }
|
||||
|
||||
export type KmsScope = { org?: string }
|
||||
export type KmsRef = KmsScope & { path: string; name: string; env?: string }
|
||||
|
||||
const KMS_SECRETS = '/admin/kms/secrets'
|
||||
|
||||
async function kmsReq<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', query?: Query, body?: unknown): Promise<T | undefined> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${KMS_SECRETS}${qs(query)}`, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body !== undefined
|
||||
? { 'Content-Type': 'application/json', Accept: 'application/json' }
|
||||
: { Accept: 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new ApiError(e instanceof Error ? e.message : 'Network request failed')
|
||||
}
|
||||
if (res.status === 403) throw new ApiError('forbidden', 403)
|
||||
const text = await res.text()
|
||||
let json: unknown
|
||||
if (text) {
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
if (!res.ok) throw new ApiError(`Request failed (HTTP ${res.status})`, res.status)
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const m = json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string'
|
||||
? (json as { message: string }).message
|
||||
: `Request failed (HTTP ${res.status})`
|
||||
throw new ApiError(m, res.status)
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
type ListShape = KmsSecretMeta[] | { secrets?: KmsSecretMeta[] }
|
||||
|
||||
function normalizeMeta(raw: ListShape | undefined): KmsSecretMeta[] {
|
||||
const arr = Array.isArray(raw) ? raw : Array.isArray(raw?.secrets) ? raw.secrets : []
|
||||
return arr.map((x) => ({
|
||||
path: String(x.path ?? ''),
|
||||
name: String(x.name ?? ''),
|
||||
env: String(x.env ?? 'default'),
|
||||
version: Number(x.version ?? 0),
|
||||
updatedTime: x.updatedTime ? String(x.updatedTime) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export const KmsAdminApi = {
|
||||
/**
|
||||
* List secret metadata under a prefix. kmsd has no list endpoint yet → 404,
|
||||
* surfaced honestly by the module (never a fabricated table).
|
||||
*/
|
||||
list: (opts: KmsScope & { prefix?: string; env?: string } = {}): Promise<KmsSecretMeta[]> =>
|
||||
kmsReq<ListShape>('GET', { org: opts.org, prefix: opts.prefix, env: opts.env }).then(normalizeMeta),
|
||||
|
||||
/** Reveal ONE value (shown once, never stored). */
|
||||
reveal: (ref: KmsRef): Promise<KmsSecretValue> =>
|
||||
kmsReq<{ secret?: { value?: string }; value?: string; version?: number }>('GET', {
|
||||
org: ref.org,
|
||||
path: ref.path,
|
||||
name: ref.name,
|
||||
env: ref.env,
|
||||
}).then((r) => ({ value: r?.secret?.value ?? r?.value ?? '', version: Number(r?.version ?? 0) })),
|
||||
|
||||
/** Create/upsert a secret (value write-only; the version is bumped). */
|
||||
create: (ref: KmsRef & { value: string }): Promise<{ version: number }> =>
|
||||
kmsReq<{ version?: number }>('POST', { org: ref.org }, {
|
||||
path: ref.path,
|
||||
name: ref.name,
|
||||
env: ref.env ?? '',
|
||||
value: ref.value,
|
||||
}).then((r) => ({ version: Number(r?.version ?? 0) })),
|
||||
|
||||
/** Rotate a secret with compare-and-set on the current version. */
|
||||
rotate: (ref: KmsRef & { value: string; version: number }): Promise<{ version: number }> =>
|
||||
kmsReq<{ version?: number }>('PATCH', { org: ref.org, path: ref.path, name: ref.name }, {
|
||||
value: ref.value,
|
||||
version: ref.version,
|
||||
env: ref.env ?? '',
|
||||
}).then((r) => ({ version: Number(r?.version ?? 0) })),
|
||||
|
||||
/** Delete a secret. */
|
||||
remove: (ref: KmsRef): Promise<void> =>
|
||||
kmsReq<unknown>('DELETE', { org: ref.org, path: ref.path, name: ref.name, env: ref.env }).then(() => undefined),
|
||||
}
|
||||
|
||||
@@ -57,11 +57,18 @@ export {
|
||||
} from './platform'
|
||||
export {
|
||||
IamAdminApi,
|
||||
KmsAdminApi,
|
||||
type Paged,
|
||||
type Organization,
|
||||
type IamUser,
|
||||
type IamApplication,
|
||||
type IamProvider,
|
||||
type Role,
|
||||
type AuditRecord,
|
||||
type KmsSecretMeta,
|
||||
type KmsSecretValue,
|
||||
type KmsScope,
|
||||
type KmsRef,
|
||||
} from './admin'
|
||||
export {
|
||||
PlaygroundApi,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Hostname-driven white-label brand registry.
|
||||
*
|
||||
* ONE console image serves every brand: the request hostname selects the bundled
|
||||
* brand mark + names + admin domain. This is the DEFAULT brand layer; the per-org
|
||||
* IAM logo override (`organization.logo`) still sits ON TOP of it (two orthogonal
|
||||
* layers: host → brand default, org → custom-logo entitlement).
|
||||
*
|
||||
* DRY: host→brand resolution is NOT re-implemented here — it composes
|
||||
* `brandFromHost` from `~/config` (the single host-suffix table). The brand id is
|
||||
* also the IAM org slug (hanzo/lux/zoo/pars), so `adminDomain` + the org scope
|
||||
* both hang off this one record.
|
||||
*
|
||||
* Pure module (no React, no server-only imports) so it is safe in the server
|
||||
* routes (the admin gate) AND the client bundle (BrandLogo, the 403 panel). Logo
|
||||
* content uses `currentColor` (except the Zoo mark, which is intentionally
|
||||
* full-color) so the mark adapts to the dark/light theme with no per-theme asset.
|
||||
*/
|
||||
import { brandFromHost, config, type BrandId } from '~/config'
|
||||
|
||||
export type { BrandId }
|
||||
|
||||
export type Brand = {
|
||||
/** Stable id for the resolved brand — also the IAM org slug. */
|
||||
readonly id: BrandId
|
||||
/** Wordmark next to the mark (e.g. "Hanzo"). */
|
||||
readonly brandName: string
|
||||
/** Org/legal name (footers, copyright). */
|
||||
readonly orgName: string
|
||||
/** Primary marketing site. */
|
||||
readonly websiteUrl: string
|
||||
/** Email domain an operator must hold to reach this brand's admin console. */
|
||||
readonly adminDomain: string
|
||||
/** viewBox for the inline logo SVG. */
|
||||
readonly logoViewBox: string
|
||||
/** Inner SVG markup for the logo mark (multi-path; build-time-trusted constant). */
|
||||
readonly logoContent: string
|
||||
}
|
||||
|
||||
// Hanzo — the canonical blocky-"H" mark. Byte-identical geometry to
|
||||
// `src/components/ui/HanzoMark.tsx` (5 paths, no opacity facets), so the default
|
||||
// Hanzo render matches the existing chrome. Do not reintroduce a hand-drawn H.
|
||||
const HANZO: Brand = {
|
||||
id: 'hanzo',
|
||||
brandName: 'Hanzo',
|
||||
orgName: 'Hanzo Industries Inc.',
|
||||
websiteUrl: 'https://hanzo.ai',
|
||||
adminDomain: 'hanzo.ai',
|
||||
logoViewBox: '0 0 67 67',
|
||||
logoContent:
|
||||
'<path d="M22.21 67V44.6369H0V67H22.21Z"/>' +
|
||||
'<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>' +
|
||||
'<path d="M22.21 0H0V22.3184H22.21V0Z"/>' +
|
||||
'<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>' +
|
||||
'<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>',
|
||||
}
|
||||
|
||||
// Lux — downward triangle (the explorer/console v1 registry mark, currentColor).
|
||||
const LUX: Brand = {
|
||||
id: 'lux',
|
||||
brandName: 'Lux',
|
||||
orgName: 'Lux Industries Inc.',
|
||||
websiteUrl: 'https://lux.network',
|
||||
adminDomain: 'lux.network',
|
||||
logoViewBox: '0 0 100 100',
|
||||
logoContent: '<path d="M50 85 L15 25 L85 25 Z"/>',
|
||||
}
|
||||
|
||||
// Zoo — interlocking circles (the explorer/console v1 registry mark; intentional
|
||||
// full-color fills, not currentColor).
|
||||
const ZOO: Brand = {
|
||||
id: 'zoo',
|
||||
brandName: 'Zoo',
|
||||
orgName: 'Zoo Labs Foundation',
|
||||
websiteUrl: 'https://zoo.ngo',
|
||||
adminDomain: 'zoo.ngo',
|
||||
logoViewBox: '0 0 1024 1024',
|
||||
logoContent:
|
||||
'<defs>' +
|
||||
'<clipPath id="zooClip"><circle cx="512" cy="511" r="270"/></clipPath>' +
|
||||
'<clipPath id="zooGClip"><circle cx="513" cy="369" r="234"/></clipPath>' +
|
||||
'<clipPath id="zooRClip"><circle cx="365" cy="595" r="234"/></clipPath>' +
|
||||
'</defs>' +
|
||||
'<g clip-path="url(#zooClip)">' +
|
||||
'<circle cx="513" cy="369" r="234" fill="#00A652"/>' +
|
||||
'<circle cx="365" cy="595" r="234" fill="#ED1C24"/>' +
|
||||
'<circle cx="643" cy="595" r="234" fill="#2E3192"/>' +
|
||||
'<g clip-path="url(#zooGClip)">' +
|
||||
'<circle cx="365" cy="595" r="234" fill="#FCF006"/>' +
|
||||
'<circle cx="643" cy="595" r="234" fill="#01ACF1"/>' +
|
||||
'</g>' +
|
||||
'<g clip-path="url(#zooRClip)">' +
|
||||
'<circle cx="643" cy="595" r="234" fill="#EA018E"/>' +
|
||||
'</g>' +
|
||||
'<g clip-path="url(#zooGClip)">' +
|
||||
'<g clip-path="url(#zooRClip)">' +
|
||||
'<circle cx="643" cy="595" r="234" fill="#FFFFFF"/>' +
|
||||
'</g></g></g>',
|
||||
}
|
||||
|
||||
// Pars — Persian 8-pointed star (the explorer/console v1 registry mark).
|
||||
const PARS: Brand = {
|
||||
id: 'pars',
|
||||
brandName: 'Pars',
|
||||
orgName: 'Parsis Foundation',
|
||||
websiteUrl: 'https://pars.network',
|
||||
adminDomain: 'pars.network',
|
||||
logoViewBox: '-120 -120 240 240',
|
||||
logoContent:
|
||||
'<path d="M0,-100 L30,-60 L100,-40 L60,0 L100,40 L30,60 L0,100 L-30,60 L-100,40 L-60,0 L-100,-40 L-30,-60 Z"' +
|
||||
' fill="none" stroke="currentColor" stroke-width="4" stroke-linejoin="round"/>' +
|
||||
'<path d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"' +
|
||||
' fill="currentColor" fill-opacity="0.15" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/>' +
|
||||
'<circle r="55" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>' +
|
||||
'<circle r="35" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4"/>' +
|
||||
'<circle r="8" fill="currentColor"/>',
|
||||
}
|
||||
|
||||
/** Every brand, keyed by id. The brand id is also the IAM org slug. */
|
||||
export const BRANDS: Record<BrandId, Brand> = { hanzo: HANZO, lux: LUX, zoo: ZOO, pars: PARS }
|
||||
|
||||
/**
|
||||
* Resolve the active brand. With an explicit `host` (server routes pass the
|
||||
* request Host header) it matches via `brandFromHost`; with no argument it uses
|
||||
* the runtime-resolved `config.brand` (window hostname in the browser). Host
|
||||
* matching is never re-implemented here — `~/config` owns the one suffix table.
|
||||
*/
|
||||
export function getBrand(host?: string | null): Brand {
|
||||
return BRANDS[host === undefined ? config.brand : brandFromHost(host)]
|
||||
}
|
||||
+125
-3
@@ -20,15 +20,31 @@
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS, type Brand } from '~/lib/branding/brands'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/** IAM OIDC issuer host that serves the privileged primitives (/v1/iam/*). */
|
||||
const IAM_URL = trim(process.env.IAM_URL ?? 'https://iam.hanzo.ai')
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — resolves the session cookie to a user. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
/**
|
||||
* In-cluster Hanzo KMS (kmsd) — the admin KMS proxy forwards here. Default is the
|
||||
* ClusterIP `Service kms` (ns hanzo), whose port 80 targets the kmsd container's
|
||||
* :8080 (universe `infra/k8s/kms/deployment.yaml`). Override per-deploy with KMS_URL.
|
||||
*/
|
||||
const KMS_URL = trim(process.env.KMS_URL ?? 'http://kms.hanzo.svc')
|
||||
/** Confidential client used for app-on-behalf mint/issue/revoke. */
|
||||
const MINT_CLIENT_ID = process.env.IAM_MINT_CLIENT_ID ?? ''
|
||||
const MINT_CLIENT_SECRET = process.env.IAM_MINT_CLIENT_SECRET ?? ''
|
||||
/** casdoor's built-in admin org — a user in it is a global (cross-tenant) admin. */
|
||||
const ADMIN_ORG = 'built-in'
|
||||
|
||||
/** IAM base URL (the admin IAM proxy forwards `/v1/iam/*` here). */
|
||||
export const iamBaseUrl = (): string => IAM_URL
|
||||
/** KMS base URL (the admin KMS proxy forwards `/v1/kms/*` here). */
|
||||
export const kmsBaseUrl = (): string => KMS_URL
|
||||
|
||||
/** True when the confidential client is wired (so routes can 501 honestly). */
|
||||
export const mintConfigured = (): boolean => Boolean(MINT_CLIENT_ID && MINT_CLIENT_SECRET)
|
||||
@@ -43,16 +59,26 @@ export type SessionUser = {
|
||||
id: string
|
||||
/** The user's current `hk-` Cloud API key, if one exists (else ''). */
|
||||
accessKey: string
|
||||
/** Verified email (get-account claim, else IAM get-user). '' when unknown. */
|
||||
email: string
|
||||
/** IAM-authoritative org-admin flag. */
|
||||
isAdmin: boolean
|
||||
/** True for built-in-org admins — may act across any tenant org. */
|
||||
isGlobalAdmin: boolean
|
||||
}
|
||||
|
||||
type AccountClaims = {
|
||||
type UserClaims = {
|
||||
owner?: string
|
||||
name?: string
|
||||
type?: string
|
||||
accessKey?: string
|
||||
User?: { owner?: string; name?: string; type?: string; accessKey?: string }
|
||||
email?: string
|
||||
isAdmin?: boolean
|
||||
isGlobalAdmin?: boolean
|
||||
}
|
||||
|
||||
type AccountClaims = UserClaims & { User?: UserClaims }
|
||||
|
||||
/**
|
||||
* Resolve the signed-in user from the request's first-party cloud session cookie
|
||||
* by asking the cloud backend `/v1/get-account` (which itself refreshes the user
|
||||
@@ -86,7 +112,47 @@ export async function resolveUser(req: NextRequest): Promise<SessionUser | null>
|
||||
// An auto-created casibase "anonymous-user" is NOT authenticated.
|
||||
if (!owner || !name || type === 'anonymous-user') return null
|
||||
|
||||
return { owner, name, id: `${owner}/${name}`, accessKey }
|
||||
const id = `${owner}/${name}`
|
||||
let email = d.email ?? d.User?.email ?? ''
|
||||
let isAdmin = Boolean(d.isAdmin ?? d.User?.isAdmin)
|
||||
let isGlobalAdmin = Boolean(d.isGlobalAdmin ?? d.User?.isGlobalAdmin) || (owner === ADMIN_ORG && isAdmin)
|
||||
|
||||
// get-account may not carry email/isAdmin (thin claims). When email is absent,
|
||||
// IAM is authoritative — fetch the user as the confidential client to resolve
|
||||
// email + admin flags before any gate decision. Fail-soft: a null lookup leaves
|
||||
// the fields empty and the @adminDomain gate refuses (fail-closed).
|
||||
if (!email) {
|
||||
const u = await iamGetUser(id)
|
||||
if (u) {
|
||||
email = u.email ?? ''
|
||||
isAdmin = Boolean(u.isAdmin)
|
||||
isGlobalAdmin = Boolean(u.isGlobalAdmin) || (owner === ADMIN_ORG && isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
return { owner, name, id, accessKey, email, isAdmin, isGlobalAdmin }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a user's authoritative identity claims (email + admin flags) from IAM as
|
||||
* the confidential client. GET, Basic auth — same credential the mint/issue
|
||||
* primitives use. Fail-soft (returns null) so a transient IAM error never opens
|
||||
* the admin gate.
|
||||
*/
|
||||
async function iamGetUser(id: string): Promise<UserClaims | null> {
|
||||
if (!mintConfigured()) return null
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${IAM_URL}/v1/iam/get-user?id=${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: basicAuth(), Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { status?: string; data?: UserClaims } | null
|
||||
if (!res.ok || !json || json.status !== 'ok' || !json.data) return null
|
||||
return json.data
|
||||
}
|
||||
|
||||
function basicAuth(): string {
|
||||
@@ -135,3 +201,59 @@ export async function issueUserToken(user: SessionUser): Promise<{ accessToken:
|
||||
if (!data.accessToken) throw new Error('IAM did not return a token')
|
||||
return { accessToken: data.accessToken, expiresIn: data.expiresIn ?? 0 }
|
||||
}
|
||||
|
||||
// ── Admin gate ───────────────────────────────────────────────────────────────
|
||||
// The trust boundary for the admin console (IAM + KMS proxies). Resolves WHO the
|
||||
// caller is from their own session, then enforces TWO authoritative checks: the
|
||||
// caller's verified email is on the host's brand admin domain, AND IAM marks them
|
||||
// an admin. Returns the gate context, or null so the route returns 403.
|
||||
|
||||
/** The authorized admin context for the request's brand. */
|
||||
export type AdminGate = {
|
||||
user: SessionUser
|
||||
/** Brand resolved from the request Host header (DRY: `brandFromHost`). */
|
||||
brand: Brand
|
||||
/** Org the operator acts on by default — the brand org (== brand id). */
|
||||
orgScope: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve + authorize the caller for the admin console of the request's brand.
|
||||
*
|
||||
* Requires BOTH: (1) the caller's verified email ends with `@<brand.adminDomain>`
|
||||
* (a hanzo.ai operator can't administer a lux host, and vice-versa), and (2) IAM
|
||||
* marks them an admin (`isAdmin`, or a built-in-org global admin). A global admin
|
||||
* may target any org; a brand admin is pinned to their own `orgScope`. Returns
|
||||
* null (→ caller 403s) on any miss — fail-closed.
|
||||
*/
|
||||
export async function getAdminGate(req: NextRequest): Promise<AdminGate | null> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return null
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const emailOnBrand = !!user.email && user.email.toLowerCase().endsWith('@' + brand.adminDomain)
|
||||
const isIamAdmin = user.isAdmin || user.isGlobalAdmin
|
||||
if (!emailOnBrand || !isIamAdmin) return null
|
||||
|
||||
return { user, brand, orgScope: brand.id }
|
||||
}
|
||||
|
||||
// ── Admin bearer-token cache ─────────────────────────────────────────────────
|
||||
// The admin proxies forward as the user (not the confidential client) so IAM/KMS
|
||||
// enforce tenant isolation from the verified `owner` claim. issue-user-token is
|
||||
// an IAM round-trip; cache the JWT per user until ~60s before expiry (same shape
|
||||
// as the AI proxy) so a console session reuses one token across admin calls.
|
||||
type CachedToken = { token: string; expMs: number }
|
||||
const adminTokenCache = new Map<string, CachedToken>()
|
||||
const ADMIN_TOKEN_SKEW_MS = 60_000
|
||||
const ADMIN_TOKEN_FALLBACK_TTL_MS = 5 * 60_000
|
||||
|
||||
/** A short-lived, user-bound IAM bearer for the admin proxies (cached per user). */
|
||||
export async function adminBearer(user: SessionUser): Promise<string> {
|
||||
const hit = adminTokenCache.get(user.id)
|
||||
if (hit && hit.expMs > Date.now()) return hit.token
|
||||
const { accessToken, expiresIn } = await issueUserToken(user)
|
||||
const ttl = expiresIn > 0 ? expiresIn * 1000 : ADMIN_TOKEN_FALLBACK_TTL_MS
|
||||
adminTokenCache.set(user.id, { token: accessToken, expMs: Date.now() + ttl - ADMIN_TOKEN_SKEW_MS })
|
||||
return accessToken
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user