* wip: initial changes, api registration * wip * LegacySearch working with sorting * Revert mapper change for now * Clean up * Cleanup, add integration tests * Improve tests * OpenAPI def regen * Use wildcard search, fix lastSeenAt handling, add lastSeenAtAge * Add missing files * Fix merge * Fixes * Add tests, regen openapi def * Address feedback * Address feedback batch 2 * Chores * regen openapidef * Address feedback * Add tests for paging * gen apis * Revert go.mod, go.sum. go.work.sum * Fix + remove extra tracer parameter
199 lines
6.0 KiB
Go
199 lines
6.0 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
|
|
"github.com/grafana/authlib/types"
|
|
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
|
)
|
|
|
|
func ValidateOnCreate(ctx context.Context, userSearchClient resourcepb.ResourceIndexClient, obj *iamv0alpha1.User) error {
|
|
requester, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return apierrors.NewUnauthorized("no identity found")
|
|
}
|
|
|
|
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
|
|
if obj.Spec.GrafanaAdmin && !requester.GetIsGrafanaAdmin() {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
obj.Name,
|
|
fmt.Errorf("only grafana admins can create grafana admins"))
|
|
}
|
|
|
|
if obj.Spec.Login == "" && obj.Spec.Email == "" {
|
|
return apierrors.NewBadRequest("user must have either login or email")
|
|
}
|
|
|
|
if err := validateRole(obj); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateEmail(ctx, userSearchClient, requester.GetNamespace(), obj.Name, obj.Spec.Email); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := validateLogin(ctx, userSearchClient, requester.GetNamespace(), obj.Name, obj.Spec.Login); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidateOnUpdate(ctx context.Context, userSearchClient resourcepb.ResourceIndexClient, oldObj, newObj *iamv0alpha1.User) error {
|
|
requester, err := identity.GetRequester(ctx)
|
|
if err != nil {
|
|
return apierrors.NewUnauthorized("no identity found")
|
|
}
|
|
|
|
isGrafanaAdmin := requester.GetIsGrafanaAdmin()
|
|
isServiceUser := requester.IsIdentityType(types.TypeAccessPolicy)
|
|
|
|
if !isGrafanaAdmin {
|
|
if newObj.Spec.Disabled != oldObj.Spec.Disabled {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
newObj.Name,
|
|
fmt.Errorf("only grafana admins can disable or enable a user"))
|
|
}
|
|
if newObj.Spec.GrafanaAdmin != oldObj.Spec.GrafanaAdmin {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
newObj.Name,
|
|
fmt.Errorf("only grafana admins can change grafana admin status"))
|
|
}
|
|
}
|
|
|
|
if !newObj.Spec.Provisioned && oldObj.Spec.Provisioned {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
newObj.Name,
|
|
fmt.Errorf("provisioned user cannot be un-provisioned"))
|
|
}
|
|
|
|
if !isServiceUser {
|
|
if newObj.Spec.Provisioned && !oldObj.Spec.Provisioned {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
newObj.Name,
|
|
fmt.Errorf("only service users can provision a user"))
|
|
}
|
|
if newObj.Spec.EmailVerified && !oldObj.Spec.EmailVerified {
|
|
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
newObj.Name,
|
|
fmt.Errorf("only service users can verify email"))
|
|
}
|
|
}
|
|
|
|
if newObj.Spec.Login == "" && newObj.Spec.Email == "" {
|
|
return apierrors.NewBadRequest("user must have either login or email")
|
|
}
|
|
|
|
if err := validateRole(newObj); err != nil {
|
|
return err
|
|
}
|
|
|
|
if newObj.Spec.Email != oldObj.Spec.Email {
|
|
if err := validateEmail(ctx, userSearchClient, requester.GetNamespace(), newObj.Name, newObj.Spec.Email); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if newObj.Spec.Login != oldObj.Spec.Login {
|
|
if err := validateLogin(ctx, userSearchClient, requester.GetNamespace(), newObj.Name, newObj.Spec.Login); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateRole(obj *iamv0alpha1.User) error {
|
|
if obj.Spec.Role == "" {
|
|
return apierrors.NewBadRequest("role is required")
|
|
}
|
|
|
|
if !identity.RoleType(obj.Spec.Role).IsValid() {
|
|
return apierrors.NewBadRequest(fmt.Sprintf("invalid role '%s'", obj.Spec.Role))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateEmail(ctx context.Context, searchClient resourcepb.ResourceIndexClient, namespace, name, email string) error {
|
|
req := createUserSearchRequest(namespace, []*resourcepb.Requirement{
|
|
{
|
|
Key: "fields.email",
|
|
Operator: string(selection.Equals),
|
|
Values: []string{email},
|
|
},
|
|
}, []string{"fields.email", "fields.login"})
|
|
|
|
resp, err := searchClient.Search(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// FIXME(mgyongyosi): Improve the exact match validation
|
|
|
|
if resp.TotalHits > 0 {
|
|
// If the found user is the same as the one being created/updated, it's not a conflict.
|
|
// This is required for Mode 2 when the resource is written to LegacyStorage and UnifiedStorage.
|
|
rows := resp.Results.Rows
|
|
if len(rows) > 0 && rows[0].Key.Name == name {
|
|
return nil
|
|
}
|
|
return apierrors.NewConflict(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
name,
|
|
fmt.Errorf("email '%s' is already taken", email))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateLogin(ctx context.Context, searchClient resourcepb.ResourceIndexClient, namespace, name, login string) error {
|
|
req := createUserSearchRequest(namespace, []*resourcepb.Requirement{
|
|
{
|
|
Key: "fields.login",
|
|
Operator: string(selection.Equals),
|
|
Values: []string{login},
|
|
},
|
|
}, []string{"fields.email", "fields.login"})
|
|
resp, err := searchClient.Search(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// FIXME(mgyongyosi): Improve the exact match validation
|
|
|
|
if resp.TotalHits > 0 {
|
|
// If the found user is the same as the one being created/updated, it's not a conflict.
|
|
// This is required for Mode 2 when the resource is written to LegacyStorage and UnifiedStorage.
|
|
rows := resp.Results.Rows
|
|
if len(rows) > 0 && rows[0].Key.Name == name {
|
|
return nil
|
|
}
|
|
return apierrors.NewConflict(iamv0alpha1.UserResourceInfo.GroupResource(),
|
|
name,
|
|
fmt.Errorf("login '%s' is already taken", login))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func createUserSearchRequest(namespace string, requirements []*resourcepb.Requirement, fields []string) *resourcepb.ResourceSearchRequest {
|
|
userGvr := iamv0alpha1.UserResourceInfo.GroupResource()
|
|
return &resourcepb.ResourceSearchRequest{
|
|
Options: &resourcepb.ListOptions{
|
|
Key: &resourcepb.ResourceKey{
|
|
Group: userGvr.Group,
|
|
Resource: userGvr.Resource,
|
|
Namespace: namespace,
|
|
},
|
|
Fields: requirements,
|
|
},
|
|
Fields: fields,
|
|
}
|
|
}
|