Skip to content

Commit

Permalink
Create an Authorization Policy for any ingress relation (#31)
Browse files Browse the repository at this point in the history
* add l4 ingress auth policies

* adding scenario and itests

* add AuthorizationPolicy CRD to itests

* refactor policy name

* fix model name in itest
  • Loading branch information
IbraAoad authored Dec 18, 2024
1 parent 3dcbaa9 commit 405caae
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 25 deletions.
113 changes: 98 additions & 15 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@
from lightkube.resources.core_v1 import Secret, Service
from lightkube.types import PatchType
from lightkube_extensions.batch import KubernetesResourceManager, create_charm_default_labels
from ops import BlockedStatus, EventBase
from ops import BlockedStatus, EventBase, main
from ops.charm import CharmBase
from ops.main import main
from ops.model import ActiveStatus, MaintenanceStatus
from ops.pebble import ChangeError, Layer

# from lightkube.models.meta_v1 import Patch
from models import (
AllowedRoutes,
AuthorizationPolicyResource,
AuthorizationPolicySpec,
AuthRule,
BackendRef,
From,
GatewayTLSConfig,
HTTPRequestRedirectFilter,
HTTPRouteFilter,
Expand All @@ -46,10 +49,14 @@
Listener,
Match,
Metadata,
Operation,
ParentRef,
PathMatch,
Rule,
SecretObjectReference,
Source,
To,
WorkloadSelector,
)

logger = logging.getLogger(__name__)
Expand All @@ -68,6 +75,12 @@
"ReferenceGrant": create_namespaced_resource(
"gateway.networking.k8s.io", "v1beta1", "ReferenceGrant", "referencegrants"
),
"AuthorizationPolicy": create_namespaced_resource(
"security.istio.io",
"v1",
"AuthorizationPolicy",
"authorizationpolicies",
),
}

GATEWAY_RESOURCE_TYPES = {RESOURCE_TYPES["Gateway"], Secret}
Expand All @@ -76,8 +89,10 @@
RESOURCE_TYPES["ReferenceGrant"],
RESOURCE_TYPES["HTTPRoute"],
}
GATEWAY_LABEL = "istio-gateway"
INGRESS_LABEL = "istio-ingress"
AUTHORIZATION_POLICY_RESOURCE_TYPES = {RESOURCE_TYPES["AuthorizationPolicy"]}
GATEWAY_SCOPE = "istio-gateway"
INGRESS_SCOPE = "istio-ingress"
AUTHORIZATION_POLICY_SCOPE = "istio-ingress-authorization-policy"


