# Instance Admin Area

The **instance admin** (also called super-admin) area lets an operator manage the whole Hephaestus deployment. A **workspace admin** has powers only within one workspace.

## Who is an instance admin?

* An account with `Account.appRole == APP_ADMIN` (ADR 0017 native auth).
* The issuer mints the namespaced **`app_admin`** granted authority for these accounts (`JwtPrincipalFactory`). This differs from the per-workspace `admin` role, which comes from membership and never appears in the JWT. `SecurityUtils.isSuperAdmin()` reads `app_admin`. `WorkspaceContextFilter` grants an instance admin `WorkspaceRole.ADMIN` in **any active workspace, with or without membership**. It grants `ADMIN`, never `OWNER`, because members grant ownership. It records that access: see [Elevated workspace access](#elevated-workspace-access).
* The authority comes **only** from `appRole`. `JwtPrincipalFactory` removes reserved authorities (`app_admin`/`admin`) that arrive through grantable `account_feature` rows. Thus, an `/admin/users`-granted flag can never confer instance-admin access.
* First-admin bootstrap (no DB seed required) is covered separately in the [operator bootstrap instructions](/next/admin/instance-admin.md#bootstrap-and-recovery).

## The shell

The [operator guide](/next/admin/instance-admin.md) owns console tasks and the user-view procedure. This page owns authorization, routing, and persistence implementation.

The admin area is a **dedicated sidebar context** (`AppSidebar` `context === "admin"`). It has its own "Back to app" header, with no workspace switcher, as in the GitLab/Grafana "admin area" pattern. It does **not** reuse the mentor context. The always-present sidebar footer has an `app_admin`-gated **"Instance administration"** entry. Thus, a newly bootstrapped admin with **zero workspaces** can reach it.

`beforeLoad` (`isAppAdmin`) guards the `/admin` route tree. The server enforces `@PreAuthorize("hasAuthority('app_admin')")` on every endpoint below. The client is not a security boundary.

## Endpoints

Instance metadata endpoints under `/admin`, all gated by `hasAuthority('app_admin')`:

| Endpoint                                                                                                                                                                                             | Purpose                                                                                                                                                                                                                                                                                                        |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /admin/users` (`adminListUsers`)                                                                                                                                                                | Paged account list                                                                                                                                                                                                                                                                                             |
| `PATCH /admin/users/{id}` (`adminUpdateUser`)                                                                                                                                                        | Change an account's app role (last-admin guard. Cannot self-demote)                                                                                                                                                                                                                                            |
| `PATCH /admin/users/{id}/status` (`adminUpdateUserStatus`)                                                                                                                                           | **Block** (`BLOCKED`) or **unblock** (`ACTIVE`) an account. Blocking revokes every session and makes sign-in, token renewal and installed clients refuse the account. Cannot block yourself or the last active instance admin. Audited as `ACCOUNT_STATUS_CHANGED`.                                            |
| `DELETE /admin/users/{id}/sessions` (`adminRevokeUserSessions`)                                                                                                                                      | **Force sign out**: revoke all of an account's active sessions. Audited as `JWT_REVOKED`.                                                                                                                                                                                                                      |
| `GET /admin/workspaces` (`adminListWorkspaces`)                                                                                                                                                      | **Metadata-only** overview of every workspace (slug, status, provider, owner login, member count, created-at). Cross-tenant through `@WorkspaceAgnostic`. This endpoint itself returns **no tenant content**. Private user content is reached through [read-only user views](#read-only-user-views).           |
| `GET /admin/audit` (`adminListAuthEvents`)                                                                                                                                                           | Read-only viewer over the append-only `auth_event` log (logins, user views, role changes, deletions). Paged, newest-first, filterable by event type. [read-only user views](#read-only-user-views) says what a `USER_VIEW` row carries.                                                                        |
| `GET /admin/config-audit` (`adminListConfigAuditEvents`)                                                                                                                                             | Read-only viewer over `config_audit_event` — who changed which workspace setting, when, and from what to what. Rows are immutable inside the retention window (DB trigger). `ConfigAuditRetentionJob` is the only way one leaves.                                                                              |
| `/admin/llm/connections*` (`adminListLlmConnections`, `adminCreateLlmConnection`, `adminUpdateLlmConnection`, `adminDeleteLlmConnection`, `adminProbeLlmConnection`, `adminProbeLlmConnectionDraft`) | The instance LLM connection catalog. Routing identity (base URL, wire API, auth mode) is immutable after create. Probe tests a saved or draft connection before anything is enabled.                                                                                                                           |
| `/admin/llm/models*` (`adminListLlmModels`, `adminCreateLlmModel`, `adminUpdateLlmModel`, `adminDeleteLlmModel`, `adminUpdateLlmModelPrice`, `adminUpdateLlmModelSharing`)                           | Models under a connection, their prices (temporal supersede-on-insert into `llm_model_price`), and who may use them — public, or granted per workspace.                                                                                                                                                        |
| `GET`/`PUT /admin/llm/settings` (`adminGetLlmSettings`, `adminUpdateLlmSettings`)                                                                                                                    | The instance LLM settings singleton: egress host allowlist and `allowWorkspaceConnections`, the switch that lets workspaces register their own provider connections.                                                                                                                                           |
| `GET /admin/llm/usage` (`adminGetLlmUsageReport`)                                                                                                                                                    | Cross-workspace monthly LLM usage and budget report, split by purse (shared models vs each workspace's own provider).                                                                                                                                                                                          |
| `PUT /admin/workspaces/{workspaceSlug}/llm/budget` (`adminUpdateWorkspaceLlmBudget`)                                                                                                                 | Set **or clear** a workspace's monthly cap on **shared-model** spend — clearing is `PUT` with `monthlyBudgetUsd: null`, not `DELETE` (there is no `DELETE` mapping. It returns 405). The workspace's cap on its own provider is a different endpoint under `/workspaces/**`, set by the workspace's own admin. |
| `GET /admin/settings` / `PATCH /admin/settings/silent-mode`                                                                                                                                          | Read or change the instance-wide outbound delivery brake. Releasing requires `If-Match` with the ETag returned by `GET`, so a stale browser cannot release a newer incident response.                                                                                                                          |

## Instance silent mode

[Instance administration](/next/admin/instance-admin.md#silent-mode) owns operator behavior and recovery. `GET /admin/settings` returns an ETag. `PATCH /admin/settings/silent-mode` requires `If-Match` when releasing the brake. Provider gateways enforce it independently from result persistence.

## Recent sign-in gate

Instance-admin actions that change access require a completed sign-in younger than `hephaestus.auth.step-up-max-age` (`HEPHAESTUS_AUTH_STEP_UP_MAX_AGE`, default 5m). These actions include app-role changes, block and unblock, force sign-out, user views, login-provider mutations, and LLM connection registration or removal. The same requirement applies to identity attachment for every user. A new link is a permanent second way into the account.

It also applies to a workspace membership change that grants the owner role, or demotes or removes an owner. An owner who lowers their own role is not asked. Changes between member and admin are not asked.

The completed OAuth login stamps the standard `auth_time` claim once. Every rotation copies it unchanged. Thus, silent keep-alive cannot make a session appear newly signed in. `SecurityConfig` converts it to Spring Security's `FactorGrantedAuthority` for the authorization-code factor. [`AllRequiredFactorsAuthorizationManager`](https://docs.spring.io/spring-security/reference/servlet/authentication/mfa.html) compares it with the window.

The comparison accepts anything not yet expired. Thus, a session stamped by a pod whose clock leads the enforcing pod's clock remains fresh. Local clock skew must never tell a newly signed-in administrator to sign in again.

Declare the requirement with `@RequiresRecentSignIn` on the handler. To decline it, use `@RecentSignInExempt(reason = …)` on the handler or controller. `RecentSignInByDefaultArchTest` fails the build when an instance-admin mutation carries neither annotation. The same rule applies to every workspace role mutation: a mutation that `@Audited` files as `WORKSPACE_ROLE`.

It also applies to every delete handler. Deleting your own account and deleting a workspace require a recent sign-in, because neither can be undone. Every other delete records why it is exempt. A handler whose `@Audited` names another ledger declares the refusal type with `@RequiresRecentSignIn(refusal = …)`. Thus, a new administrative action cannot ship without a recorded decision.

The member's current role and the target role decide together whether a workspace role change needs the gate. A change needs it when it grants the owner role, or when it demotes or removes an owner other than the caller. The current role is known only under the workspace lock.

Thus, those handlers declare `@RequiresRecentSignIn(OWNER_CHANGE)`, and the method interceptor does not ask. `MembershipService.authorizeRoleChange` decides under the workspace lock, with the same policy. Every HTTP path that changes a workspace role calls it. Provider role sync (`syncWorkspaceMembers`, `applyProviderGrant`) does not call it.

The architecture test refuses `OWNER_CHANGE` on any other handler, because there it would ask for nothing. A refusal records `WORKSPACE_OWNER_CHANGE` as `FAILURE`. The completed change stays on the workspace's `WORKSPACE_ROLE` configuration audit trail. `AuditByDefaultArchTest` gives the audit trail the same structure.

The refusal uses the `auth_event` type that the handler already declares for `@Audited`. It records a `FAILURE` that names the account whose session failed the check. It also increments `auth.step_up.denied{action}`.

### What it does and does not stop

It limits a **hijacked admin session**. A stolen cookie is dangerous only within the window, and the audit trail records every attempt. It does not stop an attacker who can control the browser through XSS, a compromised machine, or a session beside the operator's. That attacker can complete confirmation too.

It is **not a second factor**. An existing GitHub or GitLab session can satisfy OAuth without a new authenticator challenge. Protected admin access also requires local passkey verification. See [Admin passkeys](/next/admin/passkeys.md) for the policy and recovery procedure. It does not stop a malicious administrator authorized for the action. The audit trail serves that purpose.

A refused action is never replayed after confirmation. The SPA reopens the action for the operator to review and submit again. A queued privileged write is exactly what an attacker would want confirmation to unlock.

## Read-only user views

**Instance administration → Workspaces → View as user**, then **View as user** on an account, opens the normal workspace app as that account member. The instance administrator remains authenticated as themselves. The selected identity is an account, including an unclaimed roster account. Its provider mirrors remain source attribution rather than sign-in principals. A view-as read does not record the developer's first read of private feedback.

The browser keeps the selected workspace, account ID, and reason in the tab's session storage. When a view starts or exits, the app reloads so cached results cannot cross identities. When it loads the current account, it drops a stored view opened by another account. `applyUserViewHeaders` adds `X-User-View-Workspace`, `X-User-View-User`, and the reason header to every request. It excludes the administrator's own sign-in, account, consent, feature-flag, and identity reads.

During a view, the administrator's feature flags do not apply. A banner names the viewed user and offers an exit. The account menu still names the administrator, whom it signs out.

The selection endpoints use the `User view` tag in `server/openapi.yaml`. All are `GET` under `/workspaces/{slug}/user-view/users`. Like every instance-administrator controller, all require `@PreAuthorize("hasAuthority('app_admin')")`. Each request checks token authority and session revocation. Demotion and deletion revoke sessions.

Every selection handler addressed at a `{userId}` carries `@UserViewRead` (`@RequiresRecentSignIn` + `@Audited(AUTH_EVENT, "USER_VIEW")`). `UserViewArchitectureTest` fails the build for a `/user-view` handler that is not a `GET` or names `{userId}` without this annotation. `UserViewAuthorizationConfig` advises the annotation after the [recent sign-in gate](#recent-sign-in-gate). Thus, a refused confirmation leaves no successful `USER_VIEW` record.

Before the handler runs, it requires the `X-User-View-Reason` header, encoded as percent-encoded UTF-8. `UserViewAccessService` accepts 1–500 decoded characters, excluding control and format characters. It resolves the viewed member through `IdentityRepository` and commits the row. If the row does not commit, it answers 503. `OpenAPIConfiguration.userViewReasonHeader` declares the header on every such operation, so the generated client requires it.

Before every other rule, `SecurityConfig` admits a request with a view header only as an instance administrator's `GET`. `UserViewSessionFilter` answers 403 for a route outside `READ_PATHS`. It answers 404 for a workspace in the path other than the viewed one. Both checks occur before any audit row. A teammate's profile remains readable as it is for the member, and its row records that read.

Every request requires a [recent sign-in](#recent-sign-in-gate). It resolves the human member and commits the `USER_VIEW` row before the handler runs. If the row does not commit, it answers 503. `WorkspaceContextFilter` gives the request the member's workspace roles and SCM actor ID, never instance-admin elevation. The in-app feedback read does not mark feedback delivered during a view.

When the sign-in window expires, the SPA opens the access confirmation. A new sign-in returns to the same page with the view intact.

This is a view of existing content, not a user login. It does not enter onboarding, choose personal settings or create an account, so it cannot reproduce the member's first-login experience.

A successful `USER_VIEW` row records authorization to attempt a read, not proof that the handler returned content. A missing result or conversation can still produce a 404 after the row commits. The row carries these fields:

* `acting_account_id` = the instance administrator.
* `account_id` = the acting administrator.
* `viewed_account_id` = the selected account, with `viewed_actor_id` for the exact provider disclosure when present.
* `workspace_id`.
* `details` = `{"reason": …, "read": "<request path?query>"}`.

`AccountPurger` nulls `ip_inet`, `user_agent`, and `details` when the erased account is either recorded account or the recorded viewed account. Thus, it runs before deletion of that account's identity links. The read budget is a [setting](/next/admin/configuration-readiness.md#session-deadlines). [ADR 0017](https://github.com/hephaestus-build/Hephaestus/blob/main/docs/decisions/0017-replace-keycloak-with-spring-native-auth.md#update--2026-09-11) explains why this does not use Spring Security's `SwitchUserFilter`.

## Elevated workspace access

An instance admin who is not a member of a workspace still reaches it as a workspace admin, and both audit trails say so.

`WorkspaceContextFilter` makes this decision once per request. For a non-member, it records the workspace in `WorkspaceElevationContext`, a request-local `ThreadLocal`. The same `finally` clears it and the workspace context. It is deliberately not inheritable, so a background executor task starts unelevated. Downstream code reads the flag from this context, not an argument.

`ConfigAuditActor` explains the actor-attribution reason: a producer can neither forget the flag nor assert one it did not earn.

* `auth_event` gains a **`WORKSPACE_ELEVATION`** row. It marks an access *window*, not a request. `WorkspaceElevationAuditAdapter` de-duplicates per `(account, workspace)` for 15 minutes in a bounded per-process cache. Thus, workspace reads do not obscure the user-view and role-change events that the viewer exists to show. The adapter claims the cache only after it writes the row. Eviction or a second replica can add a duplicate marker. An extra window marker is harmless, but a lost marker is not.
* `config_audit_event` gains **`elevated_via_instance_admin`** per row, resolved for that row's own `workspaceId`. Thus, an instance-scoped change with no workspace never gets the wrong tag. The adapter does not de-duplicate configuration changes. Each carries its own bit.

Both admin consoles surface it, and `GET /admin/audit/export` carries it alongside the viewed-user identifier in CSV exports.

The flag does not replace the viewed-user identifier: elevated workspace administration and a private user view are different permissions. `false` means "no elevation recorded", not "the actor was a member": rows written before the flag existed all read `false`.

## Deferred / follow-up

* **`APP_AUDITOR`** read-only tier: not built. A single-operator instance has no second audience for it. The enum and authority design permit its addition. LLM governance is not on this list — it is built. An instance admin registers connections and models under `/admin/llm/*`, prices them, and grants them to workspaces. A workspace may add its own connection when instance settings permit it. Usage is metered into `llm_usage_event`. Two independent monthly budgets cap it: the instance cap on shared-model spend and the workspace cap on its own provider. The budgets are never summed. [ADR 0026](https://github.com/hephaestus-build/Hephaestus/blob/main/docs/decisions/0026-per-purpose-agent-bindings-and-llm-governance.md) records the decision.
