Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions .mockery.yaml
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
with-expecter: true
pkgname: mocks
packages:
github.com/openfga/api/proto/openfga/v1:
config:
dir: internal/subroutine/mocks
outpkg: mocks
filename: mock_OpenFGAServiceClient.go
interfaces:
OpenFGAServiceClient:

sigs.k8s.io/controller-runtime/pkg/client:
config:
dir: internal/subroutine/mocks
outpkg: mocks
filename: mock_Client.go
interfaces:
Client:

sigs.k8s.io/controller-runtime/pkg/manager:
config:
dir: internal/subroutine/mocks
filename: mock_CTRLManager.go
structname: CTRLManager
interfaces:
Manager:

sigs.k8s.io/multicluster-runtime/pkg/manager:
config:
dir: internal/subroutine/mocks
outpkg: mocks
filename: mock_Manager.go
interfaces:
Manager:

sigs.k8s.io/controller-runtime/pkg/cluster:
config:
dir: internal/subroutine/mocks
outpkg: mocks
filename: mock_Cluster.go
interfaces:
Cluster:

k8s.io/client-go/discovery:
config:
dir: internal/subroutine/mocks
filename: mock_DiscoveryInterface.go
interfaces:
Cluster:
DiscoveryInterface:
3 changes: 0 additions & 3 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@ tasks:
KUBEBUILDER_ASSETS:
sh: $(pwd)/{{.LOCAL_BIN}}/setup-envtest use {{.ENVTEST_K8S_VERSION}} --bin-dir $(pwd)/{{.LOCAL_BIN}} -p path
GO111MODULE: on
PLATFORM_MESH_TOKEN: ${PLATFORM_MESH_TOKEN}
cmds:
- echo "https://openmfp:[email protected]" >> $HOME/.git-credentials
- git config --global url."https://${PLATFORM_MESH_TOKEN}@github.com/".insteadOf "https://github.com/"
- go test -count=1 ./... {{.ADDITIONAL_COMMAND_ARGS}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count=1 is the default

test:
deps: [setup:envtest]
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/fluxcd/helm-controller/api v1.4.2
github.com/fluxcd/source-controller/api v1.7.2
github.com/go-logr/logr v1.4.3
github.com/google/gnostic-models v0.7.0
github.com/kcp-dev/kcp/sdk v0.28.1-0.20250926104223-cec2e15f24c6
github.com/kcp-dev/logicalcluster/v3 v3.0.5
github.com/kcp-dev/multicluster-provider v0.2.0
Expand Down Expand Up @@ -58,7 +59,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
Expand Down
5 changes: 4 additions & 1 deletion internal/controller/store_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/kcp-dev/logicalcluster/v3"
"github.com/platform-mesh/golang-commons/controller/lifecycle/builder"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
Expand Down Expand Up @@ -67,7 +68,9 @@ func NewStoreReconciler(log *logger.Logger, fga openfgav1.OpenFGAServiceClient,
log: log,
lifecycle: builder.NewBuilder("store", "StoreReconciler", []lifecyclesubroutine.Subroutine{
subroutine.NewStoreSubroutine(fga, mcMgr),
subroutine.NewAuthorizationModelSubroutine(fga, mcMgr, allClient, log),
subroutine.NewAuthorizationModelSubroutine(fga, mcMgr, allClient, func(cfg *rest.Config) discovery.DiscoveryInterface {
return discovery.NewDiscoveryClientForConfigOrDie(cfg)
}, log),
subroutine.NewTupleSubroutine(fga, mcMgr),
}, log).WithConditionManagement().
BuildMultiCluster(mcMgr),
Expand Down
174 changes: 151 additions & 23 deletions internal/subroutine/authorization_model.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package subroutine

import (
"bytes"
"context"
"fmt"
"net/url"
"strings"
"text/template"

openfgav1 "github.com/openfga/api/proto/openfga/v1"
language "github.com/openfga/language/pkg/go/transformer"
Expand All @@ -12,26 +16,76 @@ import (
"github.com/platform-mesh/golang-commons/logger"
"github.com/platform-mesh/security-operator/api/v1alpha1"
"google.golang.org/protobuf/encoding/protojson"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
mccontext "sigs.k8s.io/multicluster-runtime/pkg/context"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
)

const schemaVersion = "1.2"
const (
schemaVersion = "1.2"
)

var (
priviledgedGroupVersions = []string{"rbac.authorization.k8s.io/v1"}
groupVersions = []string{"authentication.k8s.io/v1", "authorization.k8s.io/v1", "v1"}

priviledgedTemplate = template.Must(template.New("model").Parse(`module internal_core_types_{{ .Name }}

{{ if eq .Scope "Cluster" }}
extend type core_platform-mesh_io_account
relations
define create_{{ .Group }}_{{ .Name }}: owner
define list_{{ .Group }}_{{ .Name }}: member
define watch_{{ .Group }}_{{ .Name }}: member
{{ end }}

{{ if eq .Scope "Namespaced" }}
extend type core_namespace
relations
define create_{{ .Group }}_{{ .Name }}: owner
define list_{{ .Group }}_{{ .Name }}: member
define watch_{{ .Group }}_{{ .Name }}: member
{{ end }}

type {{ .Group }}_{{ .Singular }}
relations
define parent: [{{ if eq .Scope "Namespaced" }}core_namespace{{ else }}core_platform-mesh_io_account{{ end }}]
define member: [role#assignee] or owner or member from parent
define owner: [role#assignee] or owner from parent

define get: member
define update: owner
define delete: owner
define patch: owner
define watch: member

define manage_iam_roles: owner
define get_iam_roles: member
define get_iam_users: member
`))
)

type NewDiscoveryClientFunc func(cfg *rest.Config) discovery.DiscoveryInterface

type authorizationModelSubroutine struct {
fga openfgav1.OpenFGAServiceClient
mgr mcmanager.Manager
allClient client.Client
fga openfgav1.OpenFGAServiceClient
mgr mcmanager.Manager
allClient client.Client
newDiscoveryClientFunc NewDiscoveryClientFunc
}

func NewAuthorizationModelSubroutine(fga openfgav1.OpenFGAServiceClient, mgr mcmanager.Manager, allClient client.Client, log *logger.Logger) *authorizationModelSubroutine {
func NewAuthorizationModelSubroutine(fga openfgav1.OpenFGAServiceClient, mgr mcmanager.Manager, allClient client.Client, newDiscoveryClientFunc NewDiscoveryClientFunc, log *logger.Logger) *authorizationModelSubroutine {
return &authorizationModelSubroutine{
fga: fga,
mgr: mgr,
allClient: allClient,
fga: fga,
mgr: mgr,
allClient: allClient,
newDiscoveryClientFunc: newDiscoveryClientFunc,
}
}

Expand Down Expand Up @@ -92,6 +146,38 @@ func (a *authorizationModelSubroutine) Process(ctx context.Context, instance run
})
}

if store.Name != "orgs" {
cfg := rest.CopyConfig(a.mgr.GetLocalManager().GetConfig())

parsed, err := url.Parse(cfg.Host)
if err != nil {
log.Error().Err(err).Msg("unable to parse host from config")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

parsed.Path, err = url.JoinPath("clusters", fmt.Sprintf("root:orgs:%s", store.Name))
if err != nil {
log.Error().Err(err).Msg("unable to join path")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

cfg.Host = parsed.String()

discoveryClient := a.newDiscoveryClientFunc(cfg)

coreModules, err := discoverAndRender(discoveryClient, modelTpl, groupVersions)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix compilation error: undefined variable modelTpl.

Line 168 references modelTpl which is not defined in this file, causing a compilation error.

You need to define a template for non-privileged core types. Add this definition in the var block (after line 36):

coreModelTemplate = template.Must(template.New("model").Parse(`module internal_core_types_{{ .Name }}

type {{ .Group }}_{{ .Singular }}
	relations
		define parent: [{{ if eq .Scope "Namespaced" }}core_namespace{{ else }}core_platform-mesh_io_account{{ end }}]
		define member: [role#assignee] or owner or member from parent
		define owner: [role#assignee] or owner from parent
		
		define get: member
		define update: member
		define delete: member
		define patch: member
		define watch: member

		define manage_iam_roles: owner
		define get_iam_roles: member
		define get_iam_users: member
`))

Then update line 168:

-coreModules, err := discoverAndRender(discoveryClient, modelTpl, groupVersions)
+coreModules, err := discoverAndRender(discoveryClient, coreModelTemplate, groupVersions)
🤖 Prompt for AI Agents
In internal/subroutine/authorization_model.go around lines 36 and 168, define a
new template variable in the var block after line 36 named coreModelTemplate
containing the provided template for non-privileged core types, then replace the
undefined modelTpl on line 168 with coreModelTemplate when calling
discoverAndRender; this ensures the template is defined and used correctly.

if err != nil {
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}
moduleFiles = append(moduleFiles, coreModules...)

priviledgedModules, err := discoverAndRender(discoveryClient, priviledgedTemplate, priviledgedGroupVersions)
if err != nil {
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}
moduleFiles = append(moduleFiles, priviledgedModules...)
}

authorizationModel, err := language.TransformModuleFilesToModel(moduleFiles, schemaVersion)
if err != nil {
log.Error().Err(err).Msg("unable to transform module files to model")
Expand All @@ -109,33 +195,22 @@ func (a *authorizationModelSubroutine) Process(ctx context.Context, instance run
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

// the following ignore comments are due to the fact, that its incredibly hard to setup a specific condition where one of the parsing methods would fail

// Compare Proto objects directly instead of DSL strings to avoid ordering issues
// The two models should be semantically equivalent even if DSL ordering differs
currentRaw, err := protojson.Marshal(res.AuthorizationModel)
if err != nil { // coverage-ignore
log.Error().Err(err).Msg("unable to marshal current model")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

current, err := language.TransformJSONStringToDSL(string(currentRaw))
if err != nil { // coverage-ignore
log.Error().Err(err).Msg("unable to transform current model to dsl")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

desiredRaw, err := protojson.Marshal(authorizationModel)
if err != nil { // coverage-ignore
log.Error().Err(err).Msg("unable to marshal desired model")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

desired, err := language.TransformJSONStringToDSL(string(desiredRaw))
if err != nil { // coverage-ignore
log.Error().Err(err).Msg("unable to transform desired model to dsl")
return ctrl.Result{}, errors.NewOperatorError(err, true, false)
}

if *current == *desired {
// Compare JSON representations instead of DSL strings
if string(currentRaw) == string(desiredRaw) {
return ctrl.Result{}, nil
}

Expand All @@ -156,3 +231,56 @@ func (a *authorizationModelSubroutine) Process(ctx context.Context, instance run

return ctrl.Result{}, nil
}

func processAPIResourceIntoModel(resource metav1.APIResource, tpl *template.Template) (bytes.Buffer, error) {

scope := apiextensionsv1.ClusterScoped
if resource.Namespaced {
scope = apiextensionsv1.NamespaceScoped
}

group := "core"
if resource.Group != "" {
group = resource.Group
}

var buffer bytes.Buffer
err := tpl.Execute(&buffer, modelInput{
Name: resource.Name,
Group: strings.ReplaceAll(group, ".", "_"), // TODO: group name length capping
Singular: resource.SingularName,
Scope: string(scope),
})
if err != nil {
return buffer, err
}

return buffer, nil
}

func discoverAndRender(dc discovery.DiscoveryInterface, tpl *template.Template, groupVersions []string) ([]language.ModuleFile, error) {
var files []language.ModuleFile
for _, gv := range groupVersions {
resourceList, err := dc.ServerResourcesForGroupVersion(gv)
if err != nil {
return nil, fmt.Errorf("discover resources for %s: %w", gv, err)
}

for _, apiRes := range resourceList.APIResources {
if strings.Contains(apiRes.Name, "/") { // skip subresources
continue
}

buf, err := processAPIResourceIntoModel(apiRes, tpl)
if err != nil {
return nil, fmt.Errorf("process api resource %s in %s: %w", apiRes.Name, gv, err)
}

files = append(files, language.ModuleFile{
Name: fmt.Sprintf("internal_core_types_%s.fga", apiRes.Name),
Contents: buf.String(),
})
}
}
return files, nil
}
Comment on lines +261 to +286
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Include group in ModuleFile name to prevent collisions.

Resources with the same plural name in different API groups will overwrite each other's module files.

Modify discoverAndRender to return group information and update line 280:

-			files = append(files, language.ModuleFile{
-				Name:     fmt.Sprintf("internal_core_types_%s.fga", apiRes.Name),
-				Contents: buf.String(),
-			})
+			group := "core"
+			if apiRes.Group != "" {
+				group = strings.ReplaceAll(apiRes.Group, ".", "_")
+			}
+			files = append(files, language.ModuleFile{
+				Name:     fmt.Sprintf("internal_core_types_%s_%s.fga", group, apiRes.Name),
+				Contents: buf.String(),
+			})

Committable suggestion skipped: line range outside the PR's diff.

5 changes: 5 additions & 0 deletions internal/subroutine/authorization_model_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,8 @@ func TestFinalizeAuthorizationModelGeneration(t *testing.T) {
finalizers := subroutine.NewAuthorizationModelGenerationSubroutine(nil).Finalizers(nil)
assert.Equal(t, []string{}, finalizers)
}

func TestAuthorizationModelGenerationSubroutine_GetName(t *testing.T) {
sub := subroutine.NewAuthorizationModelGenerationSubroutine(nil)
assert.Equal(t, "AuthorizationModelGeneration", sub.GetName())
}
Loading