From 772ed6bf8d095701a72f58371f92217130696d52 Mon Sep 17 00:00:00 2001 From: Philipp Date: Wed, 10 Jun 2026 20:01:22 +0200 Subject: [PATCH] feat: add rbac policy matrix --- CHANGELOG.md | 1 + TODO.md | 6 ++ backend/internal/rbac/policy.go | 83 ++++++++++++++++++++++++++++ backend/internal/rbac/policy_test.go | 58 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 backend/internal/rbac/policy.go create mode 100644 backend/internal/rbac/policy_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9241b83..34cb683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt. - Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt. - Backend-JWT-Middleware mit JWKS-Validierung, lokalem HS256-Fallback und geschuetztem `/me` Endpunkt angelegt. - RLS-Advisor-Cleanup fuer alle uebrigen Public-Tabellen angelegt. diff --git a/TODO.md b/TODO.md index b9b1877..1f9782c 100644 --- a/TODO.md +++ b/TODO.md @@ -82,6 +82,11 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] `profiles` wird idempotent per `insert ... on conflict` erstellt/aktualisiert - [x] Profil-Sync-Middleware mit Unit-Tests angelegt - [x] `/me` fuehrt Profil-Sync vor Handler-Ausfuehrung aus +- [x] E4-T02: Policy-/Permission-Funktion + - [x] Rollen und Aktionen zentral definiert + - [x] `Can(role, action)` angelegt + - [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt + - [x] `cluster.manage` fuer Tenant-Rollen bewusst verweigert ## MVP-Backlog @@ -131,3 +136,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-10: RLS-Advisor-Cleanup `0009_rls_advisor_cleanup` angelegt; alle Public-Tabellen haben RLS aktiv. - 2026-06-10: JWT-Middleware-Tests fuer gueltige, abgelaufene, manipulierte und fehlende Tokens erfolgreich. - 2026-06-10: Profil-Sync-Tests erfolgreich; lokaler `/me` Request legt genau ein `profiles`-Profil an. +- 2026-06-10: RBAC-Policy-Funktion mit tabellengetriebenen Rollen/Aktions-Tests erfolgreich. diff --git a/backend/internal/rbac/policy.go b/backend/internal/rbac/policy.go new file mode 100644 index 0000000..8812dde --- /dev/null +++ b/backend/internal/rbac/policy.go @@ -0,0 +1,83 @@ +package rbac + +type Role string + +const ( + RoleOwner Role = "owner" + RoleAdmin Role = "admin" + RoleMember Role = "member" + RoleViewer Role = "viewer" +) + +type Action string + +const ( + ActionVMRead Action = "vm.read" + ActionVMPower Action = "vm.power" + ActionVMCreate Action = "vm.create" + ActionVMDelete Action = "vm.delete" + ActionVMConsole Action = "vm.console" + ActionProjectRead Action = "project.read" + ActionProjectManage Action = "project.manage" + ActionSSHKeyRead Action = "ssh_key.read" + ActionSSHKeyManage Action = "ssh_key.manage" + ActionAuditRead Action = "audit.read" + ActionClusterManage Action = "cluster.manage" +) + +func Can(role Role, action Action) bool { + allowed, ok := permissions[role] + if !ok { + return false + } + return allowed[action] +} + +var permissions = map[Role]map[Action]bool{ + RoleOwner: allow( + ActionVMRead, + ActionVMPower, + ActionVMCreate, + ActionVMDelete, + ActionVMConsole, + ActionProjectRead, + ActionProjectManage, + ActionSSHKeyRead, + ActionSSHKeyManage, + ActionAuditRead, + ), + RoleAdmin: allow( + ActionVMRead, + ActionVMPower, + ActionVMCreate, + ActionVMDelete, + ActionVMConsole, + ActionProjectRead, + ActionProjectManage, + ActionSSHKeyRead, + ActionSSHKeyManage, + ActionAuditRead, + ), + RoleMember: allow( + ActionVMRead, + ActionVMPower, + ActionVMCreate, + ActionVMConsole, + ActionProjectRead, + ActionSSHKeyRead, + ), + RoleViewer: allow( + ActionVMRead, + ActionVMConsole, + ActionProjectRead, + ActionSSHKeyRead, + ), +} + +func allow(actions ...Action) map[Action]bool { + allowed := make(map[Action]bool, len(actions)) + for _, action := range actions { + allowed[action] = true + } + return allowed +} diff --git a/backend/internal/rbac/policy_test.go b/backend/internal/rbac/policy_test.go new file mode 100644 index 0000000..8ca4abb --- /dev/null +++ b/backend/internal/rbac/policy_test.go @@ -0,0 +1,58 @@ +package rbac + +import "testing" + +func TestCan(t *testing.T) { + tests := []struct { + name string + role Role + action Action + want bool + }{ + {name: "owner can manage project", role: RoleOwner, action: ActionProjectManage, want: true}, + {name: "owner can read audit", role: RoleOwner, action: ActionAuditRead, want: true}, + {name: "admin can delete vm", role: RoleAdmin, action: ActionVMDelete, want: true}, + {name: "admin can manage ssh keys", role: RoleAdmin, action: ActionSSHKeyManage, want: true}, + {name: "member can create vm", role: RoleMember, action: ActionVMCreate, want: true}, + {name: "member cannot delete vm", role: RoleMember, action: ActionVMDelete, want: false}, + {name: "member cannot read audit", role: RoleMember, action: ActionAuditRead, want: false}, + {name: "viewer can read vm", role: RoleViewer, action: ActionVMRead, want: true}, + {name: "viewer can open console", role: RoleViewer, action: ActionVMConsole, want: true}, + {name: "viewer cannot power vm", role: RoleViewer, action: ActionVMPower, want: false}, + {name: "viewer cannot create vm", role: RoleViewer, action: ActionVMCreate, want: false}, + {name: "tenant roles cannot manage cluster", role: RoleOwner, action: ActionClusterManage, want: false}, + {name: "unknown role denied", role: Role("unknown"), action: ActionVMRead, want: false}, + {name: "unknown action denied", role: RoleOwner, action: Action("unknown.action"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Can(tt.role, tt.action); got != tt.want { + t.Fatalf("Can(%q, %q) = %v, want %v", tt.role, tt.action, got, tt.want) + } + }) + } +} + +func TestPolicyMatrix(t *testing.T) { + roles := []Role{RoleOwner, RoleAdmin, RoleMember, RoleViewer} + actions := []Action{ + ActionVMRead, + ActionVMPower, + ActionVMCreate, + ActionVMDelete, + ActionVMConsole, + ActionProjectRead, + ActionProjectManage, + ActionSSHKeyRead, + ActionSSHKeyManage, + ActionAuditRead, + ActionClusterManage, + } + + for _, role := range roles { + for _, action := range actions { + _ = Can(role, action) + } + } +}