Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add access control to Organizations #17

Merged
merged 4 commits into from
Jan 12, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
90 changes: 90 additions & 0 deletions apiserver/organization/authorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package organization

import (
"context"
"errors"
"fmt"

apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/filters"
)

//go:generate go run github.com/golang/mock/mockgen -destination=./mock/$GOFILE -package mock_organization k8s.io/apiserver/pkg/authorization/authorizer Authorizer

// rbacAuthorizer processes authorization requests for `organizations.organization.appuio.io` and checks them based on rbac rules for `organizations.rbac.appuio.io`
type rbacAuthorizer struct {
Authorizer authorizer.Authorizer
}

var rbacGV = metav1.GroupVersion{
Group: "rbac.appuio.io",
Version: "v1",
}

// Authorizer makes an authorization decision based on the Attributes.
// It returns nil when an action is authorized, otherwise it returns an error.
func (a rbacAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) error {
if attr.GetResource() != "organizations" {
return fmt.Errorf("unkown resource %q", attr.GetResource())
}
decision, reason, err := a.Authorizer.Authorize(ctx, authorizer.AttributesRecord{
User: attr.GetUser(),
Verb: attr.GetVerb(),
Name: attr.GetName(),
Namespace: attr.GetName(), // We handle cluster wide resources
APIGroup: rbacGV.Group,
APIVersion: rbacGV.Version,
Resource: attr.GetResource(),
Subresource: attr.GetSubresource(),
ResourceRequest: true, // Always a resource request
Path: attr.GetPath(),
})

if err != nil {
return err
} else if decision != authorizer.DecisionAllow {
return apierrors.NewForbidden(schema.GroupResource{
Group: attr.GetAPIGroup(),
Resource: attr.GetResource(),
}, attr.GetName(), errors.New(reason))
}
return nil
}

// AuthorizerContext makes an authorization decision based on the Attributes present in the given Context.
// It returns nil when the context contains Attributes and the action is authorized, otherwise it returns an error.
func (a rbacAuthorizer) AuthorizeContext(ctx context.Context) error {
attr, err := filters.GetAuthorizerAttributes(ctx)
if err != nil {
return err
}
return a.Authorize(ctx, attr)
}

// AuthorizerVerb makes an authorization decision based on the Attributes present in the given Context, but overriding the verb and object name to the provided values
// It returns nil when the context contains Attributes and the action is authorized, otherwise it returns an error.
func (a rbacAuthorizer) AuthorizeVerb(ctx context.Context, verb string, name string) error {
attr, err := filters.GetAuthorizerAttributes(ctx)
if err != nil {
return err
}
return a.Authorize(ctx, authorizer.AttributesRecord{
User: attr.GetUser(),
Verb: verb,
Name: name,
Namespace: attr.GetNamespace(),
APIGroup: attr.GetAPIGroup(),
APIVersion: attr.GetAPIVersion(),
Resource: attr.GetResource(),
Path: attr.GetPath(),
})
}

// AuthorizerGet makes an authorization decision based on the Attributes present in the given Context, but overriding the verb to `get` and the object name to the provided values
// It returns nil when the context contains Attributes and the action is authorized, otherwise it returns an error.
func (a rbacAuthorizer) AuthorizeGet(ctx context.Context, name string) error {
return a.AuthorizeVerb(ctx, "get", name)
}
35 changes: 35 additions & 0 deletions apiserver/organization/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package organization

import (
"context"
"fmt"

orgv1 "github.com/appuio/control-api/apis/organization/v1"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)

var _ rest.Creater = &organizationStorage{}

func (s *organizationStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
org, ok := obj.(*orgv1.Organization)
if !ok {
return nil, fmt.Errorf("not an organization: %#v", obj)
}
err := s.authorizer.AuthorizeContext(ctx)
if err != nil {
return nil, err
}

// Validate Org
if err := createValidation(ctx, obj); err != nil {
return nil, err
}

if err := s.namepaces.CreateNamespace(ctx, org.ToNamespace(), options); err != nil {
return nil, convertNamespaceError(err)
}
return org, nil
}
106 changes: 106 additions & 0 deletions apiserver/organization/create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package organization

import (
"context"
"errors"
"testing"

"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

orgv1 "github.com/appuio/control-api/apis/organization/v1"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/request"
)

func TestOrganizationStorage_Create(t *testing.T) {
tests := map[string]struct {
organizationIn *orgv1.Organization

namespaceErr error

authDecision authResponse

organizationOut *orgv1.Organization
err error
}{
"GivenCreateOrg_ThenSuccess": {
organizationIn: fooOrg,
authDecision: authResponse{
decision: authorizer.DecisionAllow,
},
organizationOut: fooOrg,
},
"GivenNsExists_ThenFail": {
organizationIn: fooOrg,
authDecision: authResponse{
decision: authorizer.DecisionAllow,
},
namespaceErr: apierrors.NewAlreadyExists(schema.GroupResource{
Resource: "namepaces",
}, "foo"),
err: apierrors.NewAlreadyExists(schema.GroupResource{
Group: orgv1.GroupVersion.Group,
Resource: "organizations",
}, "foo"),
},
"GivenAuthFails_ThenFail": {
organizationIn: fooOrg,
authDecision: authResponse{
err: errors.New("failed"),
},
err: errors.New("failed"),
},
"GivenForbidden_ThenForbidden": {
organizationIn: fooOrg,
authDecision: authResponse{
decision: authorizer.DecisionDeny,
reason: "confidential",
},
err: apierrors.NewForbidden(schema.GroupResource{
Group: orgv1.GroupVersion.Group,
Resource: "organizations",
}, fooOrg.Name, errors.New("confidential")),
},
}

for n, tc := range tests {
t.Run(n, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
os, mnp, mauth := newMockedOrganizationStorage(ctrl)
mauth.EXPECT().
Authorize(gomock.Any(), isAuthRequest("create")).
Return(tc.authDecision.decision, tc.authDecision.reason, tc.authDecision.err).
Times(1)
mnp.EXPECT().
CreateNamespace(gomock.Any(), gomock.Any(), gomock.Any()).
Return(tc.namespaceErr).
AnyTimes()

nopValidate := func(ctx context.Context, obj runtime.Object) error {
return nil
}
org, err := os.Create(request.WithRequestInfo(request.NewContext(),
&request.RequestInfo{
Verb: "create",
APIGroup: orgv1.GroupVersion.Group,
Resource: "organizations",
Name: tc.organizationIn.Name,
}),
tc.organizationIn, nopValidate, nil)

if tc.err != nil {
assert.EqualError(t, err, tc.err.Error())
return
}
require.NoError(t, err)
assert.Equal(t, tc.organizationOut, org)
})
}
}
35 changes: 35 additions & 0 deletions apiserver/organization/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package organization

import (
"context"

orgv1 "github.com/appuio/control-api/apis/organization/v1"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)

var _ rest.GracefulDeleter = &organizationStorage{}

func (s *organizationStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
err := s.authorizer.AuthorizeContext(ctx)
if err != nil {
return nil, false, err
}

org, err := s.Get(ctx, name, nil)
if err != nil {
return nil, false, err
}

if deleteValidation != nil {
err := deleteValidation(ctx, org)
if err != nil {
return nil, false, err
}
}

ns, err := s.namepaces.DeleteNamespace(ctx, name, options)
return orgv1.NewOrganizationFromNS(ns), false, convertNamespaceError(err)
}
Loading