Skip to content

Commit 6eb805a

Browse files
committed
feat: introduce RBAC support with user status tracking
feat: add virtual resource UserGroup chore: rename user to usergroup feat: implement User Groups page with detailed views refactor: update RulesTable and UserGroupAllRules to use PolicyRule type for improved type safety
1 parent 191db73 commit 6eb805a

File tree

20 files changed

+973
-26
lines changed

20 files changed

+973
-26
lines changed

cmd/server/main.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,10 @@ import (
88

99
"github.com/iwanhae/kuview/pkg/controller"
1010
"github.com/iwanhae/kuview/pkg/server"
11+
"github.com/iwanhae/kuview/pkg/types"
1112
"github.com/rs/zerolog"
1213
"github.com/rs/zerolog/log"
13-
v1 "k8s.io/api/core/v1"
14-
discoveryv1 "k8s.io/api/discovery/v1"
15-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1614
ctrl "sigs.k8s.io/controller-runtime"
17-
"sigs.k8s.io/controller-runtime/pkg/client"
1815
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
1916
)
2017

@@ -43,13 +40,7 @@ func run(ctx context.Context) error {
4340

4441
mgr, err := controller.New(
4542
ctx, *cfg,
46-
[]client.Object{
47-
&v1.Node{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Node"}},
48-
&v1.Pod{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}},
49-
&v1.Namespace{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Namespace"}},
50-
&v1.Service{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}},
51-
&discoveryv1.EndpointSlice{TypeMeta: metav1.TypeMeta{APIVersion: "discovery.k8s.io/v1", Kind: "EndpointSlice"}},
52-
},
43+
types.ObjectSchemas,
5344
s,
5445
)
5546
if err != nil {

cmd/wasm/main.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,10 @@ import (
88
"syscall/js"
99

1010
"github.com/iwanhae/kuview/pkg/controller"
11+
"github.com/iwanhae/kuview/pkg/types"
1112
"github.com/rs/zerolog"
1213
"github.com/rs/zerolog/log"
13-
v1 "k8s.io/api/core/v1"
14-
discoveryv1 "k8s.io/api/discovery/v1"
15-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1614
"k8s.io/client-go/rest"
17-
"sigs.k8s.io/controller-runtime/pkg/client"
1815

1916
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
2017
)
@@ -45,13 +42,7 @@ func run(ctx context.Context) error {
4542

4643
mgr, err := controller.New(
4744
ctx, cfg,
48-
[]client.Object{
49-
&v1.Node{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Node"}},
50-
&v1.Pod{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}},
51-
&v1.Namespace{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Namespace"}},
52-
&v1.Service{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}},
53-
&discoveryv1.EndpointSlice{TypeMeta: metav1.TypeMeta{APIVersion: "discovery.k8s.io/v1", Kind: "EndpointSlice"}},
54-
},
45+
types.ObjectSchemas,
5546
emitter,
5647
)
5748
if err != nil {

pkg/controller/controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func New(ctx context.Context, cfg rest.Config, objs []client.Object, emitter Emi
4242
// Get dereferenced type of the object
4343
T := reflect.TypeOf(obj).Elem()
4444
c, err := controller.New(
45-
fmt.Sprintf("kuview_%s", obj.GetObjectKind().GroupVersionKind().String()),
45+
fmt.Sprintf("kuview_%s/%s", obj.GetObjectKind().GroupVersionKind().Group, obj.GetObjectKind().GroupVersionKind().Kind),
4646
mgr, controller.Options{
4747
Reconciler: &dummyReconciler{
4848
T: T,

pkg/server/event.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ func (s *Server) Emit(v *controller.Event) {
4444
gvk := v.Object.GetObjectKind().GroupVersionKind()
4545
namespace := v.Object.GetNamespace()
4646
name := v.Object.GetName()
47-
key := fmt.Sprintf("%s/%s/%s/%s", gvk.Group, gvk.Version, namespace, name)
47+
key := fmt.Sprintf("%s/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, namespace, name)
4848

49-
if v.Type == controller.EventTypeCreate {
49+
switch v.Type {
50+
case controller.EventTypeCreate:
5051
s.rwmu.Lock()
5152
s.cache[key] = v.Object
5253
s.rwmu.Unlock()
53-
} else if v.Type == controller.EventTypeDelete {
54+
case controller.EventTypeDelete:
5455
s.rwmu.Lock()
5556
delete(s.cache, key)
5657
s.rwmu.Unlock()

pkg/types/const.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package types
2+
3+
import (
4+
v1 "k8s.io/api/core/v1"
5+
discoveryv1 "k8s.io/api/discovery/v1"
6+
rbacv1 "k8s.io/api/rbac/v1"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"sigs.k8s.io/controller-runtime/pkg/client"
9+
)
10+
11+
var ObjectSchemas = []client.Object{
12+
&v1.Node{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Node"}},
13+
&v1.Pod{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}},
14+
&v1.Namespace{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Namespace"}},
15+
&v1.Service{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}},
16+
&v1.ServiceAccount{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"}},
17+
&discoveryv1.EndpointSlice{TypeMeta: metav1.TypeMeta{APIVersion: "discovery.k8s.io/v1", Kind: "EndpointSlice"}},
18+
&rbacv1.ClusterRole{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole"}},
19+
&rbacv1.ClusterRoleBinding{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRoleBinding"}},
20+
&rbacv1.Role{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"}},
21+
&rbacv1.RoleBinding{TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"}},
22+
}

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import KuviewBackground from "./backgrounds/kuview";
99
import Debug from "./pages/debug";
1010
import { PREFIX } from "./lib/const";
1111
import { AppSidebar } from "./components/app-sidebar";
12+
import UserGroupsPage from "./pages/usergroups";
1213

1314
export default function Page() {
1415
return (
@@ -23,6 +24,7 @@ export default function Page() {
2324
<Route path={`${PREFIX}/pods`} component={Pod} />
2425
<Route path={`${PREFIX}/namespaces`} component={Namespace} />
2526
<Route path={`${PREFIX}/services`} component={Service} />
27+
<Route path={`${PREFIX}/usergroups`} component={UserGroupsPage} />
2628
<Route path={`${PREFIX}/debug`} component={Debug} />
2729
</Switch>
2830
</div>

src/backgrounds/kuview.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
useKubernetesAtomSyncHook,
77
useServiceEndpointSliceSyncHook,
88
} from "@/lib/kuviewAtom";
9+
910
import { useAtomValue } from "jotai";
1011
import { useEffect } from "react";
12+
import { SyncUserGroup } from "./userGroup";
1113

1214
interface WindowWithKuview extends Window {
1315
kuview: (event: KuviewEvent) => void;
@@ -70,6 +72,7 @@ function SyncKubernetes() {
7072
<SyncKubernetesGVK key={gvk} gvk={gvk} />
7173
))}
7274
<SyncService />
75+
<SyncUserGroup />
7376
</>
7477
);
7578
}

src/backgrounds/userGroup.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useEffect } from "react";
2+
import { useAtomValue, useSetAtom } from "jotai";
3+
import { kubernetesAtom } from "@/lib/kuviewAtom";
4+
import {
5+
type ClusterRoleObject,
6+
type RoleObject,
7+
type UserGroupObject,
8+
type UserGroupSpec,
9+
} from "@/lib/kuview";
10+
import { useKuview } from "@/hooks/useKuview";
11+
import { Status, type Condition } from "@/lib/status";
12+
13+
// Create an virtual resource kuview.iwanhae.kr/v1/UserGroup
14+
export function SyncUserGroup() {
15+
const r = useKuview("rbac.authorization.k8s.io/v1/Role");
16+
const rb = useKuview("rbac.authorization.k8s.io/v1/RoleBinding");
17+
const cr = useKuview("rbac.authorization.k8s.io/v1/ClusterRole");
18+
const crb = useKuview("rbac.authorization.k8s.io/v1/ClusterRoleBinding");
19+
20+
const kubernetes = useAtomValue(kubernetesAtom);
21+
22+
const userGroupAtom = kubernetes["kuview.iwanhae.kr/v1/UserGroup"];
23+
const setUserGroup = useSetAtom(userGroupAtom);
24+
25+
useEffect(() => {
26+
const ug = new Map<string, UserGroupSpec>();
27+
28+
for (const binding of [...Object.values(rb), ...Object.values(crb)]) {
29+
// find the role
30+
let role: RoleObject | ClusterRoleObject | undefined = undefined;
31+
if (binding.roleRef.kind === "Role") {
32+
role =
33+
r[`${binding.metadata.namespace}/${binding.roleRef.name}`] ??
34+
undefined;
35+
} else if (binding.roleRef.kind === "ClusterRole") {
36+
role = cr[binding.roleRef.name] ?? undefined;
37+
}
38+
39+
if (!role) {
40+
continue;
41+
}
42+
43+
// assign roles to subjects
44+
for (const subject of binding.subjects || []) {
45+
let name = subject.name;
46+
let s: UserGroupSpec;
47+
switch (subject.kind) {
48+
case "User":
49+
s = ug.get(name) ?? { bindings: [], roles: [], type: "User" };
50+
ug.set(name, {
51+
bindings: [binding, ...s.bindings],
52+
roles: [role, ...s.roles],
53+
type: "User",
54+
});
55+
break;
56+
case "ServiceAccount":
57+
name = `system:serviceaccount:${subject.namespace}:${subject.name}`;
58+
s = ug.get(name) ?? { bindings: [], roles: [], type: "User" };
59+
ug.set(name, {
60+
bindings: [binding, ...s.bindings],
61+
roles: [role, ...s.roles],
62+
type: "User",
63+
});
64+
break;
65+
case "Group":
66+
name = `@${name}`;
67+
s = ug.get(name) ?? { bindings: [], roles: [], type: "Group" };
68+
ug.set(name, {
69+
bindings: [binding, ...s.bindings],
70+
roles: [role, ...s.roles],
71+
type: "Group",
72+
});
73+
break;
74+
}
75+
}
76+
}
77+
78+
const result: Record<string, UserGroupObject> = {};
79+
80+
for (const [name, spec] of ug) {
81+
result[name] = {
82+
kind: "UserGroup",
83+
apiVersion: "kuview.iwanhae.kr/v1",
84+
metadata: {
85+
name,
86+
uid: "",
87+
resourceVersion: "",
88+
creationTimestamp: "",
89+
},
90+
spec,
91+
status: {},
92+
kuviewExtra: { ...getStatus(spec.roles) },
93+
};
94+
}
95+
96+
setUserGroup(result);
97+
}, [r, rb, cr, crb, setUserGroup]);
98+
99+
return <></>;
100+
}
101+
102+
function getStatus(roles: (RoleObject | ClusterRoleObject)[]): Condition {
103+
// Error: no roles
104+
if (roles.length === 0) {
105+
return {
106+
status: Status.Error,
107+
reason: "has no roles",
108+
};
109+
}
110+
111+
// Error: all roles have no rules
112+
if (roles.every((role) => (role.rules ?? []).length === 0)) {
113+
return {
114+
status: Status.Error,
115+
reason: `has ${roles.length} roles, but all of them have no rules`,
116+
};
117+
}
118+
119+
// Warn: if has ClusterRole named cluster-admin
120+
if (roles.some((role) => role.metadata.name === "cluster-admin")) {
121+
return {
122+
status: Status.Warning,
123+
reason: "has cluster-admin role, which is too powerful",
124+
};
125+
}
126+
127+
return {
128+
status: Status.Running,
129+
reason: `has ${roles.length} roles`,
130+
};
131+
}

0 commit comments

Comments
 (0)