> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# DD-0055 — Member SSH access: browser surface, laboratory and delivery

Continuation of [DD-0055 — Member SSH access to Icarus-hpc](/reference/docs/dd/0055-hpc-ssh-key-provisioning.md);
section numbers are kept so existing references stay valid.

## 6. Browser API

Routes from #678 are declared in `contracts/ssh_access/http_api/` and bundled into
`contracts/http/api.openapi.json` with `x-owner: ssh_access`. Commands use API command
bindings (idempotency key, expected version, actor from the session). Reads return
`Cache-Control: no-store`. Error bodies use the #678 codes, including the
multi-error gated `403`. Writes require same-origin, as already enforced by the API.

### 6.1 Site-scoped resources

The browser surface names its Site on every route, because there the caller is a
member session and membership must be authorised per request:

| Method         | Path                                                      | Operation                     |
| -------------- | --------------------------------------------------------- | ----------------------------- |
| `GET`          | `/api/v1/ssh-access/sites`                                | `listHpcSites`                |
| `GET`          | `/api/v1/ssh-access/sites/{siteId}/access`                | `getHpcAccess`                |
| `GET`/`POST`   | `/api/v1/ssh-access/sites/{siteId}/keys`                  | `listHpcKeys`, `submitHpcKey` |
| `POST`         | `/api/v1/ssh-access/sites/{siteId}/keys/validate`         | `validateHpcKey`              |
| `GET`/`DELETE` | `/api/v1/ssh-access/sites/{siteId}/keys/{keyId}`          | `getHpcKey`, `deleteHpcKey`   |
| `POST`         | `/api/v1/ssh-access/sites/{siteId}/keys/{keyId}/revoke`   | `revokeHpcKey`                |
| `POST`         | `/api/v1/ssh-access/sites/{siteId}/keys/{keyId}/resubmit` | `resubmitHpcKey`              |
| `GET`          | `/api/v1/ssh-access/sites/{siteId}/projection`            | `getHpcProjection`            |

`siteId` is the Site identifier the context configures (`[a-z][a-z0-9-]{0,62}`),
never free text typed by the member. `GET /sites` answers from the SSH Access
context as the intersection of the authenticated actor's member (`ActorMembers`)
and the Sites where that member has an account (`ActorMembers.identity`), as
`{id, name}`. It therefore never lists a Site the caller cannot act at, and the
platform Site catalogue is not used, because it lists Sites where the member has
no account.

One Site serves one process; the list is usually one entry,
and a member with several Sites simply receives several roots. The process
checks the requested Site against its configured one, and any other Site reads
as the same public `404 not_found`. That response is identical for a Site the
caller cannot act at, a Site that does not exist and a route without one, so
probing never reveals that a Site exists. A key that belongs to another Site
answers `key_not_found` from inside this Site's root, with no cross-Site signal.
The context keeps `site_not_found` internally for audit; the API maps it to the
constant public body.

The `/api/v1/settings/hpc/*` routes are removed with no alias: the only deployed
consumer is the compat page in development, and it moves with this contract.
These are defaults for review: one Site per process, `/sites` returning only
`id` and `name`, a hard cut of the previous address, and one public `404`.

On `main` the API session is the provisioned local operator; the fixture identity
port maps that actor to a test member. Member login arrives with Beto.

### 6.1.1 One Site for the whole Site API

The Site API is one listener, so the Site it serves is one setting rather than a
property of one route family: `SITE_API_SITE_ID`, in the same identifier form as
the context's Site (`[a-z][a-z0-9-]{0,62}`). The command routes and the SSH access
routes both read it, and the native `commands@<site>` and `ssh-access@<site>`
grants are checked against it before any storage read; a grant for another Site
answers the shared `site_mismatch`. The member-credentials route of the AAI work
reads the same setting instead of introducing a third scope.

