Tenant scaffolding template

Canonical contents of platform-gitops/.../tenants/_template — Namespace, ResourceQuota, LimitRange, default-deny NetworkPolicy, ESO ServiceAccount + per-tenant SecretStore, AppProject — and the four tenants the spoke runs today.

The platform ships a single canonical tenant scaffold under platform-gitops/clusters/spoke-dc-v6/tenants/_template/. Onboarding a new tenant is cp -r _template apps-<division>-<team> plus token substitution plus one MR — never hand-rolled YAML. This page is the contents of that scaffold, the inventory of tenants live today, and the conventions an operator MUST honour when adding a new one.

Per DEV-OCP-0.3 (#173), DEV-OCP-4.1 (#196), DEV-OCP-4.2 (#197).

What every tenant gets

Eight resources, all at sync-wave <= 12, before any tenant workload.

FileKind / APISync wavePurpose
namespace.yamlv1/Namespace0The namespace itself; carries the load-bearing apps.platform/tenant=true label and the PSA restricted profile.
serviceaccount-app-eso.yamlv1/ServiceAccount5app-eso — the SA the per-tenant SecretStore projects to Vault. No Kubernetes RBAC bound.
argocd-rbac.yamlrbac.authorization.k8s.io/v1/Role + RoleBinding5Grants the spoke Argo controller SA serviceaccounts + secrets write inside the namespace (the cluster default ClusterRole excludes these for security).
rolebinding-edit.yamlrbac.authorization.k8s.io/v1/RoleBinding5Grants the team’s GitLab/OIDC group edit ClusterRole on this namespace, so developers can oc logs / oc exec for debugging.
serviceaccount-default-pull-patch.yamlv1/ServiceAccount (server-side patch)10Patches default SA with imagePullSecrets: [{name: app-registry-pull}] so Pods using the default SA can pull from Nexus app-registry.
resourcequota.yamlv1/ResourceQuota10Per-env hard cap (DEV-OCP-4.1). Defaults: dev = 4 CPU / 8 Gi; stg = 8 / 16; prd = 16 / 32.
limitrange.yamlv1/LimitRange10Per-container defaults + min/max (DEV-OCP-4.1). Catches Pods that omit resources.requests.
networkpolicy.yamlnetworking.k8s.io/v1/NetworkPolicy x 512Default-deny ingress+egress (DEV-OCP-4.2) plus four curated allow-list rules: from openshift-ingress, egress to kube-dns, egress to cluster monitoring, egress to Vault (30.30.30.20/30:8200).
secretstore-vault-apps.yamlexternal-secrets.io/v1/SecretStore30The per-tenant ESO bridge to Vault role apps-spoke-dc-v6-<division>. SecretStore, never ClusterSecretStore.

The full file list lives at platform-gitops/clusters/spoke-dc-v6/tenants/_template/; see 03 — Project + RoleBinding template for the AppProject it pairs with on the hub.

The load-bearing labels and annotations

Three pieces of namespace metadata drive cluster-wide behaviour:

Label / annotationOwnerEffect
apps.platform/tenant: "true"tenant templateClusterExternalSecret/app-registry-pull fans the dockerconfigjson Secret into this ns. See 08 — Cluster pull-secret fan-out.
pod-security.kubernetes.io/enforce: restrictedtenant template (ADR 0020, PCI profile)Pods that escalate privileges, run as root, or omit seccompProfile are rejected at admission.
argocd.argoproj.io/managed-by: openshift-gitops (annotation)tenant templateArgo recognises the namespace as a sync target without CreateNamespace=true.

The openshift.io/cluster-monitoring=true label is not set on tenant namespaces. That label is reserved for openshift-* platform namespaces; user-workload Prometheus excludes anything carrying it. Tenant namespaces are scraped by prometheus-user-workload automatically — no opt-in label needed. (This was the bank-payment learning; the namespace originally shipped the wrong label and lost discovery for its ServiceMonitor.)

Default-deny NetworkPolicy — the baseline

DEV-OCP-4.2 says every tenant namespace ships default-deny ingress + egress. The four allow rules cover the platform paths a tenant genuinely needs, no more:

RuleDirectionTarget
allow-from-openshift-ingressingressnamespace labelled network.openshift.io/policy-group=ingress — i.e. OpenShift Routes terminate at tenant Services.
allow-egress-dnsegressopenshift-dns ns, UDP/TCP 53 + UDP 5353.
allow-egress-cluster-monitoringegressopenshift-monitoring + openshift-user-workload-monitoring (Thanos querier ports 9091-9095, 10902).
allow-egress-vaultegress30.30.30.20/30:8200 (Vault VMs; mirrors the platform ESO egress rule from issue #162).

Two more rules ship commented-out in the template — allow-egress-internet-via-cluster-egress (uncomment + edit when the tenant has a NAT / EgressIP allocation) and allow-from-istio-ambient (uncomment when the namespace joins the OSSM3 ambient mesh).

AppProject convention — tenant-&lt;division&gt;-&lt;app&gt;

Argo AppProject is the only place that decides which Git repos may be a source.repoURL, which destinations may be a destination, and which resource kinds an Application may create. Tenant onboarding ships one AppProject per (division, app) on the hub, scoped to the destination namespace glob.

The naming convention is tenant-<division>-<app>. Example AppProjects live at clusters/hub-dc-v6/gitops-control/appproject-tenant-<division>-<app>.yaml and were introduced by DEV-OCP-2.2 (#183).

Hard rules baked into the project:

FieldSettingWhy
sourceReposthe tenant’s app monorepo URL + the platform-gitops URLA tenant cannot point an Application at an arbitrary repo.
destinations[].namespaceglob apps-<division>-*Tenant Applications can only target their own division’s namespaces.
destinations[].serverhttps://kubernetes.default.svcLocal-cluster reconcile only (ACM pull model — see GitOps consume).
clusterResourceWhitelistNamespace onlyNo ClusterRole, ClusterRoleBinding, or CRDs from tenants. Cluster-scoped resources are platform team’s job.
namespaceResourceWhitelistexplicit list (Deployment, Service, Route, ConfigMap, Secret, ExternalSecret, SecretStore, OpenLibertyApplication, CNPG Cluster, NetworkPolicy, HPA, PDB, ServiceMonitor / PodMonitor / PrometheusRule)Anything outside this list is rejected at sync. Operator CRDs the tenant uses (CNPG, OpenLiberty) are explicitly listed.
orphanedResources.warntrueReports tenant-created resources that aren’t in Git, but doesn’t auto-prune. Builds the “drift” review path.

The whitelist intentionally excludes Namespace writes from tenants — tenants reference a namespace via the overlay’s namespace: field; the namespace itself is platform-provisioned in platform-gitops/.../tenants/<ns>/.

Active tenants on spoke-dc-v6 today

Four tenants are live as of 2026-05-12. Each one lives at platform-gitops/clusters/spoke-dc-v6/tenants/<name>/:

TenantPurposeNotes
apps-platform-sampleReference scaffolding tenant. The canonical apps-<division>-<team> shape.Use this when copy-pasting for a new tenant.
ossm3-demoService mesh demo namespace (Red Hat OpenShift Service Mesh 3 ambient mode).Has additional ztunnel-workload-identity-rbac.yaml for SPIFFE issuance. Hosts the bookinfo mesh sample.
bank-employees-jboss-chatBRAC engagement: JBoss EAP chat app demonstrating Path A (Nexus) and EAP runtime.Standard scaffold, no Vault SecretStore (no app-side secrets yet).
bank-paymentBRAC engagement: payment-microservice 4-service demo with SigNoz observability and ambient-mesh exploration.Adds argocd-workload-rbac.yaml because it ships from a standalone Argo Application (project=default) rather than the tenant-apps AppSet, and needs an explicit write grant for Deployments/Services/PDBs/HPAs. Namespace is bank-payment (not apps-payment-bank-payment) — a focused-demo carve-out, kept short. The apps.platform/tenant=true label still wires the image-pull fanout.

apps-platform-sample is the template-of-templates. New apps under existing tenants don’t need a new tenant directory at all — they ship as new overlays in the team’s app monorepo and the tenant-apps ApplicationSet auto-discovers them.

The carve-out: bank-payment and project=default

The general rule is “tenant apps deploy through the tenant-apps ApplicationSet, scoped by AppProject tenant-<division>-<app>.” The carve-out is bank-payment, which ships from divisions/payment/bank-payment.git as a standalone Argo Application under project=default.

The cost of that carve-out: the spoke Argo controller’s default ClusterRole doesn’t grant write on Deployments / Services / ConfigMaps / PDBs / HPAs in tenant namespaces, so the tenant needs argocd-workload-rbac.yaml — a namespace-scoped Role + RoleBinding granting acm-openshift-gitops-argocd-application-controller those verbs for this tenant only.

Don’t replicate this pattern for new tenants. Use the AppProject + AppSet flow; the carve-out is BRAC-demo-shaped, not general.

Onboarding sequence — one MR

For a new tenant apps-<division>-<team>:

cd platform-gitops/clusters/spoke-dc-v6/tenants
cp -r _template apps-<division>-<team>
# replace DIVISION and TEAM tokens across all files
sed -i "s/DIVISION/<division>/g; s/TEAM/<team>/g" apps-<division>-<team>/*.yaml
# set the OIDC group on the RoleBinding
sed -i "s/<OIDC-GROUP>/<group-name>/" apps-<division>-<team>/rolebinding-edit.yaml
# add to the aggregation kustomization
$EDITOR clusters/spoke-dc-v6/kustomization.yaml   # add `- tenants/apps-<division>-<team>`

Then commit the MR. Hub Argo (or the spoke-local Argo) reconciles in ~3 min and the namespace is live.

The _template/ directory carries apiVersions and placeholder values that would fail server-side validation if it were itself reconciled. The aggregation kustomization.yaml deliberately does not include _template/ — that’s a copy-source only.

What is NOT in the tenant template

A few things commonly conflated with tenant scaffolding but governed elsewhere:

Out of scopeLives atWhy
The AppProject CRclusters/hub-dc-v6/gitops-control/appproject-tenant-<division>-<app>.yamlHub-side, not spoke-side. AppProjects are per (division, app) and rarely added; the tenant scaffold is per namespace.
The ClusterExternalSecret for app-registry-pullclusters/spoke-dc-v6/secrets/app-registry-pull/Cluster-wide; one per cluster. The tenant only opts in via the apps.platform/tenant=true label.
The Quay robot token (CI push)Vault secret/apps/<division>/<app>/ci/quay-robot + ESO into openshift-pipelinesDifferent SecretStore, different namespace, different credential. See 09 — Quay robot token.
The Argo Application / ApplicationSet entryhub clusters/hub-dc-v6/gitops-control/Hub-side; the AppSet auto-discovers new overlays in already-watched monorepos.

Validation

After the MR merges and Argo syncs:

NS=apps-<division>-<team>
oc get ns $NS -o jsonpath='{.metadata.labels}' | jq
oc -n $NS get sa app-eso default
oc -n $NS get rolebinding team-developers-edit argocd-tenant-manager
oc -n $NS get resourcequota,limitrange
oc -n $NS get networkpolicy
oc -n $NS get secretstore vault-apps \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
# Expected: True

# The fanned-in pull secret should appear within ~1 minute:
oc -n $NS get secret app-registry-pull \
  -o jsonpath='{.type}{"\n"}'
# Expected: kubernetes.io/dockerconfigjson

Five default-deny / allow NetworkPolicies, one each of ResourceQuota and LimitRange, the vault-apps SecretStore Ready=True, and the app-registry-pull Secret materialised — that’s the green-state for a fresh tenant.

References

  • DEV-OCP-0.3 (#173) — tenant Project + Role + RoleBinding template.
  • DEV-OCP-4.1 (#196) — ResourceQuota + LimitRange defaults.
  • DEV-OCP-4.2 (#197) — default-deny + curated allow-list NetworkPolicies.
  • DEV-OCP-2.2 (#183) — example tenant AppProject (tenant-platform-sample).
  • platform-gitops/clusters/spoke-dc-v6/tenants/_template/ — the canonical scaffold.
  • platform-gitops/clusters/hub-dc-v6/gitops-control/appproject-tenant-platform-sample.yaml — the canonical AppProject.
  • ADR 0020 — PCI-DSS profile compliance on spoke-dc-v6.

Last reviewed: 2026-05-12