Skip to content

Commit 347f847

Browse files
feat: added account creation authz check (#8)
* feat: added account creation authz check * fix: changed authz checks logic * feat: added cached orgs workspace and store id * fix: correct retrieval of the workspaceID * chore: linting issues * fix: fixed account info nil error * chore: minor refactoring * chore: more refactoring * fix: fixed nil mapper in tests --------- Co-authored-by: aaronschweig <[email protected]>
1 parent e7d06ea commit 347f847

File tree

5 files changed

+177
-46
lines changed

5 files changed

+177
-46
lines changed

cmd/serve.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package cmd
33
import (
44
"context"
55
"crypto/tls"
6+
"fmt"
67
"net/http"
8+
"net/url"
9+
"path"
710

811
kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
912
"github.com/kcp-dev/multicluster-provider/apiexport"
@@ -25,10 +28,15 @@ import (
2528
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2629
"k8s.io/client-go/tools/clientcmd"
2730

31+
tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1"
2832
"github.com/platform-mesh/rebac-authz-webhook/pkg/client"
2933
"github.com/platform-mesh/rebac-authz-webhook/pkg/config"
3034
"github.com/platform-mesh/rebac-authz-webhook/pkg/handler"
35+
36+
kcpclient "github.com/kcp-dev/kcp/pkg/client/clientset/versioned"
37+
openfgav1 "github.com/openfga/api/proto/openfga/v1"
3138
"github.com/platform-mesh/rebac-authz-webhook/pkg/mapperprovider"
39+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3240
)
3341

3442
var (
@@ -66,11 +74,22 @@ func serve() { // coverage-ignore
6674
kcpScheme := runtime.NewScheme()
6775
utilruntime.Must(kcpcorev1alpha1.AddToScheme(kcpScheme))
6876
utilruntime.Must(accountsv1alpha1.AddToScheme(kcpScheme))
77+
utilruntime.Must(tenancyv1alpha1.AddToScheme(kcpScheme))
6978

7079
srv := mgr.GetWebhookServer()
7180
cmw := &ContextMiddleware{Logger: log}
7281

73-
authHandler, err := handler.NewAuthorizationHandler(fga, mgr, serverCfg.Kcp.AccountInfoName, mps)
82+
orgsWorkspaceID, err := getOrgWorkspaceID(ctx, serverCfg)
83+
if err != nil {
84+
log.Fatal().Err(err).Msg("cannot get organization's workspace ID")
85+
}
86+
87+
orgsStoreID, err := getOrgStoreID(ctx, fga, "orgs")
88+
if err != nil {
89+
log.Fatal().Err(err).Msg("cannot get organization's store ID")
90+
}
91+
92+
authHandler, err := handler.NewAuthorizationHandler(fga, mgr, serverCfg.Kcp.AccountInfoName, orgsStoreID, orgsWorkspaceID, mps)
7493
if err != nil {
7594
log.Fatal().Err(err).Msg("could not create authorization handler")
7695
}
@@ -169,3 +188,48 @@ func (c *ContextMiddleware) Middleware(next http.Handler) http.Handler {
169188
next.ServeHTTP(w, r.WithContext(ctx))
170189
})
171190
}
191+
192+
func getOrgWorkspaceID(ctx context.Context, serviceCfg config.Config) (string, error) {
193+
194+
kubeconfigPath := serviceCfg.Kcp.KubeconfigPath
195+
kcpCfg, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
196+
if err != nil {
197+
return "", fmt.Errorf("failed to build config from kubeconfig: %w", err)
198+
}
199+
200+
parsed, err := url.Parse(kcpCfg.Host)
201+
if err != nil {
202+
return "", fmt.Errorf("failed to parse host URL: %w", err)
203+
}
204+
205+
parsed.Path = path.Join("clusters", "root")
206+
207+
wsCfg := rest.CopyConfig(kcpCfg)
208+
wsCfg.Host = parsed.String()
209+
210+
kcpClientSet, err := kcpclient.NewForConfig(wsCfg)
211+
if err != nil {
212+
return "", err
213+
}
214+
215+
orgWorkspace, err := kcpClientSet.TenancyV1alpha1().Workspaces().Get(ctx, "orgs", metav1.GetOptions{})
216+
if err != nil {
217+
return "", err
218+
}
219+
220+
return orgWorkspace.Spec.Cluster, nil
221+
}
222+
223+
func getOrgStoreID(ctx context.Context, fga openfgav1.OpenFGAServiceClient, storeName string) (string, error) {
224+
stores, err := fga.ListStores(ctx, &openfgav1.ListStoresRequest{})
225+
if err != nil {
226+
return "", err
227+
}
228+
229+
for _, store := range stores.Stores {
230+
if store.Name == storeName {
231+
return store.Id, nil
232+
}
233+
}
234+
return "", fmt.Errorf("store %s doesn't exist", storeName)
235+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ replace (
1111

1212
require (
1313
github.com/go-logr/logr v1.4.3
14+
github.com/kcp-dev/kcp/pkg/client v0.0.0-20230328080949-0f3bb69fdc08
1415
github.com/kcp-dev/kcp/sdk v0.27.1
1516
github.com/kcp-dev/logicalcluster/v3 v3.0.5
1617
github.com/kcp-dev/multicluster-provider v0.1.0
@@ -64,6 +65,7 @@ require (
6465
github.com/josharian/intern v1.0.0 // indirect
6566
github.com/json-iterator/go v1.1.12 // indirect
6667
github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250223115924-431177b024f3 // indirect
68+
github.com/kcp-dev/kcp/pkg/apis v0.11.0 // indirect
6769
github.com/mailru/easyjson v0.7.7 // indirect
6870
github.com/mattn/go-colorable v0.1.14 // indirect
6971
github.com/mattn/go-isatty v0.0.20 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
9595
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
9696
github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250223115924-431177b024f3 h1:YwNX7ZIpQXg9u5vav/fobmf4nnO0WhbELWaL3X74Oe4=
9797
github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250223115924-431177b024f3/go.mod h1:n0+EV+LGKl1MXXqGbGcn0AaBv7hdKsdazSYuq8nM8Us=
98+
github.com/kcp-dev/kcp/pkg/apis v0.11.0 h1:K6p+tNHNcvfACCPLcHgY0EMLeaIwR1jS491FyLfXMII=
99+
github.com/kcp-dev/kcp/pkg/apis v0.11.0/go.mod h1:8cUAmfMJcksauz53UtsLYG8Phhx62rvuCnd/5t/Zihk=
100+
github.com/kcp-dev/kcp/pkg/client v0.0.0-20230328080949-0f3bb69fdc08 h1:0blsXyXkTOvlNDk0w76LjQ2TsE3GWhfhS2gnDnj0Ic8=
101+
github.com/kcp-dev/kcp/pkg/client v0.0.0-20230328080949-0f3bb69fdc08/go.mod h1:RbjVziId9vruNj7tvsM+awj8kmO3EfV//xkYCDX8mPY=
98102
github.com/kcp-dev/kcp/sdk v0.27.1 h1:jBVdrZoJd5hy2RqaBnmCCzldimwOqDkf8FXtNq5HaWA=
99103
github.com/kcp-dev/kcp/sdk v0.27.1/go.mod h1:3eRgW42d81Ng60DbG1xbne0FSS2znpcN/GUx4rqJgUo=
100104
github.com/kcp-dev/logicalcluster/v3 v3.0.5 h1:JbYakokb+5Uinz09oTXomSUJVQsqfxEvU4RyHUYxHOU=

pkg/handler/handler.go

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/prometheus/client_golang/prometheus"
1616
authorizationv1 "k8s.io/api/authorization/v1"
1717
"k8s.io/apimachinery/pkg/runtime/schema"
18+
1819
"k8s.io/apimachinery/pkg/types"
1920
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
2021
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
@@ -28,6 +29,8 @@ import (
2829
type AuthorizationHandler struct {
2930
fga openfgav1.OpenFGAServiceClient
3031
accountInfoName string
32+
orgStoreID string
33+
orgWorkspaceID string
3134
mgr mcmanager.Manager
3235
mps *mapperprovider.MapperProviders
3336
}
@@ -40,12 +43,15 @@ var (
4043
})
4144
)
4245

43-
func NewAuthorizationHandler(fga openfgav1.OpenFGAServiceClient, mgr mcmanager.Manager, accountInfoName string, mps *mapperprovider.MapperProviders) (*AuthorizationHandler, error) {
46+
const rootOrgName = "tenancy_kcp_io_workspace:orgs"
4447

48+
func NewAuthorizationHandler(fga openfgav1.OpenFGAServiceClient, mgr mcmanager.Manager, accountInfoName string, orgStoreID, orgWorkspaceID string, mps *mapperprovider.MapperProviders) (*AuthorizationHandler, error) {
4549
return &AuthorizationHandler{
4650
fga: fga,
4751
accountInfoName: accountInfoName,
4852
mgr: mgr,
53+
orgStoreID: orgStoreID,
54+
orgWorkspaceID: orgWorkspaceID,
4955
mps: mps,
5056
}, nil
5157
}
@@ -55,10 +61,12 @@ var ErrNoStoreID = errors.New("no store ID found")
5561
func (a *AuthorizationHandler) getAccountInfo(ctx context.Context, sar authorizationv1.SubjectAccessReview) (*corev1alpha1.AccountInfo, error) {
5662
log := logger.LoadLoggerFromContext(ctx)
5763
info := &corev1alpha1.AccountInfo{}
64+
5865
clusterNameAttr, ok := sar.Spec.Extra["authorization.kubernetes.io/cluster-name"]
5966
if !ok || len(clusterNameAttr) == 0 {
6067
return nil, errors.New("no cluster name found in the request")
6168
}
69+
6270
log.Debug().Str("cluster", clusterNameAttr[0]).Str("accountInfoName", a.accountInfoName).Msg("Looking for AccountInfo")
6371

6472
cluster, err := a.mgr.GetCluster(ctx, clusterNameAttr[0])
@@ -76,6 +84,7 @@ func (a *AuthorizationHandler) getAccountInfo(ctx context.Context, sar authoriza
7684
log.Error().Msg("AccountInfo found but Store.Id is empty")
7785
return nil, ErrNoStoreID
7886
}
87+
7988
log.Debug().Str("storeId", info.Spec.FGA.Store.Id).Msg("Retrieved Store ID")
8089

8190
return info, nil
@@ -91,6 +100,7 @@ func (a *AuthorizationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
91100
http.Error(w, err.Error(), http.StatusBadRequest)
92101
return
93102
}
103+
94104
err = r.Body.Close()
95105
if err != nil {
96106
log.Error().Err(err).Msg("unable to close the request body")
@@ -128,28 +138,14 @@ func (a *AuthorizationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
128138

129139
log.Debug().Str("sar", fmt.Sprintf("%+v", sar)).Msg("Received SubjectAccessReview")
130140

131-
// For resource attributes, we need to get the store ID
132-
accountInfo, err := a.getAccountInfo(r.Context(), sar)
133-
if err != nil {
134-
log.Error().Err(err).Str("user", sar.Spec.User).Msg("error getting store ID from account info, responding with no opinion")
135-
noOpinion(w, sar)
136-
return
137-
}
138-
139-
group := util.CapGroupToRelationLength(sar, 50)
140-
group = strings.ReplaceAll(group, ".", "_")
141-
relation := sar.Spec.ResourceAttributes.Verb
142-
143141
var clusterName string
144142
if sar.Spec.Extra != nil {
145143
if clusterNames, exists := sar.Spec.Extra["authorization.kubernetes.io/cluster-name"]; exists && len(clusterNames) > 0 {
146144
clusterName = clusterNames[0]
147145
}
148146
}
149-
log = log.ChildLogger("clusterName", clusterName)
150147

151-
var namespaced bool
152-
var gvk schema.GroupVersionKind
148+
log = log.ChildLogger("clusterName", clusterName)
153149

154150
restMapper, ok := a.mps.GetMapper(logicalcluster.Name(clusterName))
155151
if !ok {
@@ -158,6 +154,46 @@ func (a *AuthorizationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
158154
return
159155
}
160156

157+
group := util.CapGroupToRelationLength(sar, 50)
158+
group = strings.ReplaceAll(group, ".", "_")
159+
relation := sar.Spec.ResourceAttributes.Verb
160+
161+
user := fmt.Sprintf("user:%s", sar.Spec.User)
162+
163+
// request to orgs workspace
164+
if a.orgWorkspaceID == clusterName {
165+
relation = fmt.Sprintf("%s_%s_%s", sar.Spec.ResourceAttributes.Verb, group, sar.Spec.ResourceAttributes.Resource)
166+
object := rootOrgName
167+
168+
allowed, err := a.checkPermissions(r.Context(), object, relation, user, a.orgStoreID, nil)
169+
if err != nil {
170+
log.Error().Err(err).Str("storeId", a.orgStoreID).Str("object", object).Str("relation", relation).Str("user", user).Msg("unable to call upstream openfga")
171+
noOpinion(w, sar)
172+
return
173+
}
174+
175+
log.Info().Str("allowed", fmt.Sprintf("%t", allowed)).Str("user", user).Str("object", object).Str("relation", relation).Msg("sar response")
176+
177+
if !allowed {
178+
noOpinion(w, sar)
179+
return
180+
}
181+
182+
writeResponse(w, sar, allowed)
183+
return
184+
}
185+
186+
// For resource attributes, we need to get the store ID
187+
accountInfo, err := a.getAccountInfo(r.Context(), sar)
188+
if err != nil {
189+
log.Error().Err(err).Str("user", sar.Spec.User).Msg("error getting store ID from account info")
190+
noOpinion(w, sar)
191+
return
192+
}
193+
194+
var namespaced bool
195+
var gvk schema.GroupVersionKind
196+
161197
gvr := schema.GroupVersionResource{
162198
Group: sar.Spec.ResourceAttributes.Group,
163199
Resource: sar.Spec.ResourceAttributes.Resource,
@@ -240,46 +276,64 @@ func (a *AuthorizationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
240276
return
241277
}
242278

279+
allowed, err := a.checkPermissions(r.Context(), object, relation, user, accountInfo.Spec.FGA.Store.Id, contextualTuples)
280+
if err != nil {
281+
log.Error().Err(err).Str("storeId", a.orgStoreID).Str("object", object).Str("relation", relation).Str("user", user).Msg("unable to call upstream openfga")
282+
noOpinion(w, sar)
283+
return
284+
}
285+
log.Info().Str("allowed", fmt.Sprintf("%t", allowed)).Str("user", user).Str("object", object).Str("relation", relation).Msg("sar response")
286+
287+
if !allowed {
288+
noOpinion(w, sar)
289+
return
290+
}
291+
292+
writeResponse(w, sar, allowed)
293+
}
294+
295+
func (a *AuthorizationHandler) checkPermissions(ctx context.Context, object, relation, user, storeID string, contextualTuples []*openfgav1.TupleKey) (bool, error) {
243296
preReq := time.Now()
244-
res, err := a.fga.Check(r.Context(), &openfgav1.CheckRequest{
245-
StoreId: accountInfo.Spec.FGA.Store.Id,
297+
298+
checkReq := &openfgav1.CheckRequest{
299+
StoreId: storeID,
246300
TupleKey: &openfgav1.CheckRequestTupleKey{
247301
Object: object,
248302
Relation: relation,
249-
User: fmt.Sprintf("user:%s", sar.Spec.User),
303+
User: user,
250304
},
251-
ContextualTuples: &openfgav1.ContextualTupleKeys{
305+
}
306+
307+
if contextualTuples != nil {
308+
checkReq.ContextualTuples = &openfgav1.ContextualTupleKeys{
252309
TupleKeys: contextualTuples,
253-
},
254-
})
310+
}
311+
}
255312

313+
res, err := a.fga.Check(ctx, checkReq)
256314
openfgaLatency.Observe(time.Since(preReq).Seconds())
257315
if err != nil {
258-
log.Error().Err(err).Str("storeId", accountInfo.Spec.FGA.Store.Id).Str("object", object).Str("relation", relation).Str("user", sar.Spec.User).Msg("unable to call upstream openfga")
259-
noOpinion(w, sar)
260-
return
261-
}
262-
log.Info().Str("allowed", fmt.Sprintf("%t", res.Allowed)).Str("user", sar.Spec.User).Str("object", object).Str("relation", relation).Msg("sar response")
263-
if !res.Allowed {
264-
noOpinion(w, sar)
265-
return
316+
return false, err
266317
}
318+
return res.Allowed, nil
319+
}
267320

321+
func noOpinion(w http.ResponseWriter, sar authorizationv1.SubjectAccessReview) {
268322
sar.Status = authorizationv1.SubjectAccessReviewStatus{
269-
Allowed: res.Allowed,
270-
Denied: !res.Allowed,
323+
Allowed: false,
324+
Reason: "NoOpinion",
271325
}
272-
273326
if err := json.NewEncoder(w).Encode(&sar); err != nil {
274327
http.Error(w, err.Error(), http.StatusInternalServerError)
275328
}
276329
}
277330

278-
func noOpinion(w http.ResponseWriter, sar authorizationv1.SubjectAccessReview) {
331+
func writeResponse(w http.ResponseWriter, sar authorizationv1.SubjectAccessReview, allowed bool) {
279332
sar.Status = authorizationv1.SubjectAccessReviewStatus{
280-
Allowed: false,
281-
Reason: "NoOpinion",
333+
Allowed: allowed,
334+
Denied: !allowed,
282335
}
336+
283337
if err := json.NewEncoder(w).Encode(&sar); err != nil {
284338
http.Error(w, err.Error(), http.StatusInternalServerError)
285339
}

0 commit comments

Comments
 (0)