Repository navigation
api: let operators manage keys, terminals and snapshots across orgs - #1302
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
d1770b1 to
1df83a8
Compare
5bf4d23 to
b077a03
Compare
Review — 4 findingsI'm holding my approval for now. The findings below don't block. Low
Verified against
I also checked that the |
b077a03 to
01bbc0a
Compare
|
Thanks. All four are addressed; the head is now
The rebase also moved On a Kind cluster at
Could you take another look? |
Re-review — 3 new findingsOf the four points in my earlier comment:
Low
Verified against |
d40c048 to
85b977b
Compare
Managing-org admins can create, list and revoke API keys for any organization, open terminal sessions in any sandbox, list any sandbox's snapshots, and roll back through :force-rollback, which now carries the public :rollback body. Each runs under the owning organization. Admin audit events record the organization and resource a handler acted on, including reads of another organization's snapshots. Key auth records last_used_at at most once per five minutes, revocation keeps the row with revoked_at, and org branding values are validated.
Admin key creation takes an optional user_id, defaulting to the org's oldest user, and stores it as created_by, so every release resolves the key's actor with the existing lookup; the auth change is reverted. :force-rollback refuses unless rollback is enabled for the sandbox's org, and shares the public body parser. Audit targets are attached to refusals once the resource resolves, and to admin sandbox create and update. The admin terminal route has its own idempotency scope. Branding is checked only where a patch changes it, revoke is one statement, and the admin OpenAPI entries are edited in place.
Without user_id, a key for the caller's own organization acts as the caller, as before; a key for another organization keeps the oldest-user default. A chosen user deleted before the insert answers 409 instead of 500. The admin commands route records its idempotency keys under its own path template, as the admin terminal route does, so each route keeps its own idempotency records.
… the shared parser The admin terminal-session and snapshot-list routes accepted only an sbx_ ID, while every other admin sandbox route takes the forms parse_admin_sandbox_id accepts: a dashed or undashed UUID in either case, or the sandbox pod name. They now resolve through it too, and record the canonical sbx_ ID in the session, the idempotency fingerprint and the audit target. The OpenAPI entries describe the accepted forms. A test sends each accepted form to the terminal, commands, :force-rollback and snapshot routes under one idempotency key, and checks that every form replays the first request and is audited against the canonical ID, and that refused forms answer 400.
A key created for an organization other than the caller's, without user_id, answered with a key acting as that organization's oldest user, a choice the operator never made. It now answers 400 "user_id is required for another organization's key". A key for the caller's own organization without user_id still acts as the caller. The oldest-user lookup and the 409 for an organization with no users are gone with the default; the handler doc, the OpenAPI entry and the tests follow.
Checkpoint and enable_snapshots are hidden on the public API too, but the admin surface ran them ungated before this change, and they only add snapshots. Rollback is gated because this route could not run it until it carried the :rollback body, and it rolls the sandbox back to a snapshot.
The admin /commands route mirrors a public, org-scoped route, as the terminal-session and snapshot routes do, so it now lives with them in sandboxes::guest_access and resolves its sandbox through the same helper. Its behaviour is unchanged. This keeps admin/sandboxes.rs within archcheck's 1,000-line limit, which the rebase onto #1321 had pushed it past, and the error-body note in admin/mod.rs names the mirror routes.
… comment The ID-form test compared each form's snapshot page, but no snapshot was seeded, so every form answered an empty page and the comparison could not fail. It now seeds one snapshot and compares the first snapshot's id across forms, then checks that it is the seeded one, so every form must resolve the same sandbox. The chosen-user key test's comment said choosing either user differs from picking the organization's first user, but the seeded member is the oldest user. It now says what the test distinguishes: a key that chooses the tenant admin acts as that admin, not as the oldest user.
…ough the router A key revoked after its lookup read it could still have its last_used_at stamped, because the recording UPDATE's subquery did not check revoked_at. It now does, and a test revokes a key between its lookup and the recording of its use. The deleted-user 409 test called create_error directly, so it still passed with the handler reverted to support::db_error. It now creates the key through the admin router, with a test-database trigger that deletes the chosen user as the key row is inserted. The admin /commands route's doc comment now says that it does not apply the public route's lifecycle admission or implicit wake.
85b977b to
1130a69
Compare
|
Thanks. All three are in
The rebase onto #1303 kept both PRs' behaviour in the two files they share:
|
Summary
Cluster operators are managing-organization admins who support every
organization, but several of their actions only existed as org-scoped public
routes or were refused for other organizations. This lets the admin API do
them for any organization, under the owning organization, and records each one
in the persisted admin audit trail with the organization and resource it acted
on. Every route here already requires a managing-organization admin through the
admin middleware; tenant admins get 403 and unauthenticated callers 401.
POST /orgs/{org_id}/api-keysno longerrefuses organizations other than the caller's (it answers 404 for an unknown
one). The key belongs to the target organization and keeps its
allowed_surfacespermission model. It acts as a user chosen at creation:user_id, which must be a user of that organization (400 otherwise).Without it, a key for the caller's own organization acts as the caller, as on
main. A key for another organization without it answers 400 "user_id is
required for another organization's key": the route doesn't pick a tenant
user for the operator. That user is stored as the key's
created_by, thecolumn the existing key lookup already reads, so every release, before or
after this change, resolves the key to the same user. A chosen user deleted
before the key is stored answers 409, asking to retry or choose another. For
another organization's key, the calling admin is recorded in the audit event,
not on the key.
List and revoke already accepted any organization. This lets the console
manage tenant keys without
scripts/seed-api-key.sh, and choose the user akey acts as; ui: manage tenant keys, terminals, snapshots and rollback across orgs, and show node and template state #1387 adds that to the console.
POST /sandboxes/{sandbox_id}/terminal-sessions,the admin equivalent of the public route, beside the existing admin
/commands. It resolves the sandbox without an org filter and creates thesession under its owning organization through the same session store as the
public route. So its credential is no wider: an attach token valid for this
one session and the
terminal:connectscope, expiring 5 minutes aftercreation, with a 60 s attach deadline and a 24 h session. Its idempotency keys
are recorded under the admin route's own scope, and so are the existing admin
/commandsroute's, so each route keeps its own records. The tenantsees the session in its own session list. This lets the console open terminals
in other organizations' sandboxes.
GET /sandboxes/{sandbox_id}/snapshotsreturns the public snapshot page (same filters, cursor and limit) under the
owning organization, whether or not that organization can see the hidden
public route.
:force-rollbacknow takes the public:rollbackbody(
{"snapshot_name", "auto_restart"?}), parsed by the public route's parser,and passes it to the operation. Before, it enqueued a rollback with no
snapshot, which the executor always failed with "rollback action is missing
rollback request". It now answers 400 without a body. Rollback stays behind
the public
:rollback's hidden-operation gate::force-rollbackanswers 404"rollback is not enabled for the sandbox's organization" unless
SPACE_NATIVE_HIDDEN_OPERATIONS_ORGSor thespace-native-hidden-operationsflag enables rollback for the sandbox's organization, the same check the
public action makes. Closing the gate closes both routes. It is the only
hidden action the admin surface gates:
:force-checkpointand:force-enable_snapshots, also hidden on the public API, already ran ungatedon main and only add snapshots, while rollback, which the admin route could
not run before this change, rolls the sandbox back to a snapshot. The body is
part of the idempotency fingerprint; every other action's fingerprint is
byte-identical to before, so replays across releases still match. This lets the
console drop its read-only debug-route fallback for other organizations'
snapshots, and roll back where rollback is enabled.
last_used_atandrevoked_at.The auth path never wrote
last_used_at. It now does, coarsely: a lookupwrites only when the value it just read is more than 5 minutes old, so an
active key costs at most one
UPDATEper window. TheUPDATErechecksagainst the database clock and uses
SKIP LOCKED, so concurrent requestsdon't queue on the row. The write is best effort and never fails the request.
It records the last authentication through the API. Data-plane uses are not
recorded: the ECW data-plane route resolver checks a key without writing, so a
key used only for guest traffic keeps its last API use. Revocation now keeps
the row, sets
revoked_at, and replaceskey_hashwithrevoked:<id>,which no SHA-256 digest equals. So the key stops authenticating on every
reader of
key_hashat once, including older API releases and the data-planeresolver. Revoked keys are listed only with
GET .../api-keys?include_revoked=true, so the current console, which doesn'tread
revoked_at, never shows a revoked key as live. Revoking twice answers204 and keeps the first time.
PATCH /orgs/{org_id}andPOST /orgsrejecttheme_primary/theme_secondaryother than#rgbor#rrggbb, and alogo_urlthat isn't an absolutehttpsURL without credentials (httponly on
localhost,127.0.0.1or[::1]). They answer 400 with{"error", "field"}.PATCHchecks only a value that differs from thestored one, so the console's save, which re-sends all three fields, still
works for an organization whose stored value predates validation.
nullstill clears a value.
AdminAuditTargetresponseextension; the admin middleware persists it into the new
target_org_id,target_type(api_keyorsandbox),target_idandtarget_user_idcolumns, which
GET /audit-eventslists. Handlers attach it once theresource resolves, on refusals as well as successes, and name only what
resolved. That covers key create (with the key's user) and revoke, the
terminal route,
/commands, the snapshot list, adminPOST /sandboxesandPATCH /sandboxes/{sandbox_id}, and every:force-*action on an existingsandbox. A refused revoke of another organization's key through this
organization's path names no organization. Every admin write is persisted as
before. Of the reads, only those that name a target are persisted, which today
means only the snapshot list, including its refused queries. The secret never
enters an audit event.
The new terminal and snapshot routes resolve the sandbox through
parse_admin_sandbox_id, as/commands,:force-rollbackand every otheradmin sandbox route do since #1321. All four accept
sbx_<uuid>, a dashed orundashed UUID in either case, or the sandbox pod name, and record the canonical
sbx_ID in the session or operation, its idempotency fingerprint and the audittarget.
/commandsnow lives with the terminal and snapshot routes insandboxes/guest_access.rs, which keepsadmin/sandboxes.rswithin archcheck'sfile-size limit.
The admin OpenAPI document describes the new routes, fields and refusals.
A follow-up commit makes a key revoked after its lookup keep its
last_used_at, drives the deleted-user 409 test through the admin router so it fails with the handler reverted, and documents that the admin/commandsroute applies neither the public route's lifecycle admission nor its implicit wake.Validation
At this head, rebased onto main
0271415aafter #1321:cargo nextest run -p pplx-space-api --all-features: 2212 passed, againstPostgres and Redis started by the nextest setup scripts. The only test not
run is the
#[ignore]occupancy benchmark.Route tests through the real admin router and middleware: operator allowed;
tenant admin 403; unauthenticated 401; audit event fields, including on
refusals; validation errors. They also cover:
user_id, the caller for an own-organization key, and a 400 thatcreates no key for another organization's, audited against that
organization;
user_id, and 409 when the chosen user is deleted beforethe insert;
and body staying apart;
/commands,:force-rollbackand snapshot routes undersbx_, bare, upper-case undashed, pod-name and whitespace-padded IDs: oneidempotency key under every form replays the first request, the rollback
operation targets the canonical
sbx_ID, and so does each audit event,while
SBX_, braced,sandbox-and dashed pod-name forms answer 400;The gate, terminal-scope, command-scope, own-organization default,
required-
user_id, deleted-user and ID-form tests fail with their fix undone.New Postgres-backed tests cover revocation, including inside a rolled-back
transaction, and the last-use throttle.
cargo clippy -p pplx-space-api --all-targets --all-features -- -D warningsand
cargo fmt --all -- --check: clean; every commit builds on its own.cargo archwith main's baseline andARCHITECTURE.toml: the same 112advisory findings as main, none required.
On a local Kind stack at the previous head
b077a034, with synthetic fixtures(since deleted), as a managing-organization admin:
user_id, and created a sandbox with each, ownedby the key's user;
user_id, and sawthe caller as its
created_by;user_id;key and body to the public route got a separate session, and the operator's
token was unchanged;
:force-rollbackrefused with the gate closed, with nothing enqueued;case;
Kind was not rerun at this head. The 400 for another organization's key
without
user_idand the non-sbx_IDs on the terminal route are covered bythe router tests only. An earlier run of this branch covered listing a
tenant's snapshots while the public route stayed hidden,
last_used_atandinclude_revoked.Not validated: a successful rollback. With the gate open, the operation reaches
the node, which fails it with "sandbox host mutation or reset is in progress;
retry" and then a missing
rootfs.ext4.space-sbxmgr rollbackrun on the nodeitself, with no API involved, fails the same way, so this is not specific to the
route.
Severity and risk
Severity: does not apply; this adds operator capabilities and fixes the inert
:force-rollback.Risk:
open terminals in, and read the snapshots of any organization. Each is
audited with the organization and resource.
:rollbackis closed.with
user_id, and 400 without it, instead of 403. A body-less:force-rollbackanswers 400 instead of enqueueing an operation that alwaysfailed, and a gated one 404. Revocation no longer deletes the row.
without
user_idacts as its caller, as on main. If a key's user is deleted,the key falls back to the organization's oldest user, as every release
already does for a NULL
created_by.someone changes it.
PATCHit with a valid value, ornullto clear it.UPDATEper 5 minutes.Rollout
Requires the stacked migration. While old and new API pods run together:
revoked_atuntil they are replaced.DELETEstill hard-deletes.:force-rollbackignores its body and enqueues a rollback thatfails, as it did before.
/commandsidempotency keys are recorded under a new scope. A retrythat moves between an old and a new pod misses the earlier record, so it can
start a second command session and run the command twice. New pods
don't fall back to looking up records under the old scope, so each route
keeps only its own records from the start. The admin terminal route doesn't
exist on old pods, so its new scope has no such effect.
stored in the
created_bycolumn that every pod's lookup reads.Old pods answer 422 to any body containing
user_id, and 403 to anotherorganization's key without it; new pods require
user_idfor anotherorganization's key. Finish the API rollout before the console sends that field.
Today's console creates keys only for the caller's own organization, so the new
400 does not reach it. The
new
include_revokedquery parameter defaults to false.To roll back, revert this change; the migration's columns can stay. After a
revert, keys revoked in the meantime still can't authenticate, because their
key_hashwas replaced. But they list as live, since the reverted list doesn'tfilter on
revoked_at, until they are deleted, for example withDELETE FROM api_keys WHERE revoked_at IS NOT NULLor the migration's downstep.
:force-rollbackagain ignores its body.Stack
Builds on #1301, now merged, which added the columns this uses
(
api_keys.revoked_at, and the admin audit event targets includingtarget_user_id). Rebased onto #1321, which added the admin sandbox list'spaging and filters and the shared sandbox ID parser; both rewrote
admin/sandboxes.rs, and the rebase keeps both changes.AI assistance: Claude Code drafted the code, the tests and this description.