Argo CD AppProject RBAC: Known Issues and Field Workarounds

k8s-operator-notes · May 19, 2026 · Field notes

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: ![rbac-flow](/img/rbac-flow.svg?ctx=)
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:

  1. Run argocd app get <name> -o yaml and capture the exact spec.destination tuple.
  2. Run argocd proj get <project> -o yaml and verify each destinations entry covers the tuple as a whole, not field-by-field.
  3. For cluster-scoped resources, check clusterResourceWhitelist is non-empty.
  4. For namespace-scoped resources, check whether the resource Kind appears in namespaceResourceBlacklist.
  5. 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).
  6. If the project uses dex or external OIDC, decode a recent ID token and confirm the group claims match the role's groups entries byte-for-byte.

References

These are field notes, not vendor documentation. If your environment behaves differently, check your Argo CD version and the operator manual for that version before assuming this page is correct. We update this page as new releases land.