diff --git a/docs/argocd.md b/docs/argocd.md index 0f09565..ea872f8 100644 --- a/docs/argocd.md +++ b/docs/argocd.md @@ -27,6 +27,10 @@ kubectl get app -n argocd # everything, one line each `app` is the short name for `applications.argoproj.io`. Add `-w` to watch. +> **This page assumes infra-level access.** A project developer cannot list +> Applications and must name their own — see +> [onboarding.md §D](onboarding.md#d-see-your-own-argocd-sync-status-optional). + **Only what needs attention** — the single most useful command: ```bash @@ -199,6 +203,7 @@ ApplicationSet or an app-of-apps — the parent overwrites it. Edit the file in | A common service | `k8s/argocd/infra-apps/.yaml` + its values under `k8s/infra-manifest/` | `infra-root` | | An AppProject | `k8s/argocd/projects/*.yaml` | `argocd-projects` (wave -1) | | A project's namespace / RBAC / DB | `k8s/projects//infra/` | `project-infra` ApplicationSet | +| Letting a project read its own sync status | `k8s/projects//infra/argocd-status-rbac.yaml` | `project-infra` ApplicationSet | | A project's own AppProject (its repo + namespaces + what it may deploy) | `k8s/argocd/projects/.yaml` | `argocd-projects` (wave -1) | | Registering a project's own GitOps repo | `k8s/projects//gitops.yaml` | `project-gitops` ApplicationSet | | A project's workload itself | **the project's own repo** — not this one | the generated `-` Application | diff --git a/docs/decisions.md b/docs/decisions.md index c5cea35..4c4a842 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -23,6 +23,7 @@ say why rather than deleting it. | [11](#11-alerts-go-to-slack-and-info-level-is-dropped) | Alerts go to Slack, and info-level is dropped | current | | [12](#12-gitops-is-argocd-not-flux) | GitOps is ArgoCD, not Flux | settled | | [13](#13-the-default-appproject-is-emptied) | The `default` AppProject is emptied | current | +| [14](#14-projects-read-their-own-argocd-status-via-kubernetes-rbac-not-an-argocd-ui) | Projects read their own ArgoCD status via RBAC, not an ArgoCD UI | current | --- @@ -519,3 +520,54 @@ permissions. That is the intended behaviour: project assignment becomes deliberate. An Application that suddenly cannot sync after this lands is telling you it never named a project. + +## 14. Projects read their own ArgoCD status via Kubernetes RBAC, not an ArgoCD UI + +**Current.** A project that wants to see whether ArgoCD is syncing its manifests +gets a `Role` in the `argocd` namespace scoped with `resourceNames` to its own +`Application` objects, bound to its GitHub team. There is no ArgoCD web UI, and +`argocd-server` is not exposed. The template is +`k8s/projects/_template/infra/argocd-status-rbac.yaml.example`; it is opt-in, and +a project without it sees nothing in `argocd` at all. + +**Why this and not the UI.** Projects could already see the *effects* of a sync — +Deployments, Pods and Events in their own namespaces, through Headlamp. What they +could not see is a sync that is **failing**, which is the case that matters: a +stalled sync looks exactly like "nothing has happened yet". Verified by +impersonation before this landed: a project developer got `no` for `get`, `list` +and `watch` on `applications.argoproj.io`. + +Exposing the ArgoCD UI behind Dex would also answer it, and `install.md` had +always left that door open. It was not taken because it costs a new public +endpoint, a second authorisation model (`argocd-rbac-cm`, currently empty, with +an unrestricted break-glass `admin` account) and per-project RBAC lines that must +be generated during onboarding or silently drift. The RBAC route reuses the +identity and the tool projects already have. + +**The limitation, stated plainly: `kubectl get app -n argocd` is Forbidden.** +Kubernetes RBAC cannot filter a collection, so `resourceNames` does not restrict +`list` — it only permits `get` on named objects. A developer must therefore name +the Application: + +``` +kubectl get app -n argocd project-infra- \ + -o custom-columns='SYNC:.status.sync.status,HEALTH:.status.health.status' +``` + +The alternative was granting unscoped `list`, which would expose every project's +Application to every project. A label selector does not help — the filtering is +client-side and the request is refused before it. The same applies in Headlamp, +which lists resources: the Application will not appear in a list view. + +**Consequence.** `project-infra` gains `Role` in its +`namespaceResourceWhitelist`, alongside the `RoleBinding` it already had. That +widens the cluster's most privileged AppProject by one kind, which is why the +Role is written per project with explicit `resourceNames` rather than as a shared +ClusterRole: a Role grants no more than the verbs written in it, and only inside +its own namespace. Verbs are `get` and `watch` only — not `patch`, not `delete`, +so a project cannot trigger or abort its own sync. Confirmed by impersonation, +including that another project's team is refused. + +**Revisit if** projects ask for the diff view or a self-service sync button. +Those are real arguments for the UI, and the per-project `resourceNames` work +done here is not wasted if it is built. diff --git a/docs/install.md b/docs/install.md index 2276f84..b4e2870 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1372,8 +1372,10 @@ smaller attack surface, matches the "everything in Git" model. kubectl get app -n argocd # state of everything kubectl annotate app -n argocd \ argocd.argoproj.io/refresh=hard --overwrite # re-read Git now + # sync — syncOptions must be carried over explicitly, see argocd.md + OPTS=$(kubectl get app -n argocd -o jsonpath='{.spec.syncPolicy.syncOptions}') kubectl patch app -n argocd --type merge \ - -p '{"operation":{"initiatedBy":{"username":"'"$USER"'"},"sync":{}}}' # sync + -p '{"operation":{"initiatedBy":{"username":"'"$USER"'"},"sync":{"syncOptions":'"${OPTS:-[]}"'}}}' ``` - **Debug** a stuck sync via a temporary `kubectl -n argocd port-forward svc/argocd-server 8080:443`, logging in with the @@ -1381,10 +1383,18 @@ smaller attack surface, matches the "everything in Git" model. -o jsonpath='{.data.password}' | base64 -d`). Not for daily use — the kubectl path above needs no shared credential. -If a shared dashboard is ever wanted, expose `argocd-server` via Traefik + GitHub -OAuth via ArgoCD's bundled Dex (Scouterna org, teams → RBAC). Not done here by -choice. Note that until it is, `argocd-rbac-cm` is empty and the break-glass -`admin` is unrestricted — one more reason it is not for daily use. +**Projects see their own sync status without any of this** — a Role in `argocd` +scoped to their own Applications, described in +[onboarding.md](onboarding.md#d-see-your-own-argocd-sync-status-optional) and +[decisions.md entry 14](decisions.md#14-projects-read-their-own-argocd-status-via-kubernetes-rbac-not-an-argocd-ui). + +If a shared dashboard is ever wanted anyway, expose `argocd-server` via Traefik +and point ArgoCD's `oidc.config` at the **standalone Dex this cluster already +runs** (§2b) — not ArgoCD's bundled Dex, which would be a second identity +provider for the same GitHub org. Note that until then, `argocd-rbac-cm` is empty +and the break-glass `admin` is unrestricted: exposing the UI without first +setting a default policy of `role:''` would be a real escalation, not a small +one. One more reason it is not for daily use. > **A sync overwrites hand edits.** An app left on manual sync (no `automated:`) > lets a project change the release in the cluster and keeps that drift — but the diff --git a/docs/onboarding.md b/docs/onboarding.md index 8bb0899..e1488c9 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -61,7 +61,7 @@ is excluded. grep -rlZ PROJECT "k8s/projects/$PROJECT/" | xargs -0 sed -i "s/PROJECT/$PROJECT/g" ``` - **Check it before moving on** — the file list should be 6 files, all under + **Check it before moving on** — the file list should be 7 files, all under `k8s/projects/$PROJECT/`, and no `PROJECT` may remain: ```bash @@ -435,6 +435,49 @@ which does not match this table exactly: If you find yourself able to do something on this list, treat it as a gap to report rather than a shortcut. +## D. See your own ArgoCD sync status (optional) + +Your namespaces show you what **is** running. They do not show you whether ArgoCD +is *failing to apply* something — and a stalled sync looks exactly like "my change +has not arrived yet". This section closes that gap. + +It is opt-in and needs the infra team once. Ask them to copy +`k8s/projects/_template/infra/argocd-status-rbac.yaml.example` into your project's +`infra/` directory as `argocd-status-rbac.yaml`, with your project name, your +GitHub team, and the Applications you should see. After it syncs: + +```bash +kubectl get app -n argocd project-infra- \ + -o custom-columns='SYNC:.status.sync.status,HEALTH:.status.health.status' +``` + +`SYNC: Synced` means the cluster matches Git. `OutOfSync` means it does not — +either a sync is in flight, or one failed. For the reason: + +```bash +kubectl get app -n argocd project-infra- \ + -o jsonpath='{.status.operationState.phase}{" "}{.status.operationState.message}{"\n"}' +``` + +**Which Applications exist depends on your route** (section C): + +| Application | When | +|---|---| +| `project-infra-` | always — your namespaces, developer access, database | +| `-dev`, `-prod` | only if you run your own GitOps repo (C2) | + +Each one must be listed by name in the Role, so tell infra which you want. + +**Two things this does not do**, both deliberate ([decisions.md entry +14](decisions.md#14-projects-read-their-own-argocd-status-via-kubernetes-rbac-not-an-argocd-ui)): + +- **`kubectl get app -n argocd` without a name is Forbidden**, and the + Applications do not appear in Headlamp's list view. Kubernetes RBAC cannot + filter a list, so access is granted per named object; permitting the list would + expose every project's Application to every project. Name yours and it works. +- **Read-only.** You can watch a sync; you cannot start or stop one. If you need + to force a sync, ask infra — [argocd.md](argocd.md) has the procedure. + ## Secrets Three paths, pick by how the secret should be owned: diff --git a/k8s/argocd/projects-root/projectset.yaml b/k8s/argocd/projects-root/projectset.yaml index 0e35994..ad9a957 100644 --- a/k8s/argocd/projects-root/projectset.yaml +++ b/k8s/argocd/projects-root/projectset.yaml @@ -44,7 +44,7 @@ spec: # namespace.yaml covers a single-namespace project; namespace-*.yaml # covers dev/prod/staging etc. A file that matches nothing here is # ignored SILENTLY — extend this list when adding a manifest kind. - include: '{namespace.yaml,namespace-*.yaml,developer-rbac.yaml,database.yaml,sealedsecret-*.yaml}' + include: '{namespace.yaml,namespace-*.yaml,developer-rbac.yaml,argocd-status-rbac.yaml,database.yaml,sealedsecret-*.yaml}' destination: server: https://kubernetes.default.svc syncPolicy: diff --git a/k8s/argocd/projects/project-infra.yaml b/k8s/argocd/projects/project-infra.yaml index 655ca57..087e08a 100644 --- a/k8s/argocd/projects/project-infra.yaml +++ b/k8s/argocd/projects/project-infra.yaml @@ -52,6 +52,11 @@ spec: kind: ServiceAccount - group: 'rbac.authorization.k8s.io' kind: RoleBinding + # Role is needed only for the per-project argocd-status Role, which must + # carry resourceNames and so cannot be a shared ClusterRole. A Role grants + # no more than the verbs written in it, and only within its own namespace. + - group: 'rbac.authorization.k8s.io' + kind: Role - group: 'postgresql.cnpg.io' kind: '*' - group: 'external-secrets.io' diff --git a/k8s/projects/_template/infra/argocd-status-rbac.yaml.example b/k8s/projects/_template/infra/argocd-status-rbac.yaml.example new file mode 100644 index 0000000..0fabb14 --- /dev/null +++ b/k8s/projects/_template/infra/argocd-status-rbac.yaml.example @@ -0,0 +1,46 @@ +# Lets this project READ the sync status of its OWN ArgoCD Application(s). +# Copy to argocd-status-rbac.yaml (drop the .example) to activate it. +# +# Application objects live in the `argocd` namespace, which projects otherwise +# have no access to, so without this a project cannot tell whether ArgoCD is +# failing to sync its manifests — a stalled sync looks exactly like "nothing has +# happened yet". Read-only: no sync button, no diff view. For those, see +# docs/argocd.md. +# +# ⚠️ Applications must be listed by name in resourceNames, and the names differ +# per route (see docs/onboarding.md section D): +# project-infra-PROJECT — always exists (namespace, RBAC, database) +# PROJECT-dev / -prod — only if this project runs its OWN GitOps repo +# An Application not named here stays invisible. Adding one later means editing +# this file, so keep it in step with gitops.yaml. +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: PROJECT-argocd-status + namespace: argocd +rules: + - apiGroups: ["argoproj.io"] + resources: ["applications"] + verbs: ["get", "watch"] + resourceNames: + - project-infra-PROJECT + # Uncomment the pair below if this project has a gitops.yaml. + # - PROJECT-dev + # - PROJECT-prod +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: PROJECT-argocd-status + namespace: argocd +subjects: + # Same subject as developer-rbac.yaml — the GitHub team display name, + # verbatim, and it fails silently if wrong. + - kind: Group + name: "aks:jwt:Scouterna:TEAM" + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: PROJECT-argocd-status + apiGroup: rbac.authorization.k8s.io