`SSH_ACCESS_SITE` remains the SSH access context's own binding. `ssh-access-settings`
supplies both values, and a listener whose two Site values disagree does not start.
A listener configured with native grants but no `SITE_API_SITE_ID` also refuses to
start, because a check that cannot compare is not a check; a listener that serves
only Kubernetes identities may omit it, since a projected ServiceAccount token
names no Site. The 4B qualification lane renders the installed workload without the
`ssh-access-settings` ConfigMap and supplies `SITE_API_SITE_ID` as a recorded
lane adaptation.

The browser projection (typed Protobuf, Centrifugo) is available through the
minimal live key view. It reads the owner's snapshot after subscription, applies
only newer revisions and reconciles once after unrecoverable history. No polling
is introduced.

### 6.2 Realtime per Site

The publication aggregate is already Site-scoped: the root is
`root_id(member, site)`, and the projection payload carries `member_id` and
`site`. The channel model does not change: one server-authorised principal
channel per actor, with the Site travelling in the aggregate id and the payload,
so a member with several Sites receives several roots on the same channel.
Per-Site channel names would duplicate the principal model and invite
client-side channel selection, which the realtime rules forbid.

### 6.3 Known limitation: one request queue for every Site

Every `ssh-access` process serves one Site, but all of them consume the same
request queue. With a second HPC Site, a member request for the other Site can be
delivered to the process that serves this one, which resolves it against its own
Site and answers the same public `404 not_found`; `GET /api/v1/ssh-access/sites`
then lists only the Site of the Site API that process talks to. Nothing prevents
this today because one Site is configured.

The fix is either a queue per Site, with the API addressing the Site the browser
already sends, or one process for every Site that selects the root by `site_id`.
It is required before the second Site is configured, and it is tracked in #877.

## 7. Laboratory on `metallo-ikons-1`

```mermaid
flowchart LR
    subgraph VM["metallo-ikons-1"]
        subgraph K3S["k3s: Icarus-cloud (dev-ikons)"]
            EDGE["edge"] --> PLAT["Pharos platform<br/>API, Metallo, Beto"]
            EDGE --> WAL["Waldur 8.1.3"]
            AAI["local AAI"]
        end
        subgraph H["host: Icarus-hpc"]
            SLAPD[("slapd + openssh-lpk")]
            SSSD["SSSD + sshd"]
            SL["Slurm, Munge, MariaDB"]
            PA["pharos-agent"]
            PS["pharos-ssh-access"]
        end
    end
    PA -->|"waldur.pharos.&lt;domain&gt;"| EDGE
    PS -->|"site.&lt;domain&gt;/v1/site/"| EDGE
```

**Icarus-cloud:** the `dev-ikons` dev box (k3s, Argo CD) with the platform,
Waldur 8.1.3 and the local AAI. Development uses `make takeover`; committed changes
use `make reconcile`.

**Icarus-hpc:** the VM host, configured by a new laboratory under
`dx/metallo/icarus-hpc/`, outside `devops/ansible` and `gitops`. It owns the
OpenLDAP with `slapo-unique`/`slapo-constraint`, SSSD, Slurm, Munge, MariaDB,
coordinator and `job_submit.lua` configuration instead of extending the removed
control-plane laboratory. On 21 October 2026 the VM had none of these host services installed.

Additions:

- `openssh-lpk` schema; ACLs for `cn=pharos-agent` (no `sshPublicKey` write) and
  `cn=pharos-ssh-access` (`sshPublicKey` and only the `ldapPublicKey` class value
  write, identity attributes read).
- SSSD `services = nss, pam, ssh`, `ldap_user_ssh_public_key = sshPublicKey`;
  sshd `AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u`,
  `AuthorizedKeysCommandUser nobody`, `AuthorizedKeysFile` limited to local users.
- Site agent as `pharos-agent` (pinned upstream release; `ldap` with
  `account_source: waldur` and `ldapPublicKey` in `user_object_classes`; `slurm`
  with `root_account: pharos`).
- Access agent as `pharos-ssh-access`.
- Host resolution of `site.<domain>` and `waldur.<domain>` to the
  dev box edge, and trust in their CAs.
- Waldur bootstrap as an administration job (staff token never in a runtime
  process): service provider, HPC Icarus offering with
  `service_provider_can_create_offering_user`, `identity_claim`, POSIX pool, no
  offering-user auto-deletion, Projects A and B, Orders, test users `alice` and `bob`
  with `site_username` from the local AAI.

