Argo CD AppProject RBAC: Known Issues and Field Workarounds
We run Argo CD across about forty multi-tenant clusters and have collected a fairly long list of AppProject RBAC papercuts over the last year. This page is the short version of that list: the failure modes that hit us more than once, what the error looked like, and the workaround that actually stuck. None of these are "vulnerabilities" in the security-disclosure sense; they are rough edges in how AppProject scope evaluation interacts with the rest of the reconcile loop, and most have an upstream issue tracking them.
If you are reading this because you got application destination is not permitted in project
and the destination obviously is permitted, jump to the second section. That one accounts for
about half of the support pings we get internally.
Destination tuple matching is per-tuple, not per-field
This is the most common surprise. AppProject.spec.destinations is a list, and each
entry is matched as a whole tuple against the Application's spec.destination. If you
write:
destinations:
- server: '*'
namespace: team-a-*
- server: https://kubernetes.default.svc
namespace: shared-*
then an Application with server: https://kubernetes.default.svc and
namespace: team-a-prod is rejected, even though the second entry's server matches and
the first entry's namespace matches. The matching does not cross entries. The fix is either to
widen one entry (server: '*' with the desired namespace pattern) or to add a third
entry that combines the two. We have an internal lint rule that flags any AppProject with more
than three destinations entries on the assumption that someone is trying to cross-match.
clusterResourceWhitelist defaults are surprising
If clusterResourceWhitelist is unset, Argo CD denies all cluster-scoped resources for
that project. If it is set to an empty list, same behavior. To allow cluster-scoped resources you
have to enumerate them explicitly, or use the wildcard entry
{ group: '*', kind: '*' }. We hit this every time someone copies an AppProject from a
tutorial that omitted the field — the project works for namespace-scoped resources and breaks the
moment someone declares a ClusterRole or a CRD.
The symptom is a sync error of the form resource Kind:ClusterRole is not permitted in
project tenant-a-project. The fix is the explicit allowlist:
clusterResourceWhitelist:
- group: rbac.authorization.k8s.io
kind: ClusterRole
- group: apiextensions.k8s.io
kind: CustomResourceDefinition
namespaceResourceBlacklist is evaluated before whitelist
Less common but more frustrating: if you have both
namespaceResourceWhitelist and namespaceResourceBlacklist set, the
blacklist wins for any resource that appears in both. This is documented but only in the
operator manual, not in the AppProject API reference, so it gets missed. We hit it once when a
tenant set up a blanket blacklist for Secret resources and then tried to whitelist
a specific ExternalSecret CRD — the CRD itself was fine, but a downstream sync
templated a normal Secret and the whole Application went into degraded.
Example AppProject manifest
For reference, here is the canonical AppProject we hand out to new tenants. Everything in this manifest is deliberately explicit; we have learned not to rely on defaults.
# diagram: 
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: tenant-a-project
namespace: argocd
spec:
description: Tenant A project, single-cluster, namespace-scoped only.
sourceRepos:
- https://git.example.com/tenant-a/*
destinations:
- server: https://kubernetes.default.svc
namespace: tenant-a-*
clusterResourceWhitelist: []
namespaceResourceWhitelist:
- group: '*'
kind: '*'
roles:
- name: tenant-a-deployer
policies:
- p, proj:tenant-a-project:tenant-a-deployer, applications, sync, tenant-a-project/*, allow
- p, proj:tenant-a-project:tenant-a-deployer, applications, get, tenant-a-project/*, allow
groups:
- tenant-a-platform
Two notes on the above. First, clusterResourceWhitelist: [] is intentional — this
project is namespace-scoped and we want the explicit denial. Second, the policy lines use
positional CSV; the Argo docs show this with extra whitespace for readability but the parser is
whitespace-tolerant.
Roles with empty policies silently pass
An AppProject role with no policies entries is treated as having no permissions, but
the Application controller does not log anything when a role is consulted and returns no
match — the request falls through to the next role or to the default deny. This makes "why does
my sync fail with no log line" debugging quite painful. The workaround we adopted is to put a
sentinel policy line in every role:
policies:
- p, proj:tenant-a-project:tenant-a-deployer, applications, get, tenant-a-project/null-sentinel, deny
The sentinel never matches a real Application but its presence forces the controller to log the role evaluation, which makes the rest of the policy lines visible during debug.
Group claims are case-sensitive
If your dex or OIDC group claims are mixed-case (for example Tenant-A-Platform) and
your AppProject role groups are lower-case (tenant-a-platform), the role does not
match and the user gets a confusing "permission denied" with no indication of which group claim
was evaluated. There is an upstream issue requesting case-insensitive matching but it has been
open for two years. Until then, normalise the claim in your IDP or duplicate the group entries
in both cases.
Sync-wave RBAC re-entry
This one was fixed in 2.12.5 (see the post-mortem at /research/argocd-sync-wave-rbac-2026-05) but if you are still on an older 2.11 or 2.12 release: the per-resource RBAC check during sync-wave re-entry uses the cached AppProject scope from the Application context rather than re-resolving the AppProject at each wave entry. The practical effect is that a namespace re-label in an earlier wave can interact with the project scope check in a later wave. Patch to 2.12.5 if you have not.
Debugging checklist
When a sync fails with an AppProject scope error, we walk through this in order:
- Run
argocd app get <name> -o yamland capture the exactspec.destinationtuple. - Run
argocd proj get <project> -o yamland verify each destinations entry covers the tuple as a whole, not field-by-field. - For cluster-scoped resources, check
clusterResourceWhitelistis non-empty. - For namespace-scoped resources, check whether the resource Kind appears in
namespaceResourceBlacklist. - Read the application-controller pod logs for the offending Application's name and look for
the line beginning
rbac:. That is the actual decision line. If it is missing, your role policies are probably empty (see the sentinel workaround above). - If the project uses dex or external OIDC, decode a recent ID token and confirm the group
claims match the role's
groupsentries byte-for-byte.
References
- Argo CD operator manual — AppProject
- Argo CD operator manual — RBAC
- Kubernetes upstream RBAC reference
- Argo CD project home