Git, CI/CD & GitOpsLXXVII · Argo CDMultitenancy
Projects and RBAC — the multi-tenant boundary and the policy CSV
What you'll learn
- Define an AppProject with source repositories and destination clusters allowlists
- Read a policy.csv and translate a row into the permission it grants or denies
- Recognise the failure mode of a project with no source allowlist (any repo deployable)
- Configure sync windows and cluster resource allowlists for a production project
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Argo CD’s AppProject is the multi-tenant boundary. Every
Application belongs to a project, and the project decides which
sources the Application can read from, which destinations it can
apply to, and which actions operators can perform against it.
The project is the unit of audit and the unit of blast radius:
when something goes wrong, the question is “which project?” and
the answer is also “which set of permissions?”. This lesson walks
the AppProject spec field by field and explains the policy.csv
that turns the project from a namespace into a permissions object.
What an AppProject owns
An AppProject is a Kubernetes CRD. Its spec carries four
distinct policy dimensions:
- Source repositories. A list of Git (or Helm, or OCI)
repository URLs the Applications are allowed to read from. The
controller rejects Applications whose
spec.source.repoURLis not in the list. - Destination clusters and namespaces. A list of
(server, namespace)pairs the Applications are allowed to apply to. - Cluster resource allowlist. A list of Kubernetes resource kinds the Applications are allowed to create. Used to prevent charts from creating cluster-scoped resources.
- Sync windows. The maintenance windows that gate automated syncs. Covered in LXXVII-04.
argocd proj create payments \
-d https://kubernetes.default.svc,payments \
-d https://kubernetes.default.svc,payments-staging \
-s https://github.com/example/payment-api
flowchart TB
P["AppProject: payments"] --> SR["sourceRepos allowlist"]
P --> DR["destinations allowlist"]
P --> CR["clusterResourceWhitelist"]
P --> SW["syncWindows"]
P --> POL["policy.csv"]
SR --> A["Applications in project"]
DR --> A
CR --> A
SW --> A
POL --> OPS["Operator permissions"]
Source and destination allowlists
The two most important fields are source and destination allowlists. A production project defines both explicitly:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: payments
namespace: argocd
spec:
sourceRepos:
- https://github.com/example/payment-api
- https://charts.example.com
destinations:
- server: https://kubernetes.default.svc
namespace: payments
- server: https://kubernetes.default.svc
namespace: payments-staging
clusterResourceWhitelist:
- group: ''
kind: Namespace
The fields and what they own:
sourceRepos— explicit allowlist of repositories the Applications in this project may read from. An Application whosespec.source.repoURLis not in this list is rejected at admission time.destinations— explicit allowlist of(server, namespace)pairs. An Application whosespec.destinationis not in this list is rejected.clusterResourceWhitelist— resource kinds the project is permitted to create at cluster scope. Used to permit Namespaces or CustomResourceDefinitions while denying ClusterRoles and ClusterRoleBindings.
The policy.csv and RBAC
The AppProject’s spec.rbac block carries the policy.csv - a
list of rows that grant or deny permissions to operators:
rbac:
policy: |
p, role:payments-admin, applications, *, payments/*, allow
p, role:payments-admin, applications, sync, payments/*, allow
p, role:payments-viewer, applications, get, payments/*, allow
p, role:payments-viewer, exec, deny, payments/*, deny
g, alice, role:payments-admin
g, bob, role:payments-viewer
The columns and what they own:
prows are policy entries:policy,subject,resource,action,object,effect. An entry grants (allow) or denies (deny) a permission.grows are group bindings:binding,subject,role. An entry binds a subject (user, group, JWT claim) to a role.- The verbs are Argo CD’s:
applications(CRUD on Applications),exec(pod exec, port forward),logs(pod logs),sync(invoke sync),get(read resources), and more.
Role names are arbitrary strings; binding rows assign subjects to them. A single role can grant permissions across multiple projects.
flowchart LR
AL["alice"] -->|g| RA["role:payments-admin"]
BO["bob"] -->|g| RV["role:payments-viewer"]
RA -->|p, applications, *, allow| P["payments/*"]
RA -->|p, applications, sync, allow| P
RV -->|p, applications, get, allow| P
RV -->|p, exec, deny| P
Projects as the unit of audit
A project is the unit of audit because every Application belongs to a project, every sync is recorded under the Application, every operator action is recorded against the project’s RBAC. The audit question “who deployed to payments in the last 24 hours?” is answered by listing Applications in the payments project, reading their sync history, and reading the RBAC bindings to identify the operator. Without projects, the audit question is “who deployed to Argo CD?” - and the answer is everyone with a sync permission on the default project.
Projects are also the unit of blast radius. A compromised
project cannot reach beyond its destination allowlist. An
attacker who compromises a token with payments-admin can sync
Applications in the payments project, but cannot create an
Application in the identity project because the destination
allowlist blocks it. The project is the trust boundary.
Under the hood
The AppProject is a CRD, so it is stored in etcd, versioned by Git, and enforced by the API server’s admission webhook. The controller’s RBAC check happens at admission time, not at sync time. This means an Application with a forbidden source or destination is rejected the moment the engineer applies the manifest, not five minutes later when the controller reconciles. The API server’s admission webhook is the choke point: every Application creation, update, and sync passes through it.
Production discipline
The rules for production AppProjects:
sourceReposis always explicit. No*, no omitted field. The CI lint rejects any AppProject whose sourceRepos is not a finite list.destinationsmatches the environment. A production project lists the production cluster’s server URL and the production namespaces; a staging project lists the staging cluster’s. Cross-environment destinations are forbidden.clusterResourceWhitelistis the narrowest possible list. Most projects permit onlyNamespace. CRDs and ClusterRoleBindings are denied by default.- Policy rows are reviewed line by line. A
*, *, *allow row is a privilege escalation waiting to happen.
Cross-course references
- Kubernetes for Production Sysadmins - Parts IX-XI (RBAC) cover the underlying RBAC model that Argo CD’s policy.csv implements.
- Git, CI/CD & GitOps for Infrastructure Engineers - Part LXXIV (GitOps Controllers) is the architectural layer Argo CD participates in; the AppProject is how that architecture is scoped per team.
Quiz
Knowledge check · 4 questions
Q1. An Application belongs to a project whose sourceRepos field is empty (or absent). What is the operational consequence?
Q2. An Argo CD RBAC deny row restricts permissions independently of any allow row in the same policy.
Q3. Name the four policy dimensions an AppProject's spec section owns.
Q4. Diagnose why an untrusted repository was deployable in a production Argo CD instance.
A team creates a new AppProject for an experiment. The spec.sourceRepos field is omitted. An engineer creates an Application in the project with repoURL pointing at a personal GitHub account. The Application is admitted and the controller begins syncing manifests from the personal account into a production namespace.
Passing score: 75%. Answers are checked in this browser.