Stars: Move stars from preferences apiserver to a new collections apiserver (#114006)
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
type AuthorizeFromName struct {
|
||||
Teams TeamService
|
||||
OKNames []string
|
||||
Resource map[string][]ResourceOwner // may include unknown
|
||||
}
|
||||
|
||||
func (a *AuthorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil || user == nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
owners, ok := a.Resource[attr.GetResource()]
|
||||
if !ok {
|
||||
return authorizer.DecisionDeny, "missing resource name", nil
|
||||
}
|
||||
|
||||
// Check if the request includes explicit permissions
|
||||
res := authz.CheckServicePermissions(user, attr.GetAPIGroup(), attr.GetResource(), attr.GetVerb())
|
||||
if !res.Allowed {
|
||||
log := logging.FromContext(ctx)
|
||||
log.Info("calling service lacks required permissions",
|
||||
"isServiceCall", res.ServiceCall,
|
||||
"apiGroup", attr.GetAPIGroup(),
|
||||
"resource", attr.GetResource(),
|
||||
"verb", attr.GetVerb(),
|
||||
"permissions", len(res.Permissions),
|
||||
)
|
||||
return authorizer.DecisionDeny, "calling service lacks required permissions", nil
|
||||
}
|
||||
|
||||
if attr.GetName() == "" {
|
||||
if attr.IsReadOnly() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "mutating request without a name", nil
|
||||
}
|
||||
|
||||
// the pseudo sub-resource
|
||||
if a.OKNames != nil && slices.Contains(a.OKNames, attr.GetName()) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
info, _ := ParseOwnerFromName(attr.GetName())
|
||||
if !slices.Contains(owners, info.Owner) {
|
||||
return authorizer.DecisionDeny, "unsupported owner type", nil
|
||||
}
|
||||
|
||||
switch info.Owner {
|
||||
case NamespaceResourceOwner:
|
||||
if attr.IsReadOnly() {
|
||||
// Everyone can see the namespace
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
if user.GetOrgRole() == identity.RoleAdmin {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "must be an org admin to edit", nil
|
||||
|
||||
case UserResourceOwner:
|
||||
if user.GetIdentifier() == info.Identifier {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "your are not the owner of the resource", nil
|
||||
|
||||
case TeamResourceOwner:
|
||||
if a.Teams == nil {
|
||||
return authorizer.DecisionDeny, "team checker not configured", err
|
||||
}
|
||||
ok, err := a.Teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly())
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "error fetching teams", err
|
||||
}
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "you are not a member of the referenced team", nil
|
||||
|
||||
case UnknownResourceOwner:
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// the owner was not explicitly allowed
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
type expect struct {
|
||||
decision authorizer.Decision
|
||||
reason string
|
||||
err string
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
attrs authorizer.Attributes
|
||||
expect expect
|
||||
breakpoint bool
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize(t *testing.T) {
|
||||
userABC := &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
OrgRole: identity.RoleViewer,
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{"group/stars:*", "group/preferences:*", "group/ns:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
teams func(t *testing.T) TeamService
|
||||
resource map[string][]ResourceOwner
|
||||
check []testCase
|
||||
}{
|
||||
{
|
||||
name: "stars",
|
||||
resource: map[string][]ResourceOwner{
|
||||
"stars": {UserResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "matches user",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "user-abc", // note this matches in input user name
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "different user",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "user-xyz", // not abc
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "your are not the owner of the resource",
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "fast path",
|
||||
resource: map[string][]ResourceOwner{
|
||||
"stars": {UserResourceOwner},
|
||||
"preferences": {TeamResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "missing user",
|
||||
attrs: authorizer.AttributesRecord{},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
err: "a Requester was not found in the context",
|
||||
},
|
||||
}, {
|
||||
name: "not a resource",
|
||||
user: &identity.StaticRequester{},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
ResourceRequest: false,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionNoOpinion,
|
||||
},
|
||||
}, {
|
||||
name: "unknown resource",
|
||||
user: &identity.StaticRequester{},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Resource: "xxxx",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "missing resource name",
|
||||
},
|
||||
}, {
|
||||
name: "missing service permissions",
|
||||
user: &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{""},
|
||||
},
|
||||
},
|
||||
},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "calling service lacks required permissions",
|
||||
},
|
||||
}, {
|
||||
name: "wrong owner type",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "create", // missing name
|
||||
Name: "team-xxx", // not supported
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "unsupported owner type",
|
||||
},
|
||||
}, {
|
||||
name: "unknown resource",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "create", // missing name
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "mutating request without a name",
|
||||
},
|
||||
}, {
|
||||
name: "list request",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "list", // no name
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "teams request (but not configured)",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
Name: "team-XYZ",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "team checker not configured",
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "unknown owner",
|
||||
resource: map[string][]ResourceOwner{
|
||||
"stars": {UnknownResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "get",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "something-not-an-owner",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "namespace",
|
||||
resource: map[string][]ResourceOwner{
|
||||
"ns": {NamespaceResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "readonly",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "mutating",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "create",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "must be an org admin to edit",
|
||||
},
|
||||
}, {
|
||||
name: "org admin",
|
||||
user: &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
OrgRole: identity.RoleAdmin,
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{"group/ns:create"},
|
||||
},
|
||||
},
|
||||
},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "create",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "preferences teams",
|
||||
teams: func(t *testing.T) TeamService {
|
||||
teams := NewMockTeamService(t)
|
||||
teams.On("InTeam", mock.Anything, userABC, "xyz", false).Return(true, nil)
|
||||
teams.On("InTeam", mock.Anything, userABC, "456", false).Return(false, nil)
|
||||
teams.On("InTeam", mock.Anything, userABC, "XXX", false).Return(true, fmt.Errorf("error from team"))
|
||||
return teams
|
||||
},
|
||||
resource: map[string][]ResourceOwner{
|
||||
"preferences": {
|
||||
TeamResourceOwner,
|
||||
},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "user in team",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-xyz",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "user not in team",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-456",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "you are not a member of the referenced team",
|
||||
},
|
||||
}, {
|
||||
name: "team error",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-XXX",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "error fetching teams",
|
||||
err: "error from team",
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authz := &AuthorizeFromName{
|
||||
Resource: tt.resource,
|
||||
}
|
||||
if tt.teams != nil {
|
||||
authz.Teams = tt.teams(t)
|
||||
}
|
||||
for _, check := range tt.check {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if check.user != nil {
|
||||
ctx = identity.WithRequester(ctx, check.user)
|
||||
}
|
||||
e := check.expect
|
||||
if check.breakpoint {
|
||||
require.True(t, true) // Can set breakpoint in IDE here
|
||||
}
|
||||
d, r, err := authz.Authorize(ctx, check.attrs)
|
||||
if e.err != "" {
|
||||
require.ErrorContains(t, err, e.err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.decision, d)
|
||||
if e.reason != "" {
|
||||
require.Equal(t, e.reason, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user