Distribuée
Cloud & AI Security
← All insights

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.

· 6 min · #argocd#appproject#gitops#kubernetes#rbac#supply-chain#hardening

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.

Four boundaries to lock down on an Argo CD AppProject

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:

ProjectWho syncsSourcesDestinations
platform-prodplatform / SREorg/platform-* reposprod cluster, platform-* ns only
team-billing-prodbilling teamorg/billing-*prod cluster, billing ns
team-billing-devbilling teamorg/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:

  1. Named sourceRepos — global * is forbidden; the org/billing-* pattern stops at the next / (documented Argo CD behaviour).
  2. No argocd destination — and no namespace: '*' in prod.
  3. Empty clusterResourceWhitelist — a product team usually does not need cluster CRDs or ClusterRoles.
  4. 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 admin account once SSO (OIDC / SAML) is stable.
  • Map IdP groups in argocd-rbac-cm to roles without applications, *, */*, allow for product teams.
  • Restrict who can create AppProject objects (Argo CD admin only).
  • Enable applications.namespaces / --application-namespaces only 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

  1. Export current state (argocd proj list, app list).
  2. Apply the neutralized default.
  3. Create AppProjects per team/env with whitelists.
  4. Migrate Application and ApplicationSet spec.project.
  5. Wire SSO groups to project roles.
  6. Disable local admin.
  7. 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