Kerbside VDI tokens phase 2: the vdiconsoleproxy minting endpoint¶
This is phase 2 of
PLAN-kerbside-vdi-tokens.md.
It delivers the first token: a REST endpoint that mints a
short-lived Ed25519-signed JWT for an instance the caller
owns, and returns a kerbside exchange URL. It builds directly
on phase 1's
signing key (shakenfist/util/vdi_tokens.py,
active_signing_key(get_signing_material())).
Kerbside does not consume anything from this phase yet — that is phases 5-6. This phase is provable end to end against a unit test that verifies the minted token with phase 1's published public key.
Decisions¶
These refine the master plan's open questions 2, 5 and 8 for the surface this phase builds, in the same way phase 1 refined questions 1 and 8. They stand in for the not-yet-written phase 0 for this endpoint.
-
Two new config fields, declared as Pydantic
Fields onSFConfig(shakenfist/config.py) exactly likeDNS_SERVER(config.py:255), read asconfig.KERBSIDE_URL/config.KERBSIDE_TOKEN_DURATION: -
KERBSIDE_URL: str = Field('', description=...)— the kerbside deployment's public base URL, e.g.https://kerbside.example.com. Empty default means the feature is off. This doubles as the tokenaud(open question 4: kerbside rejects any token whoseaudis not its own URL). KERBSIDE_TOKEN_DURATION: int = Field(300, description=...)— token TTL in seconds (open question 2's recommended default).
Both are cluster-wide "like DNS_SERVER": settable through
cluster_config, which load_cluster_config() injects as
SHAKENFIST_* env at process start. Restart semantics
accepted: because the endpoint reads the config singleton
(instantiated once at config.py:670), a change to either
field is seen after the API daemon restarts — correct for
deploy-time settings, and unlike the signing key (which
phase 1 deliberately reads live via
mariadb.get_cluster_config() so a freshly-created key
needs no restart). No live-read is needed here: an operator
turning the feature on restarts the API tier anyway.
- Minting lives in
util/vdi_tokens.pybeside the phase 1 key helpers, as one testable function:
def mint_console_token(
instance_uuid: str, namespace: str, *,
audience: str, issuer: str, duration: int,
) -> dict[str, Any]:
... # returns {'token', 'jti', 'kid', 'expires_at'}
It reads the active key via
active_signing_key(get_signing_material()), builds the
claim set below, and jwt.encode(...)s with
algorithm=SIGNING_ALG and headers={'kid': <active kid>}.
It takes audience/issuer/duration as explicit
arguments rather than reading config itself, so it stays
unit-testable without mocking the config singleton (the
endpoint reads config and passes them). It raises
SigningKeyError (re-used from phase 1) if no signing key
exists, so the endpoint can translate that to a clear HTTP
error. jwt.encode returns a str on PyJWT 2.x; no decode
needed.
- Claim set (normative; from the master plan's token-flow section):
| Claim | Value |
|---|---|
iss |
config.ZONE (matches util/access_tokens.py; verify the attribute name in 2a) |
aud |
config.KERBSIDE_URL |
sub |
the instance UUID |
sf:namespace |
the owning namespace — audit/display only; kerbside must not authorise from it |
iat |
int(time.time()) |
exp |
iat + duration |
jti |
uuid.uuid4().hex |
header kid |
the active key's kid |
- Endpoint
InstanceVDIProxyConsoleHelperEndpointinshakenfist/external_api/instance.py, modelled onInstanceVDIConsoleHelperEndpoint(instance.py:1442) but with a deliberately different decorator stack — without@api_base.redirect_instance_request:
@swag_from(api_base.swagger_helper(...))
@api_base.verify_token
@api_base.arg_is_instance_ref
@api_base.requires_instance_ownership
@api_base.log_token_use
def get(self, instance_ref=None, instance_from_db=None):
Minting needs no node-local state (no SPICE CA file, no
node ports), so the endpoint must answer from any API
node; carrying redirect_instance_request would wrongly
proxy it to the instance's hypervisor. Ownership is still
enforced (requires_instance_ownership), which is the whole
authorisation story (mission goal 1).
-
Guards and their HTTP codes (use
sf_api.error(code, msg), the file idiom): -
config.KERBSIDE_URLempty → 404sf_api.error(404, 'kerbside integration is not configured'). Clean feature-off signal; a client probes thevdi-console-proxycapability first anyway. - Signing key absent (
get_signing_material()isNone, surfaced asSigningKeyError) → 500 naming the fix:'kerbside signing key is not configured, run sf-ctl ensure-kerbside-signing-key'. Distinct from the 404 becauseKERBSIDE_URLbeing set means the operator intended the feature on, so a missing key is a real misconfiguration, not the off state. - Instance not
created(instance_from_db.state.value != dbo.STATE_CREATED) → 406 (matches base.py's "instance is not ready" 406 atbase.py:358). -
No SPICE console (
not instance_from_db.video['vdi'].startswith('spice')) → 409'instance does not have a SPICE console'. -
Response — JSON body, never a 3xx (the Python client follows redirects silently, master plan situation note):
expires_at is epoch seconds (== the token exp), matching
the rest of the token machinery (created in
vdi_tokens.py). Build the URL by stripping any trailing
slash from config.KERBSIDE_URL and appending
/sf-console.vv?token=<jwt> (kerbside route decided in open
question 8).
- Audit event on the instance, never carrying the token:
instance_from_db.add_event(
EVENT_TYPE_AUDIT, 'vdi console proxy token minted',
extra={'jti': jti, 'kid': kid, 'namespace': namespace,
'expires_at': expires_at})
- Capability
vdi-console-proxyadded to the instance capability line in the root-page HTML (external_api/app.py, near the admin line phase 1 edited), so clients and kerbside can probe for support.
Execution¶
All work in the shakenfist-wt-vdi-tokens worktree (branch
vdi-console-tokens), by sub-agents per the master plan's
model. Review each step's actual files, not its summary.
2b depends on 2a; 2c depends on 2a and 2b.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 2a | high | opus | none | Add KERBSIDE_URL: str = Field('', ...) and KERBSIDE_TOKEN_DURATION: int = Field(300, ...) to SFConfig in shakenfist/config.py, matching the DNS_SERVER field style (config.py:255). Add mint_console_token(instance_uuid, namespace, *, audience, issuer, duration) -> dict to shakenfist/util/vdi_tokens.py per decision 2/3: import jwt, build the claim set, sign with active_signing_key(get_signing_material())'s private_pem, algorithm=SIGNING_ALG, headers={'kid': ...}; return {'token', 'jti', 'kid', 'expires_at'}; raise SigningKeyError when no key. jti = uuid.uuid4().hex, iat = int(time.time()), exp = iat + duration. Verify config.ZONE exists and is the right issuer source (grep util/access_tokens.py); if the attribute differs, use the real one and note it. Do not read config inside the helper. Never log the token. env -u FORCE_COLOR pre-commit run --all-files. |
| 2b | medium | sonnet | none | Add InstanceVDIProxyConsoleHelperEndpoint to shakenfist/external_api/instance.py with the decorator stack in decision 4 (copy InstanceVDIConsoleHelperEndpoint at instance.py:1442, drop @redirect_instance_request). Implement the guards in decision 5 (order: config-off 404, then state 406, then SPICE 409; signing-key 500 comes from catching SigningKeyError around the mint call). Read config.KERBSIDE_URL/config.KERBSIDE_TOKEN_DURATION/config.ZONE, call vdi_tokens.mint_console_token(instance_from_db.uuid, <owning namespace>, audience=..., issuer=..., duration=...), build the response in decision 6, emit the audit event in decision 7. Find the owning-namespace attribute on the instance (likely instance_from_db.namespace — verify) for the sf:namespace claim and audit extra. Register GET /instances/<instance_ref>/vdiconsoleproxy in app.py beside the vdiconsolehelper route (app.py:355). Add vdi-console-proxy to the instance capability line in the root HTML. swag_from with a realistic {url, expires_at} example. No token in any log or in the response beyond the url. env -u FORCE_COLOR pre-commit run --all-files. |
| 2c | medium | sonnet | none | Tests. In shakenfist/tests/test_vdi_tokens.py (extend), add mint_console_token unit tests: claims correct (iss/aud/sub/sf:namespace/jti present, exp - iat == duration), header kid == active kid, and the token verifies against phase 1's public_view public PEM via jwt.decode(..., algorithms=['EdDSA'], audience=<aud>); and SigningKeyError when no key is configured. In shakenfist/tests/external_api/ (extend the phase-1 test_admin.py sibling or add an instance-endpoint test matching that dir's harness): ownership enforcement — namespace A gets 403/404 minting for namespace B's instance (use the existing ownership-test pattern); feature-off 404 when KERBSIDE_URL empty; 500 when the key is absent; 406/409 state and SPICE guards; happy path returns a url containing /sf-console.vv?token= and an integer expires_at; the audit event is recorded; the response body contains no private material and no bare token field other than inside url. env -u FORCE_COLOR pre-commit run --all-files. |
Success criteria¶
GET /instances/<ref>/vdiconsoleproxymints a token only for a caller who owns the instance; a non-owner is refused byrequires_instance_ownershipexactly as the direct vdiconsole endpoint refuses them.- With
KERBSIDE_URLempty the endpoint 404s cleanly; with it set but no signing key it 500s naming thesf-ctlcommand. - The minted token carries the decision-3 claims, expires after
KERBSIDE_TOKEN_DURATIONseconds, and verifies against the public key phase 1 publishes at/admin/vditokenpubkey(this is the phase-5 kerbside contract, proven now). - The response is a JSON
{url, expires_at}, never a redirect; the URL points at<KERBSIDE_URL>/sf-console.vv?token=<jwt>. - An audit event records
jti/kid/namespacebut never the token; no path logs the token or any private key material. pre-commit run --all-files(flake8, stestr, mypy) passes.
Out of scope¶
Kerbside's consumption of the token (phases 5-6), the client
method and CLI launch (phase 4), pip-installable ryll
(phase 3), host_subject (phase 6), and documentation
(phase 8, though the config-field description= text and
docstrings land here). This phase does not touch kerbside.