Citrix SecurSpaces™

Author a PersistentVolume for Attach Existing

SecurSpaces never creates, changes, or cleans up these volumes. It only creates a claim that binds to your volume by name.

Set the scope label

SecurSpaces records the owner of storage it creates. For a volume you author, it has no such record, so you must declare the intended consumer scope with the strong.network/mount-point-scope label.

Label value Who can attach it
project.<projectId> Only that project.
org.<organizationId> Any project in that organization.
shared Any project in the whole deployment.

Use a dot, not a colon. Kubernetes forbids : in a label value.

A volume with no scope label, or an unrecognized value, is offered to nobody and cannot be attached. A forgotten label can never accidentally expose a volume, but it is the most common cause of “my volume is not in the list”.

Both project. and org. scopes are enforced by server-side checks, on both the picker and the bind. The shared value has no boundary. Use it only for data you are content for anyone in the deployment to reach.

Find the numeric project or organization id in the browser address bar. The id is the path segment after /project/ or /organization/.

Meet the volume requirements

apiVersion: v1
kind: PersistentVolume
metadata:
  name: shared-dataset-alpha
  labels:
    strong.network/mount-point-scope: "project.1090137447483732"
spec:
  capacity:
    storage: 1Gi
  accessModes: ["ReadWriteMany"]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  mountOptions: ["tls"]
  csi:
    driver: efs.csi.aws.com
    volumeHandle: fs-0abc123::fsap-07c9def
    # volumeHandle: s3files:fs-0def456::fsap-001d    # Amazon S3 Files keeps the prefix
<!--NeedCopy-->

SecurSpaces checks the following before it lets a volume be attached:

  • spec.csi.driver is efs.csi.aws.com, for both backends.
  • spec.mountOptions contains tls.
  • spec.accessModes permits the permission the owner chose, and its first entry is one the storage driver accepts. See Set the access modes.
  • spec.capacity.storage has a value, and is not smaller than the size the owner requests. Beyond that check the value is nominal: EFS and S3 Files ignore size and never enforce it.
  • The volume is in the Available phase.

Write spec.csi.volumeHandle in the static form for your backend. For Amazon EFS it has no prefix, <fs-id>::<access-point-id>; the efs: prefix belongs to dynamic provisioning only. For Amazon S3 Files it keeps the prefix, s3files:<fs-id>::<access-point-id>. SecurSpaces does not require the handle to name an access point, so the handle you publish is what the project gets. See Publish access point handles only.

List tls in spec.mountOptions and nothing else. In particular, do not add ro there to make the data read-only. A volume’s mount options apply to every mount of it, so ro pins every Mount Point built on that volume to read-only, including ones an owner created as read/write, which reads to them as data loss. Read-only is a per-Mount-Point choice, applied at the workspace pod.

SecurSpaces reads neither of the next two fields, but Kubernetes does. Set both yourself. Each fails in a different way, and neither failure is explained anywhere in the interface:

  • spec.persistentVolumeReclaimPolicy: Retain. A volume set to Delete is offered and binds normally. When the Mount Point is deleted and the claim goes, the volume is left Released: it is not deleted and its data is untouched, but it cannot back another Mount Point until you reset it. Set Retain anyway. If the volume ever carries the EFS CSI driver’s pv.kubernetes.io/provisioned-by annotation, Delete hands it to the driver, which deletes the access point the volume names.
  • spec.storageClassName: "", an explicit empty string. Kubernetes binds a claim to a volume only when the two storageClassName values match, and the claim SecurSpaces builds always carries "". A volume naming a StorageClass is offered and accepted and then never binds: the attach times out after three minutes and the Mount Point lands in an error state. Do not omit the field either. An absent value lets the default StorageClass admission controller turn the static bind into dynamic provisioning.

No Kubernetes secret is needed. AWS access is backed by IAM and the access point.

Set the access modes

Use accessModes: ["ReadWriteMany"]. That single value is the right answer for every AWS Mount Point volume, whether owners will attach it read/write or read-only.

It does not mean the volume must be writable. The read/write choice belongs to the Mount Point, not to the volume. An owner who picks Read Only gets a genuinely read-only mount, because the workspace mounts the volume read-only and the storage driver applies ro at mount time. The access mode plays no part in it.

Note

A read/write Mount Point needs ReadWriteMany. A read-only one accepts either ReadWriteMany or ReadOnlyMany. A volume listing ReadWriteMany therefore leaves both choices open to the owner. Where the volume’s modes allow only one of the two, the dialog fixes the selection on Read Only and explains why. It never forces the selection to Read/Write.

Put ReadWriteMany first. Kubernetes passes only the first entry of accessModes to the storage driver, and the AWS driver does not accept ReadOnlyMany. A volume whose first mode is ReadOnlyMany cannot be mounted by any workspace: the picker lists it as non-selectable and tags it cannot be mounted, and an attempt to attach it is refused with Selected volume cannot be mounted. Listing ReadOnlyMany after ReadWriteMany is harmless but pointless, because nothing reads it. A volume that lists only ReadWriteOnce is not offered at all, because a Mount Point is shared across many workspace pods and needs many-node access.

spec.accessModes is not immutable on a PersistentVolume, so a volume authored the wrong way is corrected in place:

kubectl patch pv <name> --type=merge -p '{"spec":{"accessModes":["ReadWriteMany"]}}'
<!--NeedCopy-->

