TT Lab
Get started
Learn Learning paths Courses

GitOps and Argo CD

Argo CD Permissions Are Not Cluster Permissions

Continue in TT Lab

In one sentence

Who can do what inside Argo CD is decided not by Kubernetes RBAC but by one sheet in argocd-rbac-cm, namely policy.csv, and that verdict can be checked right on the spot, without a server, with argocd admin settings rbac.

Why this distinction was needed

The moment you turn on GitOps, what actually changes the cluster is not a person but Argo CD. Argo CD's controller holds very broad permissions over the namespaces it manages — it has to, in order to create anything written in a manifest. Here is where a trap arises. If a person with no permissions at all in the cluster is given just an Argo CD account, the synchronization that person presses is executed with Argo CD's permissions. Kubernetes RBAC never even looks at this person.

So Argo CD keeps its own separate permission table. It is, in the argocd-rbac-cm ConfigMap, the policy.csv key, and the format is casbin's policy CSV. There are only two kinds of lines.

p, <주체>, <자원>, <동작>, <객체>, allow|deny
g, <사용자 또는 그룹>, <역할>

p is one line of permission and g is one line of membership. Resources are names such as applications, applicationsets, projects, clusters, repositories, certificates, gpgkeys, and logs, and actions differ per resource — for applications, it accepts get, create, update, delete, sync, override, and even the form action/<그룹>/<종류>/<이름> (group, kind, name) that refers to resource actions. The object, in the case of applications, is two fields, <프로젝트>/<앱이름> (project/app name). dev/* is all apps of the dev project, and */* is everything. The mistake that happens most often here is writing only the app name in the object — web matches no app at all.

How it works

The verdict has two properties. First, deny wins regardless of line order. Whether you write allow above and deny below or the reverse, the result is the same. It differs from firewall syntax, which uses a first-match rule, so if you read it with that sense you will be wrong. Second, a request that matches no line is judged again with the role set by policy.default. If you write role:readonly there, someone with only an account can only read, and if it is empty, nothing is allowed.

Besides the global policy, there are also roles that work only inside a project. In an AppProject's spec.roles, you write a name, policy lines, and the groups to bind. The subject name is proj:<프로젝트>:<역할> (project, role) and the syntax is exactly the same as the global policy. The difference is where it lives — the global policy is a ConfigMap held by the platform team, while a project role can be edited by the team that owns that project in its own manifest. The more teams there are, the more this boundary actually reduces work.

Finally, the tools that make this lab possible. argocd admin settings rbac validate and argocd admin settings rbac can do not connect to the Argo CD server. They read only the file given with --policy-file to make the verdict. That is why you can treat the policy as code and run regression checks that run an expectation table every time you change it. can is strict mode by default, so if you give a resource name or an action name that does not exist, it stops before the verdict and tells you — it wipes out entirely the time of asking "why isn't this working" after deploying a typo'd policy.

What you see in the field

Incidents usually come like this. The on-call person synchronized the payments app at night, and that synchronization pushed up a bad commit. Looking afterward, the policy had only the single line p, role:oncall, applications, sync, prod/*, allow. Payments is another team's app, but the single field prod/* was covering even that. The fix is simple — for prod/payments, add one deny line, and it is blocked regardless of order. What is hard is knowing that nothing else broke after the fix, and there is no way to confirm that other than running the expectation table.

Incidents in the opposite direction are common too. When you narrow the permissions, the deployment automation quietly stops. The action the CI account had been using was not sync but action/apps/Deployment/restart, or the object was written with just a single * field and had to be fixed to */*. The more a change narrows, the more you need the table. Without the table, nobody narrows, and permissions only ever widen.

The limits of this lab environment

The lab Pod has neither an Argo CD controller nor an API server. So you cannot do "log in as this person and press the button". Instead, the very code that actually interprets the policy is inside the CLI, so the verdict comes out through the same path as what the server produces. A lab that brings up a real controller and watches synchronization run is separate, on the certification track.

What you will do in the next lab

You write one policy, check the syntax, and ask eight things with rbac can. You deliberately leave out a field to see the error, write deny above to try it, and decide with policy.default the share to give a stranger. You put a project role in an AppProject and put it up on the kwok cluster, and at the end you build an expectation table and a check script and run everything at once.