Stars: Move stars from preferences apiserver to a new collections apiserver (#114006)

This commit is contained in:
Ryan McKinley
2025-11-19 08:28:39 +03:00
committed by GitHub
parent e558c9af5d
commit 00329cab14
96 changed files with 3416 additions and 2380 deletions
@@ -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)
}
})
}
})
}
}