* feat(unified-rbac): SME-tier extension + host-header tenant discovery (#802)
Implements the SME-tier extension to the existing Sovereign Console SPA
per [Q-mine-1] of #795: same React bundle serves both otech-admin and
SME-admin views, tenant context discovered via window.location.host
against a back-end registry — not from path/subdomain string parsing.
Backend (catalyst-api / unified-rbac slice):
- Tenant registry (store.TenantRegistry) — flat-file host → tenant
lookup table backing the public discovery endpoint. Host normalised
to lowercase; case-insensitive lookups.
- GET /api/v1/tenant/discover (public, no auth gate) — returns
{tenant_id, tenant_kind, keycloak_realm_url, keycloak_client_id} on
200, 404 on unknown host, 503 if registry unwired. Admin URLs are
NEVER on this wire.
- POST /api/v1/sme/users — fires ADR-0003 3-step hook (Keycloak →
NewAPI → K8s Secret SSA with field manager `unified-rbac`). Each
step idempotent; persisted state machine in store.UserProvisionStore
per ADR-0003 §3.4. Returns 202 with steps[] progress array so the
SPA can render the 3-step indicator even on partial failure.
- GET /api/v1/sme/users / DELETE /api/v1/sme/users/{uuid} — list +
inverse rollback per ADR-0003 §3.7.
- internal/newapi.Client — minimal NewAPI admin REST client; 201
happy-path + 409 idempotent recovery via GET ?external_id=<uuid>
per ADR-0003 §3.2 (NewAPI does NOT rotate api_key on conflict).
Frontend (Sovereign Console SPA):
- Branded TenantID + TenantKind types (shared/types/tenant.ts) — same
pattern as DeploymentID (#749).
- shared/lib/tenantDiscover.ts — fire-and-forget discovery in main.tsx;
result cached in module state for sidebar nav + OIDC bootstrap.
- pages/sme/UsersPage.tsx — user CRUD UI with 3-step KC/NewAPI/Secret
progress indicator wired off the API response shape.
- pages/sme/RolesPage.tsx — canonical Keycloak group → app role map
(wordpress / openclaw / stalwart / rbac) per #795 [B].
- pages/sme/sme.api.ts — typed REST client; X-Tenant-Host header
carries window.location.host on every call.
- Routes mounted at /console/sme/users + /console/sme/roles under the
existing SovereignConsoleLayout — same SPA bundle, different route
tree per discovered tenant_kind.
Tests: 22 new UI tests (4 files), 33 new Go tests (4 files). All
green: branded type parsers reject empty/non-string inputs, tenant
discovery handles 200/404/503/network-error paths, the 3-step hook
runs end-to-end against fake KC/NewAPI/SSA stubs, partial-failure
states surface verbatim through the steps[] response field, public
discovery endpoint never leaks admin URLs.
Per docs/INVIOLABLE-PRINCIPLES.md #4 every URL goes through apiUrl()
in shared/config/urls; per #2 wire shapes parse through branded-type
parsers at the boundary; per #3 K8s Secret apply uses client-go SSA
(field manager `unified-rbac`) — no exec.Command kubectl shell-out.
Closes#802.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(unified-rbac): add Playwright E2E for SME-tier UI (#802)
Three specs covering:
- SME UsersPage: empty state → create form → 3-step progress
indicator (KC done / NewAPI done / Secret done) — proves the
page is wired to the API response shape.
- SME RolesPage: canonical group → app-role table renders the
full 7-row mapping locked in #795 [B].
- OTECH tenant: same SPA bundle navigates /console/dashboard for
the otech discovery payload — proves [Q-mine-1] of #795
(one bundle, two route trees, host-driven discovery).
Backend mocks: route fulfillers stub /tenant/discover, /sme/users,
and /whoami so the dev-server harness can drive the SPA without
the catalyst-api backend or a live SME vcluster. The full live
cross-cluster E2E gates on bp-newapi (#799) seeding the tenant
registry at SME-onboarding time, which lands in #804.
1440 px screenshots captured at e2e/screenshots/802-*.png:
- 802-sme-users-empty-1440.png
- 802-sme-users-create-form-1440.png
- 802-sme-users-after-create-1440.png
- 802-sme-roles-1440.png
- 802-otech-dashboard-same-bundle-1440.png
Run: VITE_CATALYST_MODE=sovereign VITE_SOVEREIGN_FQDN=acme.otech.example
npm run dev
npx playwright test e2e/sme-tier-rbac.spec.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(admin-console): add-domain flow + DNS propagation status panel (#829)
Multi-domain Sovereign — operator-admin "Add another parent domain"
surface in the Sovereign Console + live DNS propagation status panel.
Closes the MD-4 sub-ticket of epic #825.
Backend (catalyst-api/internal/handler/parent_domains.go):
- GET /api/v1/sovereign/parent-domains — list pool
- POST /api/v1/sovereign/parent-domains — add domain
- DELETE /api/v1/sovereign/parent-domains/{name} — remove
- GET /api/v1/sovereign/parent-domains/{name}/propagation
— fan-out to 5+
public DNS resolvers
The Add pipeline calls PDM /set-ns (sister #826), creates the PowerDNS
zone (sister #827, env-gated stub until that PR lands), and issues a
wildcard cert via cert-manager (also sister #827, env-gated stub). All
three steps update the same store row so the UI can render per-step
progress.
DNS propagation panel uses Go's net.Resolver with a custom Dial that
routes lookups through a SPECIFIC resolver IP (8.8.8.8, 1.1.1.1,
9.9.9.9, 208.67.222.222, 4.2.2.1) rather than the system resolver.
Per inviolable principle #4, the resolver list, expected NS records,
and per-query timeout are all env-overridable.
Frontend (ui/src/pages/admin/parent-domains/):
- ParentDomainsPage.tsx — list view + Add Domain modal + per-row
inline drawer with PropagationPanel
- PropagationPanel.tsx — polls /propagation every 60s, renders
green/yellow/red pills per resolver + rolling % propagated number
- parentDomains.api.ts — typed REST client wrappers, no inline /api/
Routing:
- /console/parent-domains registered under SovereignConsoleLayout
- Added to Settings sub-nav for operator-admin reachability
Tests:
- 6 vitest cases (empty state, populated rows, modal open, drawer
toggle, primary lock, propagation panel mount)
- 13 Go cases covering list/add/delete/validation/propagation wire
shape against a stub PDM
- 3 Playwright E2E + 1440x900 screenshots:
e2e/screenshots/829-1-just-flipped.png (0% propagated)
e2e/screenshots/829-2-partially-propagated.png (40%)
e2e/screenshots/829-3-fully-propagated.png (100%)
Per inviolable principle #10 (credential hygiene) the registrarToken
field is forwarded byte-for-byte to PDM and never enters a logged
struct; the modal input uses type="password".
Refs: #825 (parent epic), #826 (sister MD-1), #827 (sister MD-2)
---------
Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>