class DataValidationError(RuntimeError):
Expand Down Expand Up @@ -207,7 +222,7 @@ def _setup_proxy_pebble_service(self):
def _get_gateway_resource_manager(self):
return KubernetesResourceManager(
labels=create_charm_default_labels(
self.app.name, self.model.name, scope=GATEWAY_LABEL
self.app.name, self.model.name, scope=GATEWAY_SCOPE
),
resource_types=GATEWAY_RESOURCE_TYPES, # pyright: ignore
lightkube_client=self.lightkube_client,
Expand All @@ -217,13 +232,23 @@ def _get_gateway_resource_manager(self):
def _get_ingress_resource_manager(self):
return KubernetesResourceManager(
labels=create_charm_default_labels(
self.app.name, self.model.name, scope=INGRESS_LABEL
self.app.name, self.model.name, scope=INGRESS_SCOPE
),
resource_types=INGRESS_RESOURCE_TYPES, # pyright: ignore
lightkube_client=self.lightkube_client,
logger=logger,
)

def _get_authorization_policy_resource_manager(self):
return KubernetesResourceManager(
labels=create_charm_default_labels(
self.app.name, self.model.name, scope=AUTHORIZATION_POLICY_SCOPE
),
resource_types=AUTHORIZATION_POLICY_RESOURCE_TYPES, # pyright: ignore
lightkube_client=self.lightkube_client,
logger=logger,
)

def _on_cert_handler_cert_changed(self, _):
"""Event handler for when tls certificates have changed."""
self._sync_all_resources()
Expand All @@ -239,11 +264,14 @@ def _metrics_proxy_pebble_ready(self, _):
def _on_remove(self, _):
"""Event handler for remove."""
# Removing tailing ingresses
krm = self._get_ingress_resource_manager()
krm.delete()
kim = self._get_ingress_resource_manager()
kim.delete()

krm = self._get_gateway_resource_manager()
krm.delete()
kgm = self._get_gateway_resource_manager()
kgm.delete()

kam = self._get_authorization_policy_resource_manager()
kam.delete()

def _on_ingress_data_provided(self, _):
"""Handle a unit providing data requesting IPU."""
Expand Down Expand Up @@ -439,6 +467,42 @@ def _construct_httproute(self, data: IngressRequirerData, prefix: str, section_n
spec=http_route.spec.model_dump(exclude_none=True),
)

def _construct_ingress_auth_policy(self, data: IngressRequirerData):

auth_policy = AuthorizationPolicyResource(
metadata=Metadata(
name=data.app.name + "-" + self.app.name + "-" + data.app.model + "-l4",
namespace=data.app.model,
),
spec=AuthorizationPolicySpec(
rules=[
AuthRule(
to=[To(operation=Operation(ports=[str(data.app.port)]))],
from_=[ # type: ignore # this is accessible via an alias
From(
source=Source(
principals=[
_get_peer_identity_for_juju_application(
self.managed_name, self.model.name
)
]
)
)
],
)
],
selector=WorkloadSelector(matchLabels={"app.kubernetes.io/name": data.app.name}),
),
)
auth_resource = RESOURCE_TYPES["AuthorizationPolicy"]
return auth_resource(
metadata=ObjectMeta.from_dict(auth_policy.metadata.model_dump()),
# by_alias=True because the model includes an alias for the `from` field
# exclude_unset=True because unset fields will be treated as their default values in Kubernetes
# exclude_none=True because null values in this data always mean the Kubernetes default
spec=auth_policy.spec.model_dump(by_alias=True, exclude_unset=True, exclude_none=True),
)

def _construct_redirect_to_https_httproute(
self, data: IngressRequirerData, prefix: str, section_name: str
):
Expand Down Expand Up @@ -555,11 +619,13 @@ def _remove_hostname_if_present(self):

def _sync_ingress_resources(self):
current_ingresses = []
current_policies = []
relation_mappings = {}
if not self.unit.is_leader():
raise RuntimeError("Ingress can only be provided on the leader unit.")

krm = self._get_ingress_resource_manager()
kam = self._get_authorization_policy_resource_manager()

# If we can construct a gateway Secret, TLS is enabled so we should configure the routes accordingly
is_tls_enabled = self._construct_gateway_tls_secret() is not None
Expand All @@ -572,28 +638,33 @@ def _sync_ingress_resources(self):

data = self.ingress_per_appv2.get_data(rel)
prefix = self._generate_prefix(data.app.model_dump(by_alias=True))
resources_to_append = []
ingress_resources_to_append = []
ingress_policies_to_append = []
if is_tls_enabled:
# TLS is configured, so we enable HTTPS route and redirect HTTP to HTTPS
resources_to_append.append(
ingress_resources_to_append.append(
self._construct_redirect_to_https_httproute(data, prefix, section_name="http")
)
resources_to_append.append(
ingress_resources_to_append.append(
self._construct_httproute(data, prefix, section_name="https")
)
else:
# Else, we enable only an HTTP route
resources_to_append.append(
ingress_resources_to_append.append(
self._construct_httproute(data, prefix, section_name="http")
)

ingress_policies_to_append.append(self._construct_ingress_auth_policy(data))

if rel.active:
current_ingresses.extend(resources_to_append)
current_ingresses.extend(ingress_resources_to_append)
current_policies.extend(ingress_policies_to_append)
external_url = self._generate_external_url(prefix)
relation_mappings[rel] = external_url

try:
krm.reconcile(current_ingresses)
kam.reconcile(current_policies)
for relation, url in relation_mappings.items():
self.ingress_per_appv2.wipe_ingress_data(relation)
logger.debug(f"Publishing external URL for {relation.app.name}: {url}")
Expand Down Expand Up @@ -697,5 +768,17 @@ def format_labels(label_dict: Dict[str, str]) -> str:
return ",".join(f"{key}={value}" for key, value in label_dict.items())


def _get_peer_identity_for_juju_application(app_name, namespace):
"""Return a Juju application's peer identity.
Format returned is defined by `principals` in
[this reference](https://istio.io/latest/docs/reference/config/security/authorization-policy/#Source):
This function relies on the Juju convention that each application gets a ServiceAccount of the same name in the same
namespace.
"""
return f"cluster.local/ns/{namespace}/sa/{app_name}"


if __name__ == "__main__":
main(IstioIngressCharm)
68 changes: 67 additions & 1 deletion src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from enum import Enum
from typing import Dict, List, Optional

from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field


# Global metadata schema
Expand Down Expand Up @@ -166,3 +166,69 @@ class HTTPRouteResource(BaseModel):

metadata: Metadata
spec: HTTPRouteResourceSpec


# Authrization Policy schema
# Below is stripped down to cater for only L4 needed policies for ingress


class Action(str, Enum):
"""Action is a type that represents the action to take when a rule matches."""

allow = "ALLOW"


class WorkloadSelector(BaseModel):
"""WorkloadSelector defines the selector for the policy."""

matchLabels: Dict[str, str]


class Source(BaseModel):
"""Source defines the source of the policy."""

principals: Optional[List[str]] = None


class From(BaseModel):
"""From defines the source of the policy."""

source: Source


class Operation(BaseModel):
"""Operation defines the operation of the To model."""

ports: Optional[List[str]] = None
paths: Optional[List[str]] = None


class To(BaseModel):
"""To defines the destination of the policy."""

operation: Optional[Operation] = None


class AuthRule(BaseModel):
"""AuthRule defines a policy rule."""

from_: Optional[List[From]] = Field(default=None, alias="from")
to: Optional[List[To]] = None
# Allows us to populate with `Rule(from_=[From()])`. Without this, we can only use they alias `from`, which is
# protected, meaning we could only build rules from a dict like `Rule(**{"from": [From()]})`.
model_config = ConfigDict(populate_by_name=True)


class AuthorizationPolicySpec(BaseModel):
"""AuthorizationPolicySpec defines the spec of an Istio AuthorizationPolicy Kubernetes resource."""

action: Action = Action.allow
rules: List[AuthRule]
selector: WorkloadSelector


class AuthorizationPolicyResource(BaseModel):
"""AuthorizationPolicyResource defines the structure of an Istio AuthorizationPolicy Kubernetes resource."""

metadata: Metadata
spec: AuthorizationPolicySpec
29 changes: 29 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
"HTTPRoute": create_namespaced_resource(
"gateway.networking.k8s.io", "v1", "HTTPRoute", "httproutes"
),
"AuthorizationPolicy": create_namespaced_resource(
"security.istio.io",
"v1",
"AuthorizationPolicy",
"authorizationpolicies",
),
}


Expand Down Expand Up @@ -107,6 +113,29 @@ async def get_route_spec(ops_test: OpsTest, route_name: str) -> Optional[Dict[st
return None


async def get_auth_policy_spec(ops_test: OpsTest, policy_name: str) -> Optional[Dict[str, Any]]:
"""Retrieve and check the spec of the AuthorizationPolicy resource.
Args:
ops_test: pytest-operator plugin
policy_name: Name of the AuthorizationPolicy resource.
Returns:
A dictionary representing the spec of the policy, or None if not found.
"""
model = ops_test.model.info
try:
c = lightkube.Client()
policy = c.get(
RESOURCE_TYPES["AuthorizationPolicy"], namespace=model.name, name=policy_name
)
return policy.spec

except Exception as e:
logger.error("Error retrieving AuthorizationPolicy condition: %s", e, exc_info=1)
return None


async def get_route_condition(ops_test: OpsTest, route_name: str) -> Optional[Dict[str, Any]]:
"""Retrieve and check the condition from the HTTPRoute resource.
Expand Down
Loading

0 comments on commit 405caae

Please sign in to comment.