Advanced RBAC Design for Multi-Team Organization: Architecture & Implementation
In-depth architectural breakdown and operational implementation for Advanced RBAC Design for Multi-Team Organization: Architecture & Implementation.
Prerequisite knowledge: RBAC fundamentals, namespaces, ServiceAccounts, OPA basics
Question
Your company has three engineering teams: Frontend (deploys React apps), Backend (deploys APIs and workers), and Database (manages PostgreSQL and Redis). Each team has developers, leads, and CI/CD service accounts. Design a complete RBAC system where teams can only access their own namespaces, leads can approve deployments, CI/CD can deploy but not delete, and platform engineers have cluster-wide admin access with audit trails.
Quick Answer (30 sec revision)
Use namespace-per-team isolation with Role and RoleBinding for team-scoped access, ClusterRole aggregation for shared read-only views, separate ServiceAccounts per pipeline with least-privilege verbs, and OPA Gatekeeper to enforce namespace ownership. Use audit logging to track all privileged actions.
Detailed Answer
️ RBAC Architecture
ClusterAdmin (Platform Team)
│
├── ClusterRole: cluster-admin (full access)
└── Bound via ClusterRoleBinding
Team Leads (per team)
│
├── Role: team-lead (in team namespace)
│ ├── deployments: * (all verbs)
│ ├── pods: get, list, watch, delete
│ └── secrets: get, list
└── Bound via RoleBinding (namespace-scoped)
Developers (per team)
│
├── Role: developer (in team namespace)
│ ├── deployments: get, list, watch, update
│ ├── pods: get, list, watch, logs
│ └── configmaps: get, list, watch, create, update
└── Bound via RoleBinding (namespace-scoped)
CI/CD ServiceAccounts (per team)
│
├── Role: cicd-deployer (in team namespace)
│ ├── deployments: get, list, watch, create, update, patch
│ ├── services: get, list, watch, create, update, patch
│ └── NO delete verbs (intentional)
└── Bound via RoleBinding (namespace-scoped)
️ Implementation
Step 1: Namespace Structure
# namespaces.yaml — One namespace per team per environment
---
apiVersion: v1
kind: Namespace
metadata:
name: frontend-production
labels:
team: frontend
environment: production
cost-center: "CC-001"
annotations:
mycompany.com/team-lead: "alice@mycompany.com"
mycompany.com/slack-channel: "#team-frontend"
---
apiVersion: v1
kind: Namespace
metadata:
name: frontend-staging
labels:
team: frontend
environment: staging
---
apiVersion: v1
kind: Namespace
metadata:
name: backend-production
labels:
team: backend
environment: production
cost-center: "CC-002"
annotations:
mycompany.com/team-lead: "bob@mycompany.com"
mycompany.com/slack-channel: "#team-backend"
---
apiVersion: v1
kind: Namespace
metadata:
name: database-production
labels:
team: database
environment: production
cost-center: "CC-003"
annotations:
mycompany.com/team-lead: "carol@mycompany.com"
mycompany.com/slack-channel: "#team-database"
Step 2: ClusterRoles (Reusable Role Templates)
# cluster-roles.yaml
# Role: Read-only access (base for all developers)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: namespace-viewer
labels:
rbac.mycompany.com/aggregate-to-developer: "true"
rules:
- apiGroups: [""]
resources:
- pods
- pods/log
- services
- endpoints
- configmaps
- events
- resourcequotas
- limitranges
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources:
- deployments
- replicasets
- statefulsets
- daemonsets
verbs: ["get", "list", "watch"]
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "networkpolicies"]
verbs: ["get", "list", "watch"]
---
# Role: Developer (read + limited write)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: namespace-developer
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"] # Allow exec into pods for debugging
- apiGroups: [""]
resources: ["pods/portforward"]
verbs: ["create"] # Allow port-forward for local debugging
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update", "patch"]
# Can update deployments (e.g., image tag)
# Cannot CREATE or DELETE deployments
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"] # Can list secret names but not values
# Use resourceNames to restrict further
---
# Role: Team Lead (full namespace control)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: namespace-lead
rules:
- apiGroups: ["", "apps", "batch", "autoscaling", "networking.k8s.io"]
resources: ["*"]
verbs: ["*"] # Full control within namespace
- apiGroups: ["policy"]
resources: ["poddisruptionbudgets"]
verbs: ["*"]
# Leads CANNOT touch:
# - ClusterRoles / ClusterRoleBindings (cluster-scoped)
# - Nodes (cluster-scoped)
# - PersistentVolumes (cluster-scoped)
# - Namespaces themselves (cluster-scoped)
---
# Role: CI/CD Deployer (deploy but never delete)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cicd-deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Explicitly NO "delete" verb
- apiGroups: [""]
resources: ["services", "configmaps", "serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create"]
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Secrets: CI/CD can create but NOT read (write-only for security)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "update", "patch"]
# Cannot read existing secret values
Step 3: RoleBindings per Team per Namespace
# frontend-rbac.yaml — Apply to frontend-production namespace
# Bind developers to namespace-developer role
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: frontend-developers
namespace: frontend-production
subjects:
# Individual developers (from SSO/LDAP group via OIDC)
- kind: Group
name: "frontend-developers" # Maps to OIDC group claim
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: namespace-developer
apiGroup: rbac.authorization.k8s.io
---
# Bind team lead
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: frontend-lead
namespace: frontend-production
subjects:
- kind: Group
name: "frontend-leads"
apiGroup: rbac.authorization.k8s.io
- kind: User
name: "alice@mycompany.com" # Explicit user binding as backup
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: namespace-lead
apiGroup: rbac.authorization.k8s.io
---
# Bind CI/CD service account
apiVersion: v1
kind: ServiceAccount
metadata:
name: frontend-cicd
namespace: frontend-production
annotations:
mycompany.com/purpose: "GitHub Actions deployment pipeline"
mycompany.com/owner: "frontend-lead"
mycompany.com/created: "2026-07-09"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: frontend-cicd-deployer
namespace: frontend-production
subjects:
- kind: ServiceAccount
name: frontend-cicd
namespace: frontend-production
roleRef:
kind: ClusterRole
name: cicd-deployer
apiGroup: rbac.authorization.k8s.io
Step 4: Platform Team Cluster Admin with Audit Trail
# platform-admin-rbac.yaml
# Platform engineers get full cluster access
# BUT via a named ClusterRoleBinding for audit trail
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-team-cluster-admin
annotations:
mycompany.com/approved-by: "CTO"
mycompany.com/review-date: "2026-07-09"
mycompany.com/next-review: "2026-10-09" # Quarterly access review
subjects:
- kind: Group
name: "platform-engineers"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
# Break-glass emergency access (tightly audited)
# Used only during incidents — time-limited via external tooling
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: emergency-break-glass
annotations:
mycompany.com/purpose: "Emergency incident response only"
mycompany.com/approval-required: "true"
mycompany.com/max-duration: "4h"
subjects:
- kind: Group
name: "oncall-engineers"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Step 5: Kubernetes Audit Policy (Tracking Privileged Actions)
# audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Log all secret access at RequestResponse level (captures who read what)
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Log all RBAC changes (who changed who has access)
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources:
- clusterroles
- clusterrolebindings
- roles
- rolebindings
# Log all privileged pod creation
- level: RequestResponse
resources:
- group: ""
resources: ["pods"]
verbs: ["create", "update", "patch"]
# Log all delete operations (who deleted what)
- level: Metadata
verbs: ["delete", "deletecollection"]
# Log namespace-level changes
- level: RequestResponse
resources:
- group: ""
resources: ["namespaces"]
# Platform team actions: log everything they do
- level: RequestResponse
users:
- "system:serviceaccount:kube-system:*"
userGroups:
- "platform-engineers"
# Default: metadata only for read operations (reduces log volume)
- level: Metadata
verbs: ["get", "list", "watch"]
# Skip noisy health check endpoints
- level: None
nonResourceURLs:
- "/healthz*"
- "/readyz*"
- "/livez*"
- "/metrics"
# Enable audit logging in kube-apiserver
# Add to /etc/kubernetes/manifests/kube-apiserver.yaml:
# --audit-policy-file=/etc/kubernetes/audit-policy.yaml
# --audit-log-path=/var/log/kubernetes/audit.log
# --audit-log-maxage=30 # Keep 30 days
# --audit-log-maxbackup=10 # Keep 10 rotated files
# --audit-log-maxsize=100 # Rotate at 100MB
# Query audit logs for secret access
cat /var/log/kubernetes/audit.log | \
jq 'select(.objectRef.resource == "secrets") |
select(.verb == "get") | {
time: .requestReceivedTimestamp,
user: .user.username,
groups: .user.groups,
secret: .objectRef.name,
namespace: .objectRef.namespace,
sourceIP: .sourceIPs[0]
}'
# Find who deleted a deployment
cat /var/log/kubernetes/audit.log | \
jq 'select(.objectRef.resource == "deployments") |
select(.verb == "delete") | {
time: .requestReceivedTimestamp,
user: .user.username,
deployment: .objectRef.name,
namespace: .objectRef.namespace
}'
Step 6: OPA Policy — Enforce Namespace Ownership
# policy/namespace-ownership.rego
package namespace.ownership
# Deny cross-namespace resource creation
deny[msg] {
input.review.kind.kind == "Deployment"
namespace := input.review.object.metadata.namespace
# Get the team label from the namespace
# (In practice, fetch from namespace object via Gatekeeper's data.inventory)
namespace_team := data.inventory.cluster["v1"]["Namespace"][namespace].metadata.labels.team
# Get the user's team from their group membership
user_groups := input.review.userInfo.groups
user_team := [g | g := user_groups[_]; startswith(g, "team-")][0]
user_team_name := trim_prefix(user_team, "team-")
# Deny if user's team doesn't match namespace team
user_team_name != namespace_team
msg := sprintf(
"User from team '%v' cannot create resources in namespace '%v' (owned by team '%v')",
[user_team_name, namespace, namespace_team]
)
}
Step 7: ServiceAccount Token Best Practices
# Use projected service account tokens (short-lived, audience-bound)
apiVersion: v1
kind: Pod
metadata:
name: cicd-runner
namespace: frontend-production
spec:
serviceAccountName: frontend-cicd
automountServiceAccountToken: false # Disable auto-mount
volumes:
- name: cicd-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 1-hour token (vs default permanent)
audience: "kubernetes" # Audience-bound (prevents token reuse)
containers:
- name: cicd-runner
image: registry.mycompany.com/cicd-runner:latest
volumeMounts:
- name: cicd-token
mountPath: /var/run/secrets/kubernetes.io/serviceaccount
readOnly: true
Step 8: Quarterly Access Review Automation
#!/usr/bin/env python3
# access-review.py — Generate quarterly RBAC access report
from kubernetes import client, config
import json
from datetime import datetime
def generate_access_report():
config.load_incluster_config() # or load_kube_config() locally
rbac_v1 = client.RbacAuthorizationV1Api()
core_v1 = client.CoreV1Api()
report = {
"generated_at": datetime.utcnow().isoformat(),
"cluster_role_bindings": [],
"role_bindings_by_namespace": {},
"service_accounts": [],
"findings": []
}
# Audit ClusterRoleBindings
crbs = rbac_v1.list_cluster_role_binding()
for crb in crbs.items:
for subject in (crb.subjects or []):
entry = {
"binding": crb.metadata.name,
"role": crb.role_ref.name,
"subject_kind": subject.kind,
"subject_name": subject.name,
"created": str(crb.metadata.creation_timestamp),
"annotations": crb.metadata.annotations or {}
}
report["cluster_role_bindings"].append(entry)
# Flag: cluster-admin bindings need justification
if crb.role_ref.name == "cluster-admin":
if "mycompany.com/approved-by" not in (crb.metadata.annotations or {}):
report["findings"].append({
"severity": "HIGH",
"type": "MISSING_APPROVAL",
"message": f"ClusterRoleBinding '{crb.metadata.name}' grants cluster-admin without approval annotation",
"binding": crb.metadata.name
})
# Audit RoleBindings per namespace
namespaces = core_v1.list_namespace()
for ns in namespaces.items:
ns_name = ns.metadata.name
rbs = rbac_v1.list_namespaced_role_binding(ns_name)
report["role_bindings_by_namespace"][ns_name] = []
for rb in rbs.items:
for subject in (rb.subjects or []):
entry = {
"binding": rb.metadata.name,
"role": rb.role_ref.name,
"subject_kind": subject.kind,
"subject_name": subject.name,
"namespace": ns_name
}
report["role_bindings_by_namespace"][ns_name].append(entry)
# Audit ServiceAccounts — flag ones with no usage annotation
for ns in namespaces.items:
sas = core_v1.list_namespaced_service_account(ns.metadata.name)
for sa in sas.items:
if sa.metadata.name == "default":
continue
annotations = sa.metadata.annotations or {}
if "mycompany.com/purpose" not in annotations:
report["findings"].append({
"severity": "MEDIUM",
"type": "UNDOCUMENTED_SERVICE_ACCOUNT",
"message": f"ServiceAccount '{sa.metadata.name}' in namespace '{ns.metadata.name}' has no purpose annotation",
"namespace": ns.metadata.name,
"service_account": sa.metadata.name
})
return report
if __name__ == "__main__":
report = generate_access_report()
# Print summary
print(f"\n=== RBAC Access Review Report ===")
print(f"Generated: {report['generated_at']}")
print(f"ClusterRoleBindings: {len(report['cluster_role_bindings'])}")
print(f"Findings: {len(report['findings'])}")
# Print findings
if report["findings"]:
print("\n⚠️ FINDINGS:")
for finding in report["findings"]:
print(f" [{finding['severity']}] {finding['type']}: {finding['message']}")
# Save full report
with open(f"rbac-report-{datetime.now().strftime('%Y%m%d')}.json", "w") as f:
json.dump(report, f, indent=2, default=str)
print(f"\nFull report saved to rbac-report-{datetime.now().strftime('%Y%m%d')}.json")
️ Trade-offs: RBAC Design Approaches
| Pattern | Isolation | Flexibility | Maintenance | Best For | |---|---|---|---|---| | Namespace per team | Strong | Medium | Medium | Clear team boundaries | | Namespace per app | Very Strong | Low | High | Microservices with strict isolation | | Shared namespace | Weak | High | Low | Small teams, simple apps | | Virtual clusters (vCluster) | Very Strong | High | High | True multi-tenancy | | Hierarchical namespaces (HNC) | Strong | High | Medium | Large orgs with subteams |
️ Common Mistakes & Misconceptions
- "Using
cluster-adminfor CI/CD is easier." — It is easier until a compromised pipeline deletes your production namespace. CI/CD service accounts should have the minimum verbs needed — typically create, update, patch but never delete. - "Roles are cluster-scoped." —
Roleis namespace-scoped.ClusterRoleis cluster-scoped. AClusterRolebound with aRoleBinding(notClusterRoleBinding) only grants access within that namespace — a useful pattern for reusable role templates. - "RBAC prevents all unauthorized access." — RBAC controls the Kubernetes API. It does not control network access between pods (use NetworkPolicy), application-level authorization, or node-level access via SSH.
- "Default service accounts are safe to use." — The
defaultServiceAccount in every namespace should haveautomountServiceAccountToken: falseset, otherwise every pod gets API credentials it likely doesn't need.
Key Takeaway
Effective RBAC design follows three principles: least privilege (give only what's needed), explicit bindings (name every binding so it's auditable), and regular review (access creep is real — quarterly reviews catch it). The distinction between Role/ClusterRole and RoleBinding/ClusterRoleBinding is fundamental — use ClusterRole as reusable templates bound with namespace-scoped RoleBindings to avoid duplicating role definitions across namespaces.
Self-Assessment Checklist
- [ ] Can you explain the difference between
RoleandClusterRole? - [ ] Can you explain why binding a
ClusterRolewith aRoleBindingis namespace-scoped? - [ ] Can you write a CI/CD ServiceAccount with deploy-but-no-delete permissions?
- [ ] Can you describe what an audit policy
level: RequestResponsecaptures vslevel: Metadata? - [ ] Can you explain why
automountServiceAccountToken: falseis a security best practice?
Finished reading?
Mark as complete to claim your +20 XP and track progress.
