Security & Hardening
Production-grade security posture for the BANA Kubernetes cluster — covering pod-level restrictions, namespace isolation, RBAC, image supply chain, and scheduling priorities.
Pod Security Standards (PSS)
Kubernetes Pod Security Standards define three profiles: privileged, baseline, and restricted. BANA enforces these at the namespace level via labels.
Namespace Labels
Production namespaces — restricted profile
# Production namespaces — restricted profile
apiVersion: v1
kind: Namespace
metadata:
name: nx-backend
labels:
app.kubernetes.io/part-of: bana
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
---
apiVersion: v1
kind: Namespace
metadata:
name: nx-app
labels:
app.kubernetes.io/part-of: bana
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restrictedStaging namespaces — also use restricted:latest enforce mode
# Staging namespaces — also use restricted:latest enforce mode
# (matches production — all namespaces including nx-broker use restricted)
apiVersion: v1
kind: Namespace
metadata:
name: nx-backend
labels:
app.kubernetes.io/part-of: bana
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restrictedINFO
Staging uses the same restricted enforce mode as production across all namespaces (including nx-broker). This ensures security parity and catches PSS violations early.
Profile Summary
| Profile | Namespaces | Usage |
|---|---|---|
restricted | nx-backend, nx-app, nx-persistent, nx-broker, nx-search, nx-internal, nx-watcher (both staging and production) | All namespaces enforce restricted profile |
SecurityContext Template
Every container in BANA must run with a hardened SecurityContext. This template is the baseline for all backend and frontend Deployments.
The Dockerfile uses the built-in bun user (UID 1000) from the oven/bun:*-alpine base image via USER bun. The K8s SecurityContext matches this with runAsUser: 1000 / runAsGroup: 1000.
Pod-Level SecurityContext
### Pod-Level SecurityContext
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000 # matches 'bun' user in Dockerfile
runAsGroup: 1000 # matches 'bun' group in Dockerfile
fsGroup: 1000
seccompProfile:
type: RuntimeDefaultContainer-Level SecurityContext
### Container-Level SecurityContext
containers:
- name: <service>
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALLWritable Directories
Since readOnlyRootFilesystem: true prevents writes to the container filesystem, services that need writable directories use emptyDir volumes:
Since readOnlyRootFilesystem: true prevents writes to the ...
containers:
- name: <service>
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 64MiTIP
Bun and Node.js may write to /tmp for caching. Always mount an emptyDir at /tmp when using readOnlyRootFilesystem: true.
Complete Hardened Deployment Example
Deployment: nx-commerce
apiVersion: apps/v1
kind: Deployment
metadata:
name: nx-commerce
namespace: nx-backend
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: commerce
template:
metadata:
labels:
app.kubernetes.io/name: commerce
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: commerce
image: bcr.bana.com.vn/nx-commerce:0.0.1
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- containerPort: 3000
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 64MiServiceAccount Hardening
By default, Kubernetes mounts a ServiceAccount token into every pod. Most application pods don't need to call the Kubernetes API, so this token is an unnecessary attack surface.
Disable Auto-Mount
spec:
template:
spec:
automountServiceAccountToken: falseApply this to all backend and frontend Deployments.
Dedicated ServiceAccounts
Services that need Kubernetes API access get their own ServiceAccount with minimal permissions:
ServiceAccount: nx-traefik
apiVersion: v1
kind: ServiceAccount
metadata:
name: nx-traefik
namespace: nx-internal
labels:
app.kubernetes.io/name: traefik
app.kubernetes.io/part-of: bana
automountServiceAccountToken: false # Traefik uses file provider, no K8s API access neededTIP
Traefik in BANA uses a file provider configuration, not Kubernetes CRD discovery. This means it does not need to watch traefik.io CRDs and requires minimal or no Kubernetes API access, keeping the RBAC footprint small.
| ServiceAccount | Namespace | Purpose | API Access |
|---|---|---|---|
default | All | App pods | Disabled (automountServiceAccountToken: false) |
nx-traefik | nx-internal | Traefik file provider | Minimal (no CRD watching needed) |
nx-cert-manager | cert-manager | cert-manager | Secrets, Ingress, CRDs |
nx-prometheus | nx-watcher | Prometheus scraping | Read-only pods, services, endpoints |
RBAC
GitLab CI/CD Service Account
The CI/CD pipeline needs kubectl apply access scoped to specific namespaces — never cluster-admin.
ServiceAccount: gitlab-deployer
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitlab-deployer
namespace: nx-backend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gitlab-deployer
namespace: nx-backend
rules:
- apiGroups: ["", "apps", "batch", "autoscaling", "policy"]
resources:
- deployments
- services
- configmaps
- secrets
- jobs
- horizontalpodautoscalers
- poddisruptionbudgets
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["traefik.io"]
resources: ["ingressroutes", "middlewares"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitlab-deployer
namespace: nx-backend
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: gitlab-deployer
subjects:
- kind: ServiceAccount
name: gitlab-deployer
namespace: nx-backendRepeat for each namespace the pipeline deploys to (nx-app, nx-backend).
Portainer Read-Only Role
ClusterRole: nx-portainer-readonly
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: nx-portainer-readonly
rules:
- apiGroups: ["", "apps", "batch", "autoscaling", "policy", "networking.k8s.io"]
resources: ["*"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: nx-portainer-readonly
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: nx-portainer-readonly
subjects:
- kind: ServiceAccount
name: portainer-readonly
namespace: portainerRBAC Summary
| Principal | Scope | Permissions | Purpose |
|---|---|---|---|
gitlab-deployer | Per namespace | Full CRUD on deployments, services, configs, jobs | CI/CD pipeline |
nx-portainer-readonly | Cluster-wide | Read-only all resources + pod logs | Portainer dashboard for PM/QA |
nx-traefik | nx-internal | Minimal (file provider, no CRD watching) | API gateway routing |
nx-prometheus | Cluster-wide | Read-only pods, services, endpoints | Metrics scraping |
Image Supply Chain
Trivy Scanning in CI
Every image is scanned for vulnerabilities before being pushed to the registry. See Operations for the full .gitlab-ci.yml integration.
Added to .gitlab-ci.yml after docker build, before push
# Added to .gitlab-ci.yml after docker build, before push
scan-image:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --exit-code 1 --severity CRITICAL,HIGH --no-progress $REGISTRY/$SERVICE:$CI_COMMIT_SHORT_SHA
allow_failure: falseImage Signing (cosign)
Sign images after push to ensure integrity:
# In CI after push
cosign sign --key env://COSIGN_PRIVATE_KEY $REGISTRY/$SERVICE:$CI_COMMIT_SHORT_SHAAdmission Control
Use an admission controller (e.g., Kyverno) to block unscanned or unsigned images in production:
ClusterPolicy: require-image-scan
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-image-scan
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-image-registry
match:
any:
- resources:
kinds:
- Pod
namespaces:
- nx-backend
- nx-app
validate:
message: "Images must come from the bcr.bana.com.vn registry"
pattern:
spec:
containers:
- image: "bcr.bana.com.vn/nx-*"
- name: block-latest-tag
match:
any:
- resources:
kinds:
- Pod
namespaces:
- nx-backend
validate:
message: "Production images must use a specific tag, not :latest"
pattern:
spec:
containers:
- image: "!*:latest"Image Pull Policy
| Environment | Policy | Rationale |
|---|---|---|
| Staging | IfNotPresent | Faster restarts, acceptable staleness |
| Production | Always | Ensures the exact tag content matches the registry |
PriorityClass
PriorityClasses ensure critical services survive node pressure and OOM events. Lower-priority pods are preempted first.
Definitions
PriorityClass: nx-system-critical
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: nx-system-critical
value: 1000000
globalDefault: false
description: "Data layer, ingress controller"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: nx-high
value: 500000
globalDefault: false
description: "Identity (JWKS), API gateway"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: nx-default
value: 100000
globalDefault: true
description: "Backend services & frontends"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: nx-low
value: 10000
globalDefault: false
description: "Background workers, batch jobs"Assignment
| PriorityClass | Value | Services |
|---|---|---|
nx-system-critical | 1,000,000 | nginx-ingress, cert-manager, data layer StatefulSets |
nx-high | 500,000 | identity, Traefik |
nx-default | 100,000 | commerce, finance, inventory, ledger, pricing, sale, signal, payment-api, client, bo, overture, sale-renderer |
nx-low | 10,000 | payment-worker, wiki |
Usage in Deployment
spec:
template:
spec:
priorityClassName: nx-high # identity, TraefikWARNING
Never set preemptionPolicy: Never on high-priority classes. The whole point of PriorityClass is that critical services can preempt non-critical ones during resource contention.
LimitRange
Each namespace has a LimitRange that enforces minimum resource requests per container, preventing pods from being scheduled with dangerously low resources.
| Namespace | Min CPU | Min Memory | Default CPU | Default Memory |
|---|---|---|---|---|
nx-backend | 50m | 64Mi | 500m | 512Mi |
nx-app | 25m | 32Mi | 200m | 256Mi |
nx-broker | 100m | 128Mi | 500m | 768Mi |
nx-persistent | 100m | 256Mi | 500m | 1Gi |
nx-search | 50m | 128Mi | 200m | 512Mi |
nx-internal | 50m | 64Mi | 500m | 512Mi |
nx-watcher | 25m | 32Mi | 200m | 256Mi |
TIP
The nx-broker namespace enforces a minimum of cpu 100m and memory 128Mi per container. All namespaces have both minimum and maximum bounds to prevent resource abuse.
Checklist
Use this checklist when deploying a new service or auditing an existing one:
- [ ] Pod SecurityContext:
runAsNonRoot,runAsUser: 1000,seccompProfile: RuntimeDefault - [ ] Container SecurityContext:
allowPrivilegeEscalation: false,readOnlyRootFilesystem: true,capabilities.drop: [ALL] - [ ]
automountServiceAccountToken: false(unless API access needed) - [ ] Dedicated ServiceAccount if API access is required
- [ ] Image from
bcr.bana.com.vn/nx-*only - [ ] Trivy scan passing (no CRITICAL/HIGH vulnerabilities)
- [ ] PriorityClass assigned
- [ ] Namespace has correct PSS labels
- [ ] No
:latesttag in production manifests