1
0
Fork 0
mirror of https://github.com/kyverno/kyverno.git synced 2024-12-14 11:57:48 +00:00

feat: support authorizer variable in CEL expressions (#8024)

* feat: support authorizer variable in CEL expressions

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

* feat: add the auth reason

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

* feat: add kuttl tests

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

* fix lint issue

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

* fix kuttl test

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

* fix: add helpers

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>

---------

Signed-off-by: Mariam Fahmy <mariam.fahmy@nirmata.com>
This commit is contained in:
Mariam Fahmy 2023-09-05 13:16:50 +03:00 committed by GitHub
parent 7a3a3194eb
commit b495c6d112
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 291 additions and 30 deletions

View file

@ -23,7 +23,7 @@ type CanIOptions interface {
// - group version resource is determined from the kind using the discovery client REST mapper
// - If disallowed, the reason and evaluationError is available in the logs
// - each can generates a SubjectAccessReview resource and response is evaluated for permissions
RunAccessCheck(context.Context) (bool, error)
RunAccessCheck(context.Context) (bool, string, error)
}
type canIOptions struct {
@ -55,30 +55,30 @@ func NewCanI(discovery Discovery, sarClient authorizationv1client.SubjectAccessR
// - group version resource is determined from the kind using the discovery client REST mapper
// - If disallowed, the reason and evaluationError is available in the logs
// - each can generates a SelfSubjectAccessReview resource and response is evaluated for permissions
func (o *canIOptions) RunAccessCheck(ctx context.Context) (bool, error) {
func (o *canIOptions) RunAccessCheck(ctx context.Context) (bool, string, error) {
// get GroupVersionResource from RESTMapper
// get GVR from kind
apiVersion, kind := kubeutils.GetKindFromGVK(o.gvk)
gv, err := schema.ParseGroupVersion(apiVersion)
if err != nil {
return false, fmt.Errorf("failed to parse group/version %s", apiVersion)
return false, "", fmt.Errorf("failed to parse group/version %s", apiVersion)
}
gvr, err := o.discovery.GetGVRFromGVK(gv.WithKind(kind))
if err != nil {
return false, fmt.Errorf("failed to get GVR for kind %s", o.gvk)
return false, "", fmt.Errorf("failed to get GVR for kind %s", o.gvk)
}
if gvr.Empty() {
// cannot find GVR
return false, fmt.Errorf("failed to get the Group Version Resource for kind %s", o.gvk)
return false, "", fmt.Errorf("failed to get the Group Version Resource for kind %s", o.gvk)
}
logger := logger.WithValues("kind", kind, "namespace", o.namespace, "gvr", gvr.String(), "verb", o.verb)
result, err := o.checker.Check(ctx, gvr.Group, gvr.Version, gvr.Resource, o.subresource, o.namespace, o.verb)
if err != nil {
logger.Error(err, "failed to check permissions")
return false, err
return false, "", err
}
if !result.Allowed {
logger.Info("disallowed operation", "reason", result.Reason, "evaluationError", result.EvaluationError)
}
return result.Allowed, nil
return result.Allowed, result.Reason, nil
}

View file

@ -72,7 +72,7 @@ func TestCanIOptions_DiscoveryError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := NewCanI(tt.fields.discovery, nil, tt.fields.kind, tt.fields.namespace, tt.fields.verb, "", "admin")
got, err := o.RunAccessCheck(context.TODO())
got, _, err := o.RunAccessCheck(context.TODO())
if tt.wantErr {
assert.Error(t, err)
} else {
@ -117,7 +117,7 @@ func TestCanIOptions_SsarError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := NewCanI(tt.fields.discovery, tt.fields.sarClient, tt.fields.kind, tt.fields.namespace, tt.fields.verb, "", "admin")
got, err := o.RunAccessCheck(context.TODO())
got, _, err := o.RunAccessCheck(context.TODO())
if tt.wantErr {
assert.Error(t, err)
} else {
@ -174,7 +174,7 @@ func TestCanIOptions_RunAccessCheck(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := NewCanI(tt.fields.client.Discovery(), tt.fields.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), tt.fields.kind, tt.fields.namespace, tt.fields.verb, "", "admin")
got, err := o.RunAccessCheck(context.TODO())
got, _, err := o.RunAccessCheck(context.TODO())
if tt.wantErr {
assert.Error(t, err)
} else {

View file

@ -65,11 +65,11 @@ func (a *dclientAdapter) IsNamespaced(group, version, kind string) (bool, error)
return false, nil
}
func (a *dclientAdapter) CanI(ctx context.Context, kind, namespace, verb, subresource, user string) (bool, error) {
func (a *dclientAdapter) CanI(ctx context.Context, kind, namespace, verb, subresource, user string) (bool, string, error) {
canI := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), kind, namespace, verb, subresource, user)
ok, err := canI.RunAccessCheck(ctx)
ok, reason, err := canI.RunAccessCheck(ctx)
if err != nil {
return false, err
return false, reason, err
}
return ok, nil
return ok, reason, nil
}

View file

@ -25,7 +25,7 @@ type RawClient interface {
}
type AuthClient interface {
CanI(ctx context.Context, kind, namespace, verb, subresource, user string) (bool, error)
CanI(ctx context.Context, kind, namespace, verb, subresource, user string) (bool, string, error)
}
type ResourceClient interface {

View file

@ -8,6 +8,7 @@ import (
kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1"
engineapi "github.com/kyverno/kyverno/pkg/engine/api"
"github.com/kyverno/kyverno/pkg/engine/handlers"
"github.com/kyverno/kyverno/pkg/engine/internal"
engineutils "github.com/kyverno/kyverno/pkg/engine/utils"
celutils "github.com/kyverno/kyverno/pkg/utils/cel"
admissionregistrationv1alpha1 "k8s.io/api/admissionregistration/v1alpha1"
@ -57,6 +58,10 @@ func (h validateCELHandler) Process(
gvk := resource.GroupVersionKind()
namespaceName := resource.GetNamespace()
resourceName := resource.GetName()
resourceKind, _ := policyContext.ResourceKind()
policyKind := policyContext.Policy().GetKind()
policyName := policyContext.Policy().GetName()
object := resource.DeepCopyObject()
// in case of update request, set the oldObject to the current resource before it gets updated
var oldObject runtime.Object
@ -76,7 +81,8 @@ func (h validateCELHandler) Process(
validations := rule.Validation.CEL.Expressions
auditAnnotations := rule.Validation.CEL.AuditAnnotations
optionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: false}
optionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: true}
expressionOptionalVars := cel.OptionalVariableDeclarations{HasParams: hasParam, HasAuthorizer: false}
// compile CEL expressions
compiler, err := celutils.NewCompiler(validations, auditAnnotations, matchConditions, variables)
if err != nil {
@ -84,12 +90,12 @@ func (h validateCELHandler) Process(
}
compiler.CompileVariables(optionalVars)
filter := compiler.CompileValidateExpressions(optionalVars)
messageExpressionfilter := compiler.CompileMessageExpressions(optionalVars)
messageExpressionfilter := compiler.CompileMessageExpressions(expressionOptionalVars)
auditAnnotationFilter := compiler.CompileAuditAnnotationsExpressions(optionalVars)
matchConditionFilter := compiler.CompileMatchExpressions(optionalVars)
// newMatcher will be used to check if the incoming resource matches the CEL preconditions
newMatcher := matchconditions.NewMatcher(matchConditionFilter, nil, "", "", "")
newMatcher := matchconditions.NewMatcher(matchConditionFilter, nil, policyKind, "", policyName)
// newValidator will be used to validate CEL expressions against the incoming object
validator := validatingadmissionpolicy.NewValidator(filter, newMatcher, auditAnnotationFilter, messageExpressionfilter, nil)
@ -108,8 +114,11 @@ func (h validateCELHandler) Process(
}
}
admissionAttributes := admission.NewAttributesRecord(object, oldObject, gvk, namespaceName, resourceName, gvr, "", admission.Operation(policyContext.Operation()), nil, false, nil)
requestInfo := policyContext.AdmissionInfo()
userInfo := internal.NewUser(requestInfo.AdmissionUserInfo.Username, requestInfo.AdmissionUserInfo.UID, requestInfo.AdmissionUserInfo.Groups)
admissionAttributes := admission.NewAttributesRecord(object, oldObject, gvk, namespaceName, resourceName, gvr, "", admission.Operation(policyContext.Operation()), nil, false, &userInfo)
versionedAttr, _ := admission.NewVersionedAttributes(admissionAttributes, admissionAttributes.GetKind(), nil)
authorizer := internal.NewAuthorizer(h.client, resourceKind)
// validate the incoming object against the rule
var validationResults []validatingadmissionpolicy.ValidateResult
if hasParam {
@ -124,10 +133,10 @@ func (h validateCELHandler) Process(
}
for _, param := range params {
validationResults = append(validationResults, validator.Validate(ctx, gvr, versionedAttr, param, namespace, celconfig.RuntimeCELCostBudget, nil))
validationResults = append(validationResults, validator.Validate(ctx, gvr, versionedAttr, param, namespace, celconfig.RuntimeCELCostBudget, &authorizer))
}
} else {
validationResults = append(validationResults, validator.Validate(ctx, gvr, versionedAttr, nil, namespace, celconfig.RuntimeCELCostBudget, nil))
validationResults = append(validationResults, validator.Validate(ctx, gvr, versionedAttr, nil, namespace, celconfig.RuntimeCELCostBudget, &authorizer))
}
for _, validationResult := range validationResults {

View file

@ -171,7 +171,8 @@ func (h validateManifestHandler) verifyManifest(
}
func (h validateManifestHandler) checkDryRunPermission(ctx context.Context, kind, namespace string) (bool, error) {
return h.client.CanI(ctx, kind, namespace, "create", "", config.KyvernoServiceAccountName())
ok, _, err := h.client.CanI(ctx, kind, namespace, "create", "", config.KyvernoServiceAccountName())
return ok, err
}
func verifyManifestAttestorSet(resource unstructured.Unstructured, attestorSet kyvernov1.AttestorSet, vo *k8smanifest.VerifyResourceOption, path string, uid string, logger logr.Logger) (bool, string, error) {

View file

@ -0,0 +1,73 @@
package internal
import (
"context"
engineapi "github.com/kyverno/kyverno/pkg/engine/api"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
// Authorizer implements authorizer.Authorizer interface. It is intended to be used in validate.cel subrules.
type Authorizer struct {
client engineapi.Client
resourceKind schema.GroupVersionKind
}
func (a *Authorizer) Authorize(ctx context.Context, attributes authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
ok, reason, err := a.client.CanI(ctx,
a.resourceKind.Kind,
attributes.GetNamespace(),
attributes.GetVerb(),
attributes.GetSubresource(),
attributes.GetUser().GetName(),
)
if err != nil {
return authorizer.DecisionDeny, reason, err
}
if ok {
return authorizer.DecisionAllow, reason, nil
} else {
return authorizer.DecisionDeny, reason, nil
}
}
func NewAuthorizer(client engineapi.Client, resourceKind schema.GroupVersionKind) Authorizer {
return Authorizer{
client: client,
resourceKind: resourceKind,
}
}
// User implements user.Info interface. It is intended to be used in validate.cel subrules.
type User struct {
name string
uid string
groups []string
extra map[string][]string
}
func (u *User) GetName() string {
return u.name
}
func (u *User) GetUID() string {
return u.uid
}
func (u *User) GetGroups() []string {
return u.groups
}
func (u *User) GetExtra() map[string][]string {
return u.extra
}
func NewUser(name, uid string, groups []string) User {
return User{
name: name,
uid: uid,
groups: groups,
}
}

View file

@ -40,7 +40,7 @@ func NewAuth(client dclient.Interface, user string, log logr.Logger) *Auth {
// CanICreate returns 'true' if self can 'create' resource
func (a *Auth) CanICreate(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
canI := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "create", "", a.user)
ok, err := canI.RunAccessCheck(ctx)
ok, _, err := canI.RunAccessCheck(ctx)
if err != nil {
return false, err
}
@ -50,7 +50,7 @@ func (a *Auth) CanICreate(ctx context.Context, gvk, namespace, subresource strin
// CanIUpdate returns 'true' if self can 'update' resource
func (a *Auth) CanIUpdate(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
canI := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "update", "", a.user)
ok, err := canI.RunAccessCheck(ctx)
ok, _, err := canI.RunAccessCheck(ctx)
if err != nil {
return false, err
}
@ -60,7 +60,7 @@ func (a *Auth) CanIUpdate(ctx context.Context, gvk, namespace, subresource strin
// CanIDelete returns 'true' if self can 'delete' resource
func (a *Auth) CanIDelete(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
canI := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "delete", "", a.user)
ok, err := canI.RunAccessCheck(ctx)
ok, _, err := canI.RunAccessCheck(ctx)
if err != nil {
return false, err
}
@ -70,7 +70,7 @@ func (a *Auth) CanIDelete(ctx context.Context, gvk, namespace, subresource strin
// CanIGet returns 'true' if self can 'get' resource
func (a *Auth) CanIGet(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
canI := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "get", "", a.user)
ok, err := canI.RunAccessCheck(ctx)
ok, _, err := canI.RunAccessCheck(ctx)
if err != nil {
return false, err
}

View file

@ -23,10 +23,12 @@ func newAuthChecker(client dclient.Interface, user string) AuthChecker {
func (a *authChecker) CanIUpdate(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
checker := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "update", subresource, a.user)
return checker.RunAccessCheck(ctx)
ok, _, err := checker.RunAccessCheck(ctx)
return ok, err
}
func (a *authChecker) CanIGet(ctx context.Context, gvk, namespace, subresource string) (bool, error) {
checker := auth.NewCanI(a.client.Discovery(), a.client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), gvk, namespace, "get", subresource, a.user)
return checker.RunAccessCheck(ctx)
ok, _, err := checker.RunAccessCheck(ctx)
return ok, err
}

View file

@ -73,7 +73,7 @@ func validateAuth(ctx context.Context, client dclient.Interface, policy kyvernov
kinds := sets.New(spec.MatchResources.GetKinds()...)
for kind := range kinds {
checker := auth.NewCanI(client.Discovery(), client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), kind, namespace, "delete", "", config.KyvernoUserName(config.KyvernoServiceAccountName()))
allowedDeletion, err := checker.RunAccessCheck(ctx)
allowedDeletion, _, err := checker.RunAccessCheck(ctx)
if err != nil {
return err
}
@ -82,7 +82,7 @@ func validateAuth(ctx context.Context, client dclient.Interface, policy kyvernov
}
checker = auth.NewCanI(client.Discovery(), client.GetKubeClient().AuthorizationV1().SubjectAccessReviews(), kind, namespace, "list", "", config.KyvernoUserName(config.KyvernoServiceAccountName()))
allowedList, err := checker.RunAccessCheck(ctx)
allowedList, _, err := checker.RunAccessCheck(ctx)
if err != nil {
return err
}

View file

@ -0,0 +1,4 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- serviceaccount.yaml

View file

@ -0,0 +1,4 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- rbac.yaml

View file

@ -0,0 +1,6 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- policy.yaml
assert:
- policy.yaml

View file

@ -0,0 +1,4 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
- command: kubectl apply -f ./pod.yaml --as=system:serviceaccount:default:test-account

View file

@ -0,0 +1,10 @@
apiVersion: v1
kind: Pod
metadata:
name: webserver
spec:
containers:
- name: webserver
image: nginx:latest
ports:
- containerPort: 80

View file

@ -0,0 +1,22 @@
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-host-port
spec:
validationFailureAction: Enforce
background: false
rules:
- name: host-port
match:
any:
- resources:
kinds:
- Pod
validate:
cel:
expressions:
- expression: "authorizer.serviceAccount('default', 'test-account').group('').resource('pods').namespace('default').check('delete').allowed()"
message: "The user isn't allowed to delete pods in the 'default' namespace."
- expression: "object.spec.containers.all(container, !has(container.ports) || container.ports.all(port, !has(port.hostPort) || port.hostPort == 0))"
message: "The fields spec.containers[*].ports[*].hostPort must either be unset or set to `0`"

View file

@ -0,0 +1,25 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: role
namespace: default
rules:
- apiGroups:
- ''
resources:
- pods
verbs: ["create", "update", "get", "list", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rolebinding
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: role
subjects:
- namespace: default
kind: ServiceAccount
name: test-account

View file

@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: test-account
namespace: default

View file

@ -0,0 +1,4 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- serviceaccount.yaml

View file

@ -0,0 +1,4 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- rbac.yaml

View file

@ -0,0 +1,6 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
apply:
- policy.yaml
assert:
- policy.yaml

View file

@ -0,0 +1,12 @@
apiVersion: kuttl.dev/v1beta1
kind: TestStep
commands:
- script: |
if kubectl apply -f ./deployment.yaml --as=system:serviceaccount:default:test-account-1
then
echo "Test failed. Deployment shouldn't be created."
exit 1
else
echo "Test succeeded. Deployment isn't created as expected."
exit 0
fi

View file

@ -0,0 +1,17 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment-test-1
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
containers:
- name: container2
image: nginx

View file

@ -0,0 +1,21 @@
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: check-deployment-replicas-1
spec:
validationFailureAction: Enforce
background: false
rules:
- name: deployment-replicas
match:
any:
- resources:
kinds:
- Deployment
validate:
cel:
expressions:
- expression: "authorizer.serviceAccount('default', 'test-account-1').group('apps').resource('deployments').namespace('default').check('delete').allowed()"
message: "The user isn't allowed to delete deployments in the 'default' namespace."
- expression: "object.spec.replicas <= 3"
message: "Deployment spec.replicas must be less than 3."

View file

@ -0,0 +1,25 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: role-1
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
verbs: ["create", "update", "get", "list", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rolebinding-1
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: role-1
subjects:
- namespace: default
kind: ServiceAccount
name: test-account-1

View file

@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: test-account-1
namespace: default

View file

@ -2,6 +2,8 @@ apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-host-port
annotations:
pod-policies.kyverno.io/autogen-controllers: none
spec:
validationFailureAction: Enforce
background: false