Jaah-managed NodeLink storage per location
Epic: Shared account tokens — make the NodeLink flow (plan 6407; PRD 6405, architecture 6406). Item 2 of 10, except the absorb step (a separate child). **Security surface** (cross-org store reach, tenant authz on operator storage): run the autonomous security review lane before merge and state in the PR body that it ran, with its verdict.
Context (binding): PRD decisions D1–D8 and architecture decisions A1–A6 are settled; do not reopen them. Every capability is on by default for an org whose plan has the NodeLink term, with no new flag. "Live stack" means app.dev.jaah.ai after your PR's Jaah Deploy succeeds. The item is either shipped or rejected with a reason in the PR body.
Depends on the placement-seam child, `node_allocation.placement(session, node_ids) -> dict[UUID, Placement]` with `Placement(location: "hosting_vpc"|"main_vpc", shared_machine: bool)`. It also depends on the provisioning child: `StorageSourceFields` (props `{orgStores, value, onChange, managed: {available, location} | null}`), the provision-request/cron semantics, and `NodeLinkDetailOut.progress`.
## Requirement (verbatim)
2. "Add a "Jaah-managed" option on the NodeLink Storage tab for tenants whose nodes run on operator infrastructure (hosted machines or operator shared hosts). The operator plane holds the defaults per node location (hosting VPC vs. main VPC via the bastion route: account, region, subnet, Allowed CIDR, an operator-held AWS credential), so the tenant never types a subnet id or creates an AWS credential. Operator-plane setting(s) for those defaults are part of this item."
Decisions:
- **D1.** Jaah-managed storage is one **operator-owned EFS per node location** (hosting VPC; main VPC via the bastion route). Each tenant NodeLink gets **its own access point** on it. No new billing line.
- **A1.** Keep D1. Isolation is the access-point id plus network reach, the same "ids are the access control" limit that `docs/policies/nodelink.md` § Accepted limits already records. This child adds a managed-storage line to that section.
- **A4.** The operator provides the dev oracle for item 2 (an operator AWS credential and a dev tenant on operator infra).
## Approach (architecture, C1 minus absorb)
- **Model (the only data-model change).** Add `org_shared_stores.managed_location TEXT NULL`, CHECK in (`hosting_vpc`, `main_vpc`), with a partial UNIQUE index on `managed_location WHERE NOT NULL`.
- A managed store is an ordinary store row in the **operator-plane org**, using an **operator-plane AWS credential**.
- Tenant NodeLinks point at it across orgs through `nodelinks.store_id`, which is already in the tenancy gap register (`apps/api/app/tenancy/manifest.py:976-979`). Update that register entry's text to say the cross-org case is intentional for managed stores only.
- Use one shared store row, not per-tenant rows: `write_policy` builds the policy per store_id (`nodelinks.py:224-289`).
- The migration is additive.
- **Managed exception.** Each case below is an explicit `OR store.managed_location IS NOT NULL`, with a test per reader.
- `nodelinks.desired_for_node` (`:409`) and `ready_stores` (`:318-325`).
- `vnodes.shared_config`.
- `nodelinks.set_storage` (`:582`): allowed only for a managed store whose location equals the placement of every attached node.
- `_drop_store_if_empty` (`:548`): never drops a managed store.
- Adopt (`existing` with `access_point_id`) is refused on a managed store (422).
- Route reach checks (`routes/nodelinks.py::_require_reach_to_store`, `_require_reach_to_current`): for a managed store, the tenant's authority is `credentials:edit`, plus the plan term, plus every attached node's placement matching. The tenant never needs reach to the operator credential.
- Grep every `OrgSharedStore` select; a missed reader fails closed.
- **Tenant serialization.** `store_out` / `NodeLinkDetailOut` show a managed store as `{"managed": true, "name": "Jaah-managed", "status"}` plus the NodeLink's own access point id. They never show the FS id, subnet, CIDR, credential, SG or mount target. The node wire still carries `efs_id`/`mount_ip`.
- **Attach paths.** In `set_attachments`, `set_node_attachments` and `birth_set`, a node whose placement differs from its managed NodeLink's location gets a 409, "This node can't reach Jaah-managed storage."
- **Tenant API.**
- `NodeLinkStorageIn` gains `managed: bool = False`, an exclusive fourth choice next to `store_id`/`create`/`existing`. It cannot be combined with `access_point_id`.
- New route: `GET /api/org/nodelinks/managed-storage?nodelink_id=<uuid?>` under `@feature(credentials, view)`, returning `{"available": bool, "location": "hosting_vpc"|"main_vpc"|null, "reason": str|null}`.
- The node set is the NodeLink's attached nodes when `nodelink_id` is given, else the org's Linux nodes.
- `available` requires one shared placement for every node in the set AND that location's managed store to be `ready`. Mixed locations return false with a reason (2f).
- **Web (tenant).** Add the "Jaah-managed" option to `StorageSourceFields` (`managed` prop). It is the default when available and absent otherwise. A managed store gets the progress line with "file system" and "mount target" pre-marked done.
- **Operator API and UI.** Operator Settings gets a "NodeLink storage" section, operator-only (gated by `routes/settings.py::is_operator`), with one card per location.
- Routes:
- `GET /api/operator/nodelink-locations` → `[{location, store: StoreOut|null, tenants: int}]`
- `PUT /api/operator/nodelink-locations/{location}`, body `{create: StoreCreateIn} | {existing: StoreExistingIn}`
- `POST /api/operator/nodelink-locations/{location}/provision`
- The AWS account is shown read-only from the credential (its description or STS identity), never typed.
- Region, subnet and Allowed CIDR come from `StoreCreateIn`. The FS and mount target come from `StoreExistingIn`, or from the store once created.
- The credential is picked from the operator-plane AWS credentials.
- Reuse `routes/org_shared_store.py::new_created_store` / `new_existing_store` with the operator-plane org. Keys are opened server-side only (`org_shared_store._keys`).
- Replacing a location's store: existing NodeLinks stay on the old row, which loses `managed_location` only when emptied. Deleting a location store that still has NodeLinks returns 409.
- Here `existing` must NOT merge other orgs' rows; the absorb child adds that.
- **Delete (2e).** Unchanged: `_finish_delete` deletes the AP and rewrites the policy without it. The managed store is never dropped.
- **Policy doc.** Add the managed-storage line to `docs/policies/nodelink.md` § Accepted limits (single home; do not copy it elsewhere).
## Acceptance criteria (verbatim from PRD)
- 2a. Operator-plane setting(s) in the console (operator Settings) holding, per node location (hosting VPC; main VPC via bastion): AWS account, region, subnet, Allowed CIDR, operator-held AWS credential, and the operator EFS file system for that location. Editable only by operators. → verify: open operator Settings on dev, fill/see both locations.
- 2b. Storage tab shows a "Jaah-managed" source option when every node the NodeLink is (or will be) attached to runs on operator infrastructure (Jaah-hosted machine or operator shared host) and the matching location default is set; it is the default selection in that case. Otherwise the option is absent (not shown disabled) and the existing sources remain. → verify on dev: tenant with hosted nodes sees and can pick it; tenant with only self-hosted nodes does not.
- 2c. Choosing it provisions an access point **per NodeLink on the operator FS for that location** (D1) with the standard uid/gid 3000 and `/nodelinks/<id>` root; tenant never sees or types a subnet id, CIDR or credential, and the operator credential is never shown to the tenant. → verify: create via Jaah-managed on dev; NodeLink `ready`; the tenant's Secrets list unchanged; Storage tab shows "Jaah-managed" (no AWS ids beyond the access point).
- 2d. Isolation: a tenant NodeLink's AP cannot be adopted or attached by another org. → verify: API test that another org's adopt/attach of that AP is refused.
- 2e. Deleting the NodeLink removes its AP and leaves the operator FS intact. → verify: delete on dev; AP gone; other tenants' NodeLinks still `ready`.
- 2f. Mixed locations (nodes in both hosting VPC and main VPC): Assumed the option is offered only when all attached nodes share one location, because one NodeLink is one AP on one FS; say so if wrong.
- 3d (Jaah-managed route). "Applies to every provisioning route (create new, existing store, Jaah-managed, and the item-1 dialog)." The managed route shows the provisioning progress line and reaches `ready` with zero Provision presses.
Isolation tests required (architecture 2d):
- Another org's `PUT …/storage` naming the managed store's AP as `access_point_id` → 422.
- Another org's attach to the NodeLink → 404.
- Another org's read of the NodeLink or store → 404.
- The tenant serialization carries no FS, subnet, CIDR, SG, mount target or credential id.
- The node wire for a tenant's node lists only its own org's APs.
Credentials & reach:
- Visual checks, items 1, 3–10 on dev: `jaah/debug/main/operator-login` (operator; switches to tenants) on app.dev.jaah.ai. **Verified**: `task_credential_get` succeeded, and browser sign-in to app.dev.jaah.ai (1.2.535) succeeded from node h6-3.
- Item 2 live (an operator location store on dev and a tenant on operator infra): an operator-plane AWS credential in the dev console and a dev tenant with hosted/shared-host nodes. **Deferred**: neither exists in the roster or on dev. The operator answered "I'll provide it on dev" (A4) before C1's verification.
- All automated tests: worktree only (`./dc check full`); needs nothing beyond the worktree.
Out of scope:
- Epic: "Out of scope: any change to the NodeLink data model beyond what item 2's defaults need, the mount helper, tailnet ACLs, or the EFS policy model."
- Do not modify `packaging/jaah-shared-mount`, tailnet/headscale ACLs, or the EFS access-point policy model (`write_policy` semantics, uid/gid 3000, `tls,noexec,nosuid,nodev`).
- No NodeLink kind, managed flag or shared-account flag on `nodelinks`. The only column is `org_shared_stores.managed_location`. No code path keyed on the accounts path beyond `ACCOUNTS_MOUNT_PATH`.
- No new billing line or metering (D1). No change to the jaahd token-dir resolver semantics. No change to Org/System credential ownership rules.
- This child: NO absorb/merge of other orgs' store rows (the absorb child owns it). No per-tenant store rows on the operator FS. No per-NodeLink uid/IAM mounts (deferred follow-up). No share-subscriptions dialog (C3). No terraform change.
Done means:
- 2a–2f and 3d (managed route) as above, proven by API tests. That includes every isolation test listed, a test per reader that learned the managed exception, and placement-mismatch attach → 409. All tests run without AWS (fakes).
- The operator "NodeLink storage" cards are readable at 360 px. The tenant Storage tab shows only "Jaah-managed" plus the AP id, with no other AWS ids anywhere in the tenant DOM or API response.
- Live dev check for 2a–2c/2e: **(deferred — operator-plane AWS credential and a dev tenant on operator infra not provided yet, A4).** This child merges on automated tests and records the deferral in the PR body. Verify 2a's Settings UI renders on dev as the operator (an empty card is fine).
- `docs/policies/nodelink.md` § Accepted limits carries the managed-storage line, and the tenancy register entry is updated.
- The PR body records that the autonomous security review lane ran, with its verdict.
- `./dc check full` green; Jaah Deploy succeeded.
Outcome
Shipped Jaah-managed NodeLink storage per location (operator Settings cards + tenant option, operator-plane provisioning, placement-gated attach). Full gate green, security lane pass, PR #5528 merged, dev deploy ok. Live 2a–2e deferred (A4); follow-ups in report.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)