Supply chain
Hardening Argo CD AppProjects: the multi-tenant control everyone leaves wide open
DevSecOps case analysis: neutralize the default project, restrict sourceRepos and destinations, whitelist Kubernetes kinds, and prove project RBAC before an auditor does.
In most clusters I audit, Argo CD is running, Applications sync, and the default project is still the install-time permissive one: sourceRepos: ['*'], destinations: server '*', namespace '*', clusterResourceWhitelist: group '*', kind '*'. In other words, any Application can deploy anything, from any repository, to any cluster.
That is not a cosmetic oversight. The AppProject is Argo CD’s multi-tenant boundary: allowed Git sources, target clusters/namespaces, permitted Kubernetes kinds, and project roles. Without it, Argo CD’s global RBAC (SSO, argocd-rbac-cm) only protects the UI — not what an Application is allowed to sync.
This article walks through the hardening I deliver on DevSecOps engagements: threat → AppProject controls → YAML artefacts → auditor evidence. No theory — the manifest that replaces the permissive default.
The threat: what an open default project allows
Three scenarios show up repeatedly in audits.
Escalation through the Argo CD namespace. Official docs are explicit: if a project allows deployment into the namespace where Argo CD is installed, Applications under that project effectively get admin-level access. A contributor who pushes a manifest creating an Application (or an AppProject, an argocd-rbac-cm ConfigMap, a cluster Secret) in that namespace takes over GitOps. The default project with namespace: '*' allows exactly that.
Sync from an untrusted repository. With sourceRepos: ['*'], an Application can point at a public fork, a personal repo, or a compromised Git remote. GitHub access controls (CODEOWNERS, branch protection) no longer apply: Argo CD pulls whatever the Application declares.
Unfiltered cluster-scoped kinds. A ClusterRole / ClusterRoleBinding created by a “product” Application grants rights outside the namespace. Cluster-scoped resources are denied by default unless a whitelist (or the historical */* on default) allows them — which is why you must never leave group: '*', kind: '*'.
The control against all three is the same: neutralize default, create one AppProject per team (or per environment), and make every boundary explicit.
Step 0: neutralize the default project
Before adding product projects, close the wide-open door. An AppProject with empty lists and a full namespace-resource blacklist leaves no orphan Application able to sync.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: default
namespace: argocd
spec:
description: "Neutralized — no Application should remain on default"
sourceRepos: []
destinations: []
sourceNamespaces: []
clusterResourceWhitelist: []
namespaceResourceBlacklist:
- group: "*"
kind: "*"
Then migrate every existing Application to a named project (spec.project: team-platform-prod). While an Application still references default, sync will fail — that is intentional.
Target model: one project per team and environment
For a shared platform with two or three product teams, I separate at least:
| Project | Who syncs | Sources | Destinations |
|---|---|---|---|
platform-prod | platform / SRE | org/platform-* repos | prod cluster, platform-* ns only |
team-billing-prod | billing team | org/billing-* | prod cluster, billing ns |
team-billing-dev | billing team | org/billing-* | dev cluster, billing-* ns |
Never mix prod and non-prod in the same AppProject: an accidental sync (wrong destination, wrong ApplicationSet) must be blocked at the project layer, not by human review alone.
Artefact: hardened AppProject for a product team
Example for a billing team in production. Adapt the Git org, cluster server, and kind list to your stack (Helm, Kustomize, operator CRDs).
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: team-billing-prod
namespace: argocd
labels:
team: billing
env: prod
spec:
description: "Billing — prod only. No access to the argocd namespace."
# Named repos — no global wildcard
sourceRepos:
- "https://github.com/acme-corp/billing-*"
- "https://github.com/acme-corp/shared-helm-charts"
# Where Applications for this project may live (apps-in-any-ns)
# Never put "argocd" here for a product project
sourceNamespaces:
- "billing-gitops"
destinations:
- name: prod-eks
namespace: billing
- name: prod-eks
namespace: billing-jobs
# Cluster-scoped: deny by default. No cluster kinds for this team.
clusterResourceWhitelist: []
# Namespaced: explicit allow-list (not blacklist alone)
namespaceResourceWhitelist:
- group: "apps"
kind: Deployment
- group: "apps"
kind: StatefulSet
- group: "batch"
kind: Job
- group: "batch"
kind: CronJob
- group: ""
kind: Service
- group: ""
kind: ConfigMap
- group: ""
kind: Secret
- group: ""
kind: ServiceAccount
- group: "networking.k8s.io"
kind: NetworkPolicy
- group: "networking.k8s.io"
kind: Ingress
- group: "policy"
kind: PodDisruptionBudget
- group: "autoscaling"
kind: HorizontalPodAutoscaler
namespaceResourceBlacklist:
- group: "rbac.authorization.k8s.io"
kind: Role
- group: "rbac.authorization.k8s.io"
kind: RoleBinding
roles:
- name: developer
description: "Sync and read — no Application delete"
groups:
- "acme-corp:billing-devs"
policies:
- "p, proj:team-billing-prod:developer, applications, get, team-billing-prod/*, allow"
- "p, proj:team-billing-prod:developer, applications, sync, team-billing-prod/*, allow"
- "p, proj:team-billing-prod:developer, applications, action/*, team-billing-prod/*, allow"
- "p, proj:team-billing-prod:developer, logs, get, team-billing-prod/*, allow"
- "p, proj:team-billing-prod:developer, exec, create, team-billing-prod/*, deny"
- name: lead
description: "Create/update/delete Applications in the project"
groups:
- "acme-corp:billing-leads"
policies:
- "p, proj:team-billing-prod:lead, applications, *, team-billing-prod/*, allow"
- "p, proj:team-billing-prod:lead, repositories, get, team-billing-prod/*, allow"
Non-negotiables in this manifest:
- Named
sourceRepos— global*is forbidden; theorg/billing-*pattern stops at the next/(documented Argo CD behaviour). - No
argocddestination — and nonamespace: '*'in prod. - Empty
clusterResourceWhitelist— a product team usually does not need cluster CRDs or ClusterRoles. - Project roles bound to SSO groups — global RBAC only grants read; sync rights live on the project.
Global RBAC and SSO: the mandatory complement
AppProjects do not replace instance hardening:
- Disable the local
adminaccount once SSO (OIDC / SAML) is stable. - Map IdP groups in
argocd-rbac-cmto roles withoutapplications, *, */*, allowfor product teams. - Restrict who can create
AppProjectobjects (Argo CD admin only). - Enable
applications.namespaces/--application-namespacesonly for dedicated GitOps namespaces — never for team-controlled app namespaces without review.
The Applications in any namespace docs restate it: never add user namespaces to sourceNamespaces on a privileged project.
Sync windows and orphans
Two often-missed controls, easy to add on the same AppProject:
spec:
syncWindows:
- kind: allow
schedule: "0 8-18 * * 1-5"
duration: 10h
applications:
- "*"
timeZone: "Europe/Paris"
- kind: deny
schedule: "0 0 * * 6,0"
duration: 24h
applications:
- "*"
manualSync: true
And on the Application side, orphanedResources.warn: true to detect objects created outside Git in the destination namespace — useful audit evidence that “everything running is declared”.
Evidence for the auditor
What we hand over at the end of an engagement, reusable as-is:
argocd proj list -o wide
argocd proj get team-billing-prod -o yaml
# No Application should remain on default
argocd app list -o wide | awk '$2=="default" {print}'
# No destination should include the argocd ns (except admin projects)
kubectl -n argocd get appprojects -o json \
| jq -r '.items[] | select(.metadata.name!="default")
| .metadata.name as $p
| .spec.destinations[]?
| select(.namespace=="argocd" or .namespace=="*")
| "\($p) → \(.server // .name)/\(.namespace)"'
Expected: zero lines on the argocd/* destination filter, zero Applications on default, and AppProject YAML versioned in the platform GitOps repo (app-of-apps).
One-day recovery checklist
- Export current state (
argocd proj list,app list). - Apply the neutralized
default. - Create AppProjects per team/env with whitelists.
- Migrate Application and ApplicationSet
spec.project. - Wire SSO groups to project
roles. - Disable local admin.
- Archive the evidence commands above in the audit pack.
Conclusion
Argo CD hardening does not start with plugins or secret vaulting: it starts with the AppProject. As long as default stays permissive, the rest of the GitOps posture is cosmetic. For a structured review of this area (RBAC, AppProject, secrets, admission), the Supply Chain & CI/CD Security offer covers exactly that scope in a two-week engagement with a prioritized remediation plan.
Sources: Argo CD Projects, Declarative setup — AppProject, Applications in any namespace, AWS EKS — Working with Argo CD Projects.
Found this useful? Share it.
Go further
A topic, a project, a question?
Distribuée supports demanding SMBs on cloud security, CI/CD supply chain and AI security.
Book 15 min