Make sysadmin-{api,web} a Self-Contained Product Slice
Context
ADR 2026_05_19_split-sysadmin-into-proprietary-packages extracted the sysadmin plane into packages/sysadmin-{api,web}. The initial extraction was minimal: sysadmin-api owned only TenantAdminController + a tiny WhoamiController (for the role gate), and its _sysadmin_lifespan connected MongoDB only. sysadmin-web inherited every page and composable from @swiss-ai-hub/web via Nuxt Layer extension.
In practice this left sysadmin-web dependent on the main API to function. The inherited composables it uses (useUsers, useRoles, useCreateRole, useUpdateRole, useDeleteRole, useAuthProviders) all call @core/sdk/client.*, which is configured with a single base URL via apiBaseUrl. To make them resolve, we either had to:
- Point
apiBaseUrlcross-origin at the main API — coupling the sysadmin product to the main API origin, breaking the "independently deployable" property recorded in the earlier ADR, and inconsistent between dev and prod (dev was cross-origin, prod was same-origin). - Or expose those endpoints on
sysadmin-apiitself, same-origin undersysadmin.${DOMAIN}/api/v1.
The composability model behind the Nuxt layer + SDK split is: pick the UI components you need from @swiss-ai-hub/web, then mount the API controllers backing those components on the API runner you ship next to it. We chose to honor that model — sysadmin-{api,web} should be runnable standalone, with no main API alongside.
Decision Drivers
- Independent deployability (carried over from
2026_05_19)
Sysadmin slice must work withsysadmin-apias its only backend. - SDK composability
Thepick UI + mount matching APIpattern is the central design promise of how downstream consumers will assemble their own products from the swiss-ai-hub building blocks. Sysadmin-web is the first in-repo consumer that exercises it; the pattern must hold for sysadmin first. - Avoid cross-origin coupling
Cross-origin token forwarding, audience mismatches, preflight overhead, and dev/prod config divergence are all real costs. - Avoid invented
sysadmin-api-only endpoints when an existingpackages/apicontroller fits
The earlier draft added a bespokeWhoamiControllerinstead of reusingMyAccountController. That was wrong — it duplicated purpose without good reason.
Decision
Lifespan expansion
SysadminApiRunner._sysadmin_lifespan is widened from "MongoDB-only" to MongoDB + NATS + Redis + AccessChangeHook (with its own OpenWebuiProvisioner instance), plus I18nMiddleware is attached to the FastAPI app so use_locale resolves. This is intentionally a medium lifespan: it does NOT pull in Milvus, S3, Neo4j, WebSocket plumbing, discovery services, RPC responders, or event subscribers — those remain main-API-only.
Why the OpenWebUI provisioner shows up despite this being supposedly "without provisioners": MongoEngine post_save / post_delete signals are per-process. The access-mirror hook is registered against those signals in the process that wants to react to them. Without a listener on sysadmin-api, a role assignment made through sysadmin-web (UserController.assign_role mounted on sysadmin-api → UserTenantRoleEntity.add_roles → Mongo save) would silently leave OpenWebUI's SCIM grants stale until the next mutation happened to go through main API. sysadmin-api therefore builds its own OpenWebuiProvisioner(redis=...) and calls AccessChangeHook.connect(...). We deliberately do NOT call provisioner.provision() from sysadmin-api — that's main API's idempotent startup responsibility; running it twice could race.
Controller mount list
sysadmin-api/main.py mounts a curated subset of packages/api controllers, picked to cover every endpoint sysadmin-web's confined surface (/auth/* + /tenants/*) hits:
| Controller | Reason |
|---|---|
MyAccountController.get_my_identity() only | Role gate (is_sys_admin) for sysadmin.global.ts. Replaces the removed WhoamiController. |
UserController | Inherited useUsers, useUser. |
RoleController | Inherited useRoles, useCreateRole, useUpdateRole, useDeleteRole. |
AuthProviderController | Inherited useAuthProviders on /auth/login. |
Code ownership stays in packages/api; sysadmin-api only re-mounts via the public swiss_ai_hub.api interface.
MyAccount split
MyAccountController.get_my_account() conflated two concerns: user identity (is_sys_admin, roles, dashboard) and a runtime-discovered access matrix (which services / agents / processes the user can use). The access-matrix path needs NATS + locale, and on sysadmin-api would produce a misleadingly narrow matrix anyway (because runner.controllers there reflects only sysadmin-api's surface).
We split it: a new MyAccountController.get_my_identity() returns the identity portion only (UserDTO, no access field), depending on nothing beyond Security(authenticated_user()) + MongoDB. The existing get_my_account() stays unchanged on main API. sysadmin-api mounts only get_my_identity().
Deleted
WhoamiController+ its DTO + sysadmin-api'sroutes/whoami/package.- The dev-only cross-origin
MAIN_API_URLbranch inpackages/sysadmin-web/.app/nuxt.config.ts.apiBaseUrlis now/api/v1same-origin in both dev (Nitro proxy →:8001) and prod (sysadmin.${DOMAIN}/api/v1→sysadmin-api).
Consequences
Positive
- Standalone product:
sysadmin-api+sysadmin-webis runnable with no main API on the wire. Token flow, role gate, tenant CRUD, user list, role CRUD, auth providers all land same-origin. - No dev/prod divergence:
apiBaseUrlis the same value in both environments. - Composability pattern proven: the "pick UI + mount matching API" model is honored end-to-end with sysadmin as the first concrete instance — downstream consumers building on
@swiss-ai-hub/webhave a worked example to copy. - API surface cleaner: the identity-vs-access conflation in
MyAccountControlleris named and split.
Negative
sysadmin-apistartup is heavier: still under 1s in dev, but it now needs NATS + Redis reachable. Deploy order matters — sysadmin-api will fail to start if either is down.sysadmin-apiOpenAPI surface is larger: 4 controllers re-mounted vs. the original 2 owned. Anyone scanning the spec will see tenant-scoped routes there.- Compose ENV needs updating: sysadmin-api's compose entry now needs
NATS_URLandREDIS_URL(or equivalent derived from existing settings). Tracked separately under deploy/infra changes.
Reversibility
The decision is fully reversible — controllers can be unmounted and the lifespan can be re-narrowed without touching code in packages/api. The reverse direction (cross-origin to main API) requires changing one config value in sysadmin-web/.app/nuxt.config.ts. We do not expect to reverse this, but the option exists.
References
- ADR
2026_05_19_split-sysadmin-into-proprietary-packages— original extraction. - ADR
2026_04_15_sysadmin_implicit_admin_access— sysadmin short-circuit that keeps the auth model coherent when the same controllers are mounted on both APIs. - PR #1304 — the open-source rollout PR this change ships in.
