Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions docs/admin/bootstrap-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,33 @@ adminPolicy:

`catalogs.waasImages` (on by default) renders a **registry-wide**
`WorkspaceImage`: it approves *every* image under `docker.io/xorhub`
for the protocols `vnc`/`rdp`/`ssh`, and the api-server periodically
syncs the picker metadata (names, icons, versions, recommended
sizing/securityContext) from the published
[`catalog-waas-images.yaml`](https://github.com/XoRHub/waas-images/blob/main/catalog-waas-images.yaml)
(`apiServer.catalogSyncInterval`, default 6 h — cosmetic metadata only,
the approval itself never changes without an admin).
for the protocols `vnc`/`rdp`/`ssh`, and the api-server syncs the picker
metadata (names, icons, versions, recommended sizing/securityContext)
from the published
[`catalog-waas-images.yaml`](https://github.com/XoRHub/waas-images/blob/main/catalog-waas-images.yaml).
Cosmetic metadata only — the approval itself never changes without an
admin.

Three things trigger that sync:

- **creation or a source change**, without waiting for a tick — on the
console's PUT (synchronously, so the saved form already shows the
entry count) and on a `kubectl apply` alike, where entries appear
within seconds. The discriminant is `spec.catalog` itself: editing
display fields never refetches, only a **moved source** does;
- the **periodic pass**, `apiServer.catalogSyncInterval` (default 6 h);
- the per-image **Sync now** button (`POST
/api/v1/admin/images/{name}/sync`), the escape hatch when the manifest
*content* changed under an unchanged source — no automatic trigger can
see that.

:::caution Setting `catalogSyncInterval` ≤ 0
It disables **only the ticker** — creation/source-change and manual
syncs keep working — but nothing then retries a **failed** sync either.
A transient fetch failure while applying a new image leaves it without
catalog entries until an admin clicks *Sync now* or the source itself
moves. Only reasonable where syncs are driven deliberately.
:::

Implication: combined with the default policy's `images: []`, **any
authenticated user can run any official XorHub desktop image** on day
Expand Down
7 changes: 7 additions & 0 deletions docs/admin/daily-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ quota) that's fine, just mirror the change in Git.
images appear in the picker on their own (catalog sync) — review the
[waas-images releases](https://github.com/XoRHub/waas-images) rather
than the picker.
- **A new or repointed image syncs immediately** — console save or
`kubectl apply`, both show the discovered entries within seconds, and
the catalog table surfaces `lastSyncTime`, the entry count and
`lastSyncError`. Use **Sync now** when the catalog file was
re-published under the *same* URL (nothing can observe that), or to
retry after a failed fetch. See [the sync
triggers](bootstrap-governance#the-bootstrap-catalogs--what-approved-means-here).

### Policy and quota care

Expand Down
60 changes: 60 additions & 0 deletions docs/concepts/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,66 @@ the source of truth, Git seeds them. If ArgoCD manages them, a UI edit
is a manual override that the next sync overwrites; configure
`selfHeal`/`ignoreDifferences` accordingly.

## Sessions

Signing in — locally or through SSO — sets an **httpOnly session
cookie**. Nothing else: the portal stores no token, in `localStorage` or
anywhere else, and page scripts cannot read the cookie. There is no
credential in the browser for a malicious script to steal.

What that means day to day:

- **Reloading the page keeps you signed in.** The cookie survives it; the
portal re-reads your profile on load.
- **Nothing appears in the URL.** After an SSO round-trip you land on the
portal with a clean address bar.
- Sessions last `apiServer.accessTokenTTL` (8 h by default). That bound is
a fallback, not the security control — see below.

### Changes take effect on the next request, not in 8 hours

Every authenticated request re-checks the account behind the session. So
these are **immediate**, without waiting for any token to expire:

| Action | Effect |
|---|---|
| Deactivate a user | Their next request is refused |
| Change a user's role | Their session stops; they sign in again with the new role |
| Reset or change a password | Existing sessions end |
| Sign out | Every session of that account ends |

:::warning Signing out is global, not per device

Signing out on one browser ends that account's sessions **everywhere**.
That is the intended default for a security control — per-device sign-out
would require the platform to track each session individually, which it
deliberately does not.

:::

**One residual to know**: a desktop connection already open keeps running
until it is closed. The tunnel is authorized once, when the connection
opens, so revocation gates every *new* connection rather than tearing
down a live one. Pause or delete the workspace to cut an in-progress
session immediately.

### Scripts and CI

The cookie is for browsers. Anything else authenticates with the
`Authorization: Bearer` header, using the token `POST /api/v1/auth/login`
returns:

```sh
TOKEN=$(curl -s -X POST https://waas.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"ci","password":"…"}' | jq -r .data.accessToken)

curl -H "Authorization: Bearer $TOKEN" https://waas.example.com/api/v1/workspaces
```

The same rules apply to it — deactivate the account and the token stops
working on the next call.

## Audit

The api-server journals `workspace.created/denied/paused/resumed`,
Expand Down
162 changes: 142 additions & 20 deletions docs/concepts/placement.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,16 @@ From highest to lowest priority — enforced server-side:
`workspaces.defaultNamespacePattern`, shared by the operator and the
api-server. An invalid pattern makes both components **refuse to
start** — never a silent fallback;
3. **built-in `waas-workspaces`**: a single shared namespace.
3. **built-in `waas-{user}`**: one namespace per user.

The built-in default isolates because the namespace is where every
per-user protection attaches — ownership label, policy-derived quota,
default-deny NetworkPolicy — and because it matches the `waas-<user>`
prefix the webhook already treats as the owner's territory. A **shared**
namespace remains perfectly legitimate, but it is an explicit admin
choice (a literal pattern on a template or in
`workspaces.defaultNamespacePattern`), no longer what you get by
default.

Patterns accept these placeholders — each value is sanitized (NFKD,
lowercase, DNS-1123) and hash-suffixed on truncation/collision, so two
Expand All @@ -42,6 +51,28 @@ workspaces — existing ones keep their namespace, by design. Moving a
workspace means recreating it.
:::

:::warning Default changed in chart 0.3.0

The built-in default moved from the shared `waas-workspaces` to the
per-user `waas-{user}`. Existing workspaces are untouched (their
`spec.targetNamespace` is frozen); only **new** ones land per-user. To
keep the previous behavior, declare it explicitly:

```yaml
workspaces:
defaultNamespacePattern: "waas-workspaces"
```

**Retained home volumes do not follow.** A PVC is namespaced and only
attachable in the namespace it was left in, so homes retained in the old
shared namespace no longer appear when creating a workspace that now
resolves to `waas-<user>`. Moving that data is a storage operation,
outside the platform's scope: use the usual tooling (VolumeSnapshot, or
a backup/restore round-trip with Longhorn, Velero or equivalent).
Keeping the shared pattern, as above, avoids the question entirely.

:::

The portal shows the resolved namespace at creation time, and the
template editor lists the valid placeholders.

Expand All @@ -51,35 +82,112 @@ Created on first workload if missing — never modified afterward (your
admin edits are not overwritten):

- **Labels**: `app.kubernetes.io/managed-by=waas-operator`, the owner
label, and Pod Security labels (`enforce=baseline`,
label (personal namespaces only — the nominal case under the per-user
default), and Pod Security labels (`enforce=baseline`,
`warn=restricted`) — plus the template's
`placement.namespaceLabels/Annotations` (a server-side denylist
filters reserved domains: `kubernetes.io`, `xorhub.io`,
`argoproj.io`, service-mesh injectors, …).
- A **`waas-quota` ResourceQuota** derived from the owner's policy
aggregate caps (defense in depth — the webhook remains the primary
enforcement).
- A **default-deny ingress NetworkPolicy**: only the platform namespace
(where guacd/wwt run) can reach the desktops. Egress stays open.
- **No user RBAC**: users never talk to the Kubernetes API directly —
everything goes through the portal or your GitOps pipeline.
aggregate caps, personal namespaces only (defense in depth — the
webhook remains the primary enforcement).
- A **`waas-default-ingress` NetworkPolicy**, ingress *and* egress
(see below). It is the one bootstrap object the operator **reconciles**
— drift on it is healed.
- **No user RBAC**, and no ServiceAccount token mounted in the desktop
pod: users never talk to the Kubernetes API directly — everything goes
through the portal or your GitOps pipeline.

Namespace metadata is only one of the template's three metadata
surfaces, each with its own sync model: namespace labels/annotations
are **create-only** (above), workload metadata converges **by
rollout**, and home-PVC metadata (`spec.homeVolume`) is **synced in
place** with removal tracking — see [Volumes](volumes).

Shared namespaces (the built-in default, or `{os}`/`{templateName}`
patterns) get **neither** an ownership label **nor** an auto
ResourceQuota — a shared namespace quota would cap the whole team at
one person's budget; set your own namespace quota if you want one.
Shared namespaces — now the opt-in exception: a literal pattern like
`waas-workspaces`, or `{os}`/`{templateName}` — get **neither** an
ownership label **nor** an auto ResourceQuota, since they host several
owners and a shared quota would cap the whole team at one person's
budget; set your own namespace quota if you want one. The webhook stays
the per-user enforcement either way.

For non-admins, a namespace **deviating** from the server-resolved
default must either match the `waas-<user>` prefix (recomputed from the
trusted identity) or be an existing namespace labeled with their
ownership — anti-spoofing is webhook-enforced, fail-closed. System
namespaces (`kube-*`, the platform namespace) are refused for everyone.
default must either be an existing namespace labeled with their
ownership, or match the `waas-<user>` prefix recomputed from the trusted
identity — and in that second case only while the namespace is free or
already theirs. Since `waas-{user}` is now the default, a name inside
someone's prefix territory (`waas-alice-lab` for `alice`) may well be
the personal namespace of the user `alice-lab`, quota and retained
volumes included: the prefix is a name rule, not proof of ownership, so
the webhook refuses it with `PlacementDenied`. Anti-spoofing is
webhook-enforced, fail-closed. System namespaces (`kube-*`, the platform
namespace) are refused for everyone.

### What desktops can reach: the `waas-default-ingress` policy

Despite its (historical, kept to avoid orphaning policies already
stamped on existing namespaces) name, it shapes both directions:

- **Ingress**: denied except from the CR namespace and the release
namespace — that's where guacd/wwt run, and they need to reach the
desktops.
- **Egress**: **default-deny** as well. DNS (53 UDP+TCP, any
destination) is always allowed — a desktop that cannot resolve is a
broken desktop — then the public internet minus
`operator.desktopEgress.blockedCIDRs`, plus any `extraAllowedCIDRs`.

The blocked defaults are the cloud IMDS `/32` and the RFC1918 ranges, so
a desktop reaches neither the kube-apiserver nor the platform namespace.

:::warning Those defaults assume RFC1918 cluster networks
On a provider that puts the Service CIDR outside RFC1918 — GKE defaults
to `34.118.224.0/20` — the kube-apiserver stays reachable from desktops
until you append your own range. Check it:

```sh
kubectl get svc kubernetes -n default -o jsonpath='{.spec.clusterIP}'
```

Emptying `blockedCIDRs` means "carve out **nothing**", not "use the
defaults" — override entries, never reset the key.
:::

Desktops needing an internal service (a package mirror, a private Git)
get it through `extraAllowedCIDRs`, which wins over the blocked ranges.
`operator.desktopEgress.enabled: false` falls back to the historical
ingress-only policy — the escape hatch for CNIs that do not enforce
egress rules.

### Pod Security: why `baseline`, and how to raise it

`enforce=baseline` is **not** a statement that desktops need baseline.
Measured against the published catalog, the images start and serve
normally under `restricted` given `allowPrivilegeEscalation: false`,
`capabilities.drop: [ALL]` and `seccompProfile: RuntimeDefault`.

The level is `baseline` because **choosing it is the cluster
administrator's call, not the platform's** — the same reason the desktop
container ships no `securityContext` of its own. A hardened cluster
usually fills that field with a cluster-wide mutation policy (Kyverno,
`MutatingAdmissionPolicy`, Gatekeeper), and those are almost always
written *add-if-absent*: a pre-filled field would silently take desktop
pods out of your policy. `warn=restricted` is the canary — the cluster
tells you what raising `enforce` would cost, without breaking a session.

To raise it:

```sh
kubectl label ns <workspace-namespace> \
pod-security.kubernetes.io/enforce=restricted --overwrite
```

The bootstrap is create-only, so the operator never reverts your label.
Pair it with a template `workload.securityContext` (or a mutation
policy) supplying the three controls above, otherwise the namespace
refuses its own pods. The label cannot be set through
`placement.namespaceLabels` — every `*.kubernetes.io` key is denied
there, so a template author, who is not necessarily a platform admin,
can never lower a namespace to `privileged`.

## Namespace cleanup

Expand All @@ -95,6 +203,11 @@ creation**:
[retained home volume](volumes) keeps it alive, and the volume's
later deletion re-triggers the janitor.

With the per-user default, `Retain` means one `waas-<user>` namespace
per user survives the deletion of their last workspace — coherent with
the reason `Retain` exists (the retained home PVC lives there) and
reused by that user's next workspace.

## Workload naming

The api-server computes a workload name from the display name
Expand All @@ -106,9 +219,18 @@ renames the compute.
## One pitfall to know about

A template's `env.valueFrom.secretKeyRef` resolves in the **pod's**
namespace — the target namespace. A placed template referencing a
Secret that only exists in the platform namespace breaks at startup
(`CreateContainerConfigError`): provision the Secret in the target
namespaces (External Secrets/Vault), or don't place that template.
namespace — the target namespace, never the platform namespace,
whatever the pattern. A template referencing a Secret that only exists
in the platform namespace breaks at startup
(`CreateContainerConfigError`).

What the per-user default changes is that the target namespace is no
longer **known in advance**, so the Secret cannot simply be
pre-provisioned once. Two supported ways out: provision it into the
target namespaces (External Secrets/Vault), or pin the template to a
shared namespace known in advance where the Secret is pre-provisioned —
what the dev `dev-ssh` template does with
`placement.namespace: waas-workspaces`.

Protocol `credentialsSecretRef`s are **not** affected — they resolve
server-side in the platform namespace.
9 changes: 9 additions & 0 deletions docs/concepts/volumes.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ spec:
The webhook checks: same owner, volume actually retained, same target
namespace. The operator then re-labels the volume as live.

:::caution After a placement-pattern change
"Same target namespace" is a hard constraint — a PVC is only attachable
where it lives. Volumes retained under a previous pattern (e.g. the
shared `waas-workspaces`, the built-in default before chart 0.3.0) do
**not** appear when the new workspace resolves to `waas-<user>`. Moving
them is a storage operation: see the [migration
note](placement#how-the-target-namespace-is-resolved).
:::

## Template metadata on home volumes

A template can stamp labels and annotations on the home PVC via
Expand Down
31 changes: 31 additions & 0 deletions docs/guides/remote-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,37 @@ managed elsewhere.
- Clipboard policies apply to remote sessions exactly as to provisioned
ones.

## Targets the api-server refuses

A "remote machine" pointed at the cluster itself would turn the feature
into a pivot into your own network, so the api-server rejects:
loopback, link-local (the cloud IMDS at `169.254.169.254` included), the
kube-apiserver ClusterIP, single-label names, `*.svc` and
`*.<cluster domain>` names, plus any CIDR listed in
`apiServer.remoteBlockedCIDRs`. Add your cluster's **pod and service
CIDRs** there — they cannot be discovered from inside a pod. The cluster
DNS domain is auto-discovered from the api-server pod's
`/etc/resolv.conf` (`cluster.local` fallback); override it with
`apiServer.clusterDomain` only if that discovery is wrong for your
cluster.

The check runs at create, update **and** connect, so entries registered
before the guard existed are covered too. Two deliberate
non-restrictions:

- **RFC1918 addresses stay allowed** — a legitimate remote machine
commonly sits on a private LAN reached over VPN or peering. That is
also why `remoteBlockedCIDRs` does not default to those ranges.
- **A hostname that fails to resolve stays allowed** — registering a
machine that is off, or behind DNS the api-server cannot see, must
keep working.

:::warning Guardrail, not a security boundary
guacd re-resolves the name when it dials, so DNS rebinding bypasses the
check. The structural answer is an egress NetworkPolicy on the platform
pods (guacd/wwt), which does not exist yet.
:::

## Wake-on-LAN

A remote machine with a registered MAC address can be woken from the
Expand Down
Loading