The patch is accepted on a Bound volume, does not disturb the binding, and leaves the data untouched. Restart any workspace that was already stuck on the volume, so that it gets a fresh pod and the driver is asked again. The bound claim keeps its own original access modes, which is expected and harmless: a claim’s spec really is immutable, only the volume’s first mode reaches the driver, and the claim’s status follows the patch. If the volume is reconciled from a manifest by GitOps, or guarded by an admission webhook, change it at the source instead, or the next sync reverts the patch.

To find volumes already authored the wrong way:

kubectl get pv -o json | jq -r '
  .items[] | select(.spec.csi.driver=="efs.csi.aws.com")
  | select(.spec.accessModes[0]=="ReadOnlyMany")
  | .metadata.name'
<!--NeedCopy-->

Warning

Do not delete and re-create a volume to change its access modes. While a claim is still bound to it, kubectl delete pv only marks the volume Terminating, because Kubernetes holds the kubernetes.io/pv-protection finalizer until the volume leaves the Bound phase, so the corrected volume cannot be applied under the same name. Never force-remove that finalizer on a bound volume: doing so strips a live Mount Point’s binding.

In no shape does accessModes publish a dataset read-only. Access modes are a hint used to match a claim to a volume, and they enforce nothing at mount time. To publish data that a consumer genuinely cannot write, enforce it in the storage layer: give the access point a read-only POSIX identity, or deny writes with a file system policy. That holds whichever permission the Mount Point’s owner picks.

Publish access point handles only

The volume handle has three colon-separated fields, FileSystemId:Subpath:AccessPointId. Leave a field empty to omit it, which is why a handle with no subpath carries two colons. Only the access point field enforces isolation.

Handle What it mounts Isolation
fs-id The file system root. None. The workspace sees every project’s data.
fs-id:/subdir A subdirectory. None. Path-confined only, treated exactly like a bare file system id.
fs-id::fsap-id An access point. Full, but only if that access point is itself restricted.
fs-id:/subdir:fsap-id A subdirectory under the access point root. The same condition. The isolation comes from the access point, never from the subpath.

To combine a subpath with an access point, put a single colon between them. fs-id:/subdir::fsap-id is not that handle. The extra colon pushes the access point id out of the third field, so SecurSpaces reads no access point id and treats the volume as a whole file system attach.

Naming an access point is not a condition of being offered. A bare file system handle is offered and binds like any other volume. SecurSpaces withholds one only from a user who is neither an administrator nor a security officer, and only when the file system is one it provisions Mount Points from, or when it cannot read that set of file systems. On any other file system, publishing a bare handle is your decision alone.

Warning

On the route where you publish a volume, the handle you publish is the security boundary. A bare file system handle mounts and works, there is no second, cluster-side check when a volume you pre-built is bound, and nothing warns the user. Any user who can create a Mount Point in a project your volume is scoped to can bind it. Publish access point handles only, unless you intend everyone in that scope to see the whole file system.

An access point id is not by itself isolation. SecurSpaces decides isolation purely from whether an access point id is present. It never reads the access point’s configuration from AWS. In AWS both the root directory and the POSIX user are optional on an access point, and one created without them exposes the file system root while still satisfying every check. Verify before publishing:

aws efs describe-access-points --access-point-id <fsap-id> \
  --query 'AccessPoints[].{Root:RootDirectory.Path,Uid:PosixUser.Uid,Gid:PosixUser.Gid}'
<!--NeedCopy-->

Publish only if Root is a real per-dataset path, not / and not null, and Uid and Gid are set. Access points cannot be edited after creation, so an unrestricted one must be replaced.

To sweep a file system for unrestricted access points:

aws efs describe-access-points --file-system-id <fs-id> \
  --query 'AccessPoints[?RootDirectory.Path==`/` || PosixUser==`null`].AccessPointId'
<!--NeedCopy-->

Never publish a subpath handle that points at your StorageClass basePath. That directory is the parent of every directory created by Create New, including data orphaned by deleted Mount Points.

Reset a released volume

A volume you author is single-use per bind cycle. Deleting a Mount Point removes only the claim. Because of Retain, Kubernetes moves the volume to the Released phase and keeps a stale claimRef that prevents rebinding. The picker still lists it, in a separate non-selectable group marked as needing a reset.

Confirm the phase first, then dry-run the patch against the API server, then apply it:

kubectl get pv <pv-name> -o jsonpath='{.status.phase}'     # must read: Released
kubectl patch pv <pv-name> --type=json \
  -p='[{"op":"remove","path":"/spec/claimRef"}]' --dry-run=server
kubectl patch pv <pv-name> --type=json -p='[{"op":"remove","path":"/spec/claimRef"}]'
<!--NeedCopy-->

Warning

Clearing claimRef returns the volume to the pool with its existing data intact, and the next project that attaches it inherits that data. Reset only volumes you intend to hand back. The command has no safety check of its own: run against a bound volume it strips a live Mount Point’s binding. Confirm the phase reads Released first, and use the server-side dry run to catch a mistyped volume name before the live patch changes anything.

SecurSpaces does not reset the volume for you. This flow exists for operators who do not grant SecurSpaces write access to PersistentVolumes, so SecurSpaces has read-only access and never changes a volume you own.

Author a PersistentVolume for Attach Existing