## 8. Verification

| Lane                              | Covers                                                                                                                                                                                                                                                   |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit                              | #678 transition table, gate order, key validation cases, generation fencing, propagation window                                                                                                                                                          |
| PostgreSQL integration            | Concurrent submissions, duplicates in every state, lost response retry, resubmit keeping `key_id`, stale and replayed Site results, AUP bulk revocation, restricted role                                                                                 |
| API contract                      | Every #678 route and error code, validate/submit parity, gated `403`, origin and content type                                                                                                                                                            |
| Site API                          | Grants: access-agent credential only on SSH routes, Runtime credential refused there, expired and revoked credentials, Site derived from credential                                                                                                      |
| Access agent + OpenLDAP container | ACL enforcement in both directions, missing entry, `uidNumber` mismatch, first/last key and auxiliary class changes, unmanaged values preserved, crash after modify before acknowledgement                                                               |
| Lab e2e (`metallo-ikons-1`)       | Waldur membership → LDAP entry → `id`; key before and after provisioning; `ssh` succeeds; revoke → `ssh` fails after the window; leaving one of two Projects keeps keys; leaving all keeps login but `sbatch` fails; agent restart; measured propagation |

Before hand-off: `pnpm verify` (Python 3.13), `pnpm test:integration`,
`pnpm check:large-file-review`, and an explicit report of skipped environment lanes.
Evidence without credentials goes to `.local/icarus-hpc/<target>/evidence`.

## 9. Delivery

Branch from `main`. Each step is a separate PR below roughly 1,000 changed lines.

```mermaid
flowchart LR
    P1["1 context"] --> P2["2 browser API"]
    P1 --> P3["3 Site protocol"]
    P3 --> P4["4 access agent"]
    P5["5 Icarus-hpc lab"] --> P6["6 e2e lane"]
    P2 --> P6
    P4 --> P6
    P6 --> P7["7 projection + page"]
    BETO["Beto merged"] --> P8["8 Beto adaptors"]
    P2 --> P8
```

| # | Scope                                                                          | Done when                                           |
| - | ------------------------------------------------------------------------------ | --------------------------------------------------- |
| 1 | `ssh_access` domain, application, PostgreSQL adaptor, migrations, registration | Unit and PostgreSQL lanes pass                      |
| 2 | Browser API contract and bindings, fixture ports                               | API contract lane passes                            |
| 3 | Site routes, native credential verifier, edge route                            | Site API lane passes                                |
| 4 | Access agent and systemd packaging                                             | OpenLDAP container lane passes                      |
| 5 | Icarus-hpc laboratory and Waldur bootstrap (parallel to 1–4)                   | Provisioning scenario passes on `metallo-ikons-1`   |
| 6 | E2E lane                                                                       | All lab scenarios pass, propagation measured        |
| 7 | Browser projection and page                                                    | Realtime recovery lane passes                       |
| 8 | Beto adaptors after the Beto branch merges                                     | Fixtures removed from non-development configuration |

## 10. Open questions

- Context name (`ssh_access` proposed) and AUP source (ADR-0055 open questions).
- LDAP transport on Icarus-hpc: the access agent uses `ldapi://` with SASL EXTERNAL;
  the upstream Site agent supports simple bind only and uses loopback `ldap://`.
  Confirm the listener and service-user mapping with jimsiak.
- Site agent ACL change (jimsiak). No existing entry needs `ldapPublicKey`.
- Acceptance of the distributed owner's separate Site API hostname route commit,
  including route timeouts/body limits and equivalent staging hostname/TLS evidence.
- Username consistency once Beto lands: Party Site binding, Waldur offering-user
  username and LDAP `uidNumber` must agree before any write.

<details class="page-source"><summary>Page source</summary>

<a href="/source/docs/dd/0055-hpc-ssh-key-provisioning-browser-and-operations.md.txt">Source snapshot</a> · Revision `f928834c8`.

</details>
