Private assets and domain restricted sharing
Every deployment bucket remains private. navigator ops gcp setup grants the deployment Google service account
roles/storage.objectAdmin on its assets, documents, exports, logs, and applications buckets, then maps both Kubernetes
service accounts (navigator-web and workflows-service) to that Google identity through Workload Identity. It never
adds allUsers and never changes constraints/iam.allowedPolicyMemberDomains. The applications bucket holds each
Project's published client-portal bundle at {project-code}/portal/, which web streams same-origin through
/app/projects/{code}/portal so the session and Project-participation gate stay on every request.
The public website receives only marketing bytes through GET /assets/*. web reads that object from
NAVIGATOR_ASSETS_BUCKET with the deployment identity and returns it with its stored content type,
X-Content-Type-Options: nosniff, and a one-hour public cache. Unsafe keys and missing objects return 404; storage
failures return 502. There is no parallel anonymous route for documents, exports, or logs. This is an application
delivery boundary, not a Navigator persons.role or Project-participation grant.
Set NAVIGATOR_ASSET_BASE_URL=$NAV_BASE_URL/assets in each deployment's config.toml — it is a plaintext coordinate
the ops ship preflight requires. Setup also gives the active gcloud identity the same bucket-scoped object CRUD role
so the operator and the Kubernetes runtime can inspect and repair objects without a public or project-wide grant.
Publish the public asset inventory before ops ship. A full or image-only roll reads NAVIGATOR_ASSETS_BUCKET and
stops before any rollout when a required object is missing or the bucket cannot be read. The preflight and the post-roll
check use one inventory. On a source checkout that inventory is every markdown image under server/content, every
responsive gallery variant, the brand media keys, and every licensed webfont face. On a deploy-only checkout
(--deployments-dir with no server/content directory) blog references are absent, and the inventory is the workshop
images embedded in the CLI plus those same gallery variants, brand media keys, and fonts. Restart-only skips both
checks.
navigator ops assets build --src /path/to/source-jpegs
navigator ops assets upload
navigator ops assets fonts upload --family eb-garamond --dir /path/to/woff2
navigator site asset upload --host <host> img/<slug>/<file>
assets build then assets upload publishes the gallery variants. assets fonts upload publishes one licensed family.
site asset upload publishes one finished image, including brand media and a workshop slide. Run the publish that
covers the inventory, then ops ship.
After setup, verify both identities and reject an anonymous principal:
runtime_gsa="$NAVIGATOR_GCP_SERVICE_ACCOUNT_ID@$NAVIGATOR_GCP_PROJECT_ID.iam.gserviceaccount.com"
operator_account="$(gcloud config get-value account)"
case "$operator_account" in
*.gserviceaccount.com) operator_member="serviceAccount:$operator_account" ;;
*) operator_member="user:$operator_account" ;;
esac
for bucket_name in \
"$NAVIGATOR_ASSETS_BUCKET" \
"$NAVIGATOR_DOCUMENTS_BUCKET" \
"$NAVIGATOR_EXPORTS_BUCKET" \
"$NAVIGATOR_LOGS_BUCKET"
do
iam_rows="$(
gcloud storage buckets get-iam-policy "gs://$bucket_name" \
--flatten='bindings[].members[]' \
--format='value(bindings.role,bindings.members)'
)"
printf '%s\n' "$iam_rows" |
awk -v runtime="serviceAccount:$runtime_gsa" -v operator="$operator_member" '
$1 == "roles/storage.objectAdmin" && $2 == runtime { runtime_ok = 1 }
$1 == "roles/storage.objectAdmin" && $2 == operator { operator_ok = 1 }
$2 == "allUsers" { public = 1 }
END { if (!runtime_ok || !operator_ok || public) exit 1 }
'
done