Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support referencing TwingateGroup from TwingateResourceAccess #412

Merged
merged 11 commits into from
Sep 23, 2024
Merged
65 changes: 47 additions & 18 deletions app/crds.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ class BaseK8sModel(BaseModel):
status: dict[str, Any] | None = None


class _KubernetesObjectRef(BaseModel):
name: str
namespace: str = Field(default="default")


# region TwingateResourceCRD


Expand Down Expand Up @@ -151,11 +156,6 @@ class PrincipalTypeEnum(str, Enum):
ServiceAccount = "serviceAccount"


class _ResourceRef(BaseModel):
name: str
namespace: str = Field(default="default")


class _PrincipalExternalRef(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)

Expand All @@ -166,44 +166,73 @@ class _PrincipalExternalRef(BaseModel):
class ResourceAccessSpec(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)

resource_ref: _ResourceRef
resource_ref: _KubernetesObjectRef
principal_id: str | None = None
group_ref: _KubernetesObjectRef | None = None
principal_external_ref: _PrincipalExternalRef | None = None
security_policy_id: str | None = None

@model_validator(mode="after")
def validate_princiapl_id_or_principal_external_ref(self):
if self.principal_id or self.principal_external_ref:
def validate_target_ref_exists(self):
if self.principal_id or self.group_ref or self.principal_external_ref:
return self

raise ValueError("Missing principal_id or principal_external_ref")
raise ValueError("Missing principal_id, group_ref or principal_external_ref")

@property
def resource_ref_fullname(self) -> str:
return f"{self.resource_ref.namespace}/{self.resource_ref.name}"

def get_resource_ref_object(self) -> OptionalK8sObject:
@property
def group_ref_fullname(self) -> str | None:
return (
f"{self.group_ref.namespace}/{self.group_ref.name}"
if self.group_ref
else None
)

def _get_ref_object(
self, plural_type: str, namespace: str, name: str
) -> OptionalK8sObject:
log_prefix = (
f"ResourceAccessCRD._get_ref_object({plural_type}, {namespace}, {name}):"
)
try:
kapi = kubernetes.client.CustomObjectsApi()
response = kapi.get_namespaced_custom_object(
"twingate.com",
"v1beta",
self.resource_ref.namespace,
"twingateresources",
self.resource_ref.name,
namespace,
plural_type,
name,
)
logging.info(
"%s got %s",
log_prefix,
response,
)
logging.info("ResourceAccessCRD.get_resource_ref_object got: %s", response)
return response
except kubernetes.client.exceptions.ApiException as api_ex:
if api_ex.status == 404:
logging.warning(
"ResourceAccessCRD.get_resource_ref_object: resource not found."
)
logging.warning("%s resource not found.", log_prefix)
else:
logging.exception("ResourceAccessCRD.get_resource_ref_object failed")
logging.exception("%s failed", log_prefix)

return None

def get_resource_ref_object(self) -> OptionalK8sObject:
return self._get_ref_object(
"twingateresources", self.resource_ref.namespace, self.resource_ref.name
)

def get_group_ref_object(self) -> OptionalK8sObject:
if not self.group_ref:
return None

return self._get_ref_object(
"twingategroups", self.group_ref.namespace, self.group_ref.name
)

def get_resource(self) -> TwingateResourceCRD | None:
resource_ref_object = self.get_resource_ref_object()
if not resource_ref_object:
Expand Down
10 changes: 9 additions & 1 deletion app/handlers/handlers_groups.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from datetime import timedelta

import kopf
Expand Down Expand Up @@ -49,8 +50,15 @@ def twingate_group_create_update(body, spec, logger, memo, patch, **kwargs):
)


GROUP_RECONCILER_INTERVAL = int(os.environ.get("GROUP_RECONCILER_INTERVAL", timedelta(hours=10).seconds)) # fmt: skip
GROUP_RECONCILER_INIT_DELAY = int(os.environ.get("GROUP_RECONCILER_INIT_DELAY", 60)) # fmt: skip


@kopf.timer(
"twingategroup", interval=timedelta(hours=10).seconds, initial_delay=60, idle=60
"twingategroup",
interval=GROUP_RECONCILER_INTERVAL,
initial_delay=GROUP_RECONCILER_INIT_DELAY,
idle=60,
)
def twingate_group_reconciler(body, spec, logger, memo, patch, **_):
return twingate_group_create_update(body, spec, logger, memo, patch)
Expand Down
9 changes: 9 additions & 0 deletions app/handlers/handlers_resource_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ def get_principal_id(
if principal_id := access_crd.principal_id:
return principal_id

if group_ref_object := access_crd.get_group_ref_object():
group_spec = group_ref_object["spec"]
if group_id := group_spec.get("id"):
return group_id

raise kopf.TemporaryError(
"TwingateGroup object doesn't have an id yet. retrying...", delay=15
)

if ref := access_crd.principal_external_ref:
# Once `twingate_resource_access_change` ran and we have the principal_id
# we dont use it and do not re-query the API
Expand Down
21 changes: 21 additions & 0 deletions app/handlers/tests/test_handlers_resource_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,31 @@ def test_id_invalid_spec(self):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.principal_external_ref = None
access_crd.get_group_ref_object.return_value = None
with pytest.raises(
ValueError, match="Missing principal_id or principal_external_ref"
):
get_principal_id(access_crd, None, MagicMock())

def test_id_from_group_ref_object(self):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.principal_external_ref = None
access_crd.get_group_ref_object.return_value = {"spec": {"id": "group-id"}}
assert get_principal_id(access_crd, None, MagicMock()) == "group-id"

def test_id_from_group_ref_object_not_ready_raises_temoraryerror(self):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.principal_external_ref = None
access_crd.get_group_ref_object.return_value = {"spec": {"id": None}}
with pytest.raises(kopf.TemporaryError):
assert get_principal_id(access_crd, None, MagicMock()) == "group-id"

def test_from_external_ref_group(self, mock_api_client):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.get_group_ref_object.return_value = None
access_crd.principal_external_ref = MagicMock()
access_crd.principal_external_ref.type = "group"
access_crd.principal_external_ref.name = "group-name"
Expand All @@ -54,6 +71,7 @@ def test_from_external_ref_group(self, mock_api_client):
def test_from_external_ref_sa(self, mock_api_client):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.get_group_ref_object.return_value = None
access_crd.principal_external_ref = MagicMock()
access_crd.principal_external_ref.type = "serviceAccount"
access_crd.principal_external_ref.name = "sa-name"
Expand All @@ -68,6 +86,7 @@ def test_from_external_ref_sa(self, mock_api_client):
def test_from_external_ref_returns_none(self, mock_api_client):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.get_group_ref_object.return_value = None
access_crd.principal_external_ref = MagicMock()
access_crd.principal_external_ref.type = "serviceAccount"
access_crd.principal_external_ref.name = "sa-name"
Expand All @@ -82,6 +101,7 @@ def test_from_external_ref_returns_none(self, mock_api_client):
def test_from_external_ref_invalid_type_returns_none(self, mock_api_client):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.get_group_ref_object.return_value = None
access_crd.principal_external_ref = MagicMock()
access_crd.principal_external_ref.type = "invalid"
access_crd.principal_external_ref.name = "sa-name"
Expand All @@ -92,6 +112,7 @@ def test_from_external_ref_invalid_type_returns_none(self, mock_api_client):
def test_from_external_ref_uses_created_status_principal_id(self):
access_crd = MagicMock()
access_crd.principal_id = None
access_crd.get_group_ref_object.return_value = None
access_crd.principal_external_ref = MagicMock()
access_crd.principal_external_ref.type = "invalid"
access_crd.principal_external_ref.name = "sa-name"
Expand Down
127 changes: 127 additions & 0 deletions app/tests/test_crds_resourceaccess.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,45 @@ def sample_resource_object():
}


@pytest.fixture
def sample_group_object():
return {
"apiVersion": "twingate.com/v1beta",
"kind": "TwingateGroup",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": '{"apiVersion":"twingate.com/v1beta","kind":"TwingateGroup","metadata":{"annotations":{},"name":"example","namespace":"default"},"spec":{"members":["eran@twingate.com"],"name":"Example Group"}}\n',
"twingate.com/kopf-managed": "yes",
"twingate.com/last-handled-configuration": '{"spec":{"id":"R3JvdXA6MjAxNjc5MA==","name":"Example Group"}}\n',
},
"creationTimestamp": "2024-07-24T22:51:32Z",
"finalizers": ["twingate.com/finalizer"],
"generation": 31,
"name": "example",
"namespace": "default",
"resourceVersion": "9848778",
"uid": "da2bd676-3e9e-4162-b203-bced1d27385e",
},
"spec": {"id": "R3JvdXA6MjAxNjc5MA==", "name": "Example Group"},
"status": {
"twingate": {"progress": {}},
"twingate_group_create_update": {
"success": True,
"ts": "2024-09-19T17:53:16.460069",
"twingate_id": "R3JvdXA6MjAxNjc5MA==",
},
"twingate_group_reconciler": {
"message": "Group reconciled",
"success": True,
"ts": "2024-09-19T17:54:16.494586",
"twingate_id": "R3JvdXA6MjAxNjc5MA==",
},
"user_ids": [{"email": "eran@twingate.com", "id": "VXNlcjoxMTYwMDA="}],
"user_ids_hash": "eaa47aed357c554764003095d6656f49",
},
}


@pytest.fixture
def sample_resourceaccess_object():
return {
Expand Down Expand Up @@ -161,6 +200,68 @@ def test_deserialization(sample_resourceaccess_object):
assert crd.spec.resource_ref_fullname == "default/foo"


def test_deserialization_with_group_ref():
data = {
"apiVersion": "twingate.com/v1",
"kind": "TwingateResourceAccess",
"metadata": {
"creationTimestamp": "2023-09-29T19:30:39Z",
"finalizers": ["kopf.zalando.org/KopfFinalizerMarker"],
"generation": 1,
"managedFields": [
{
"apiVersion": "twingate.com/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:finalizers": {
".": {},
'v:"kopf.zalando.org/KopfFinalizerMarker"': {},
}
}
},
"manager": "kopf",
"operation": "Update",
"time": "2023-09-29T19:30:39Z",
},
{
"apiVersion": "twingate.com/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:spec": {
".": {},
"f:principalId": {},
"f:resourceRef": {".": {}, "f:name": {}, "f:namespace": {}},
}
},
"manager": "kubectl-create",
"operation": "Update",
"time": "2023-09-29T19:30:39Z",
},
],
"name": "foo-access-to-bar",
"namespace": "default",
"resourceVersion": "612168",
"uid": "ad0298c5-b84f-4617-b4a2-d3cbbe9f6a4c",
},
"spec": {
"resourceRef": {"name": "foo", "namespace": "default"},
"groupRef": {
"name": "test-group",
"namespace": "myns",
},
},
}
crd = TwingateResourceAccessCRD(**data)
assert crd.spec.group_ref.namespace == "myns"
assert crd.spec.group_ref.name == "test-group"
assert crd.spec.resource_ref.name == "foo"
assert crd.spec.resource_ref.namespace == "default"
assert crd.metadata.name == "foo-access-to-bar"
assert crd.metadata.uid == "ad0298c5-b84f-4617-b4a2-d3cbbe9f6a4c"
assert crd.spec.resource_ref_fullname == "default/foo"


def test_deserialization_with_principal_external_ref():
data = {
"apiVersion": "twingate.com/v1",
Expand Down Expand Up @@ -220,6 +321,9 @@ def test_deserialization_with_principal_external_ref():
assert crd.spec.resource_ref_fullname == "default/foo"


# region get_resource


def test_spec_get_resource_ref_object(
mock_get_namespaced_custom_object, sample_resourceaccess_object
):
Expand Down Expand Up @@ -274,3 +378,26 @@ def test_spec_get_resource_failure_returns_none(
crd = TwingateResourceAccessCRD(**sample_resourceaccess_object)
response = crd.spec.get_resource()
assert response is None


# endregion


# region get_group_ref_object
def test_spec_get_group_ref_object(
mock_get_namespaced_custom_object, sample_resourceaccess_object, sample_group_object
):
resource_access_object = sample_resourceaccess_object
del sample_resourceaccess_object["spec"]["principalId"]
sample_resourceaccess_object["spec"]["groupRef"] = {
"name": sample_group_object["metadata"]["name"],
"namespace": sample_group_object["metadata"]["namespace"],
}

mock_get_namespaced_custom_object.return_value = sample_group_object
crd = TwingateResourceAccessCRD(**resource_access_object)
response = crd.spec.get_group_ref_object()
assert response == sample_group_object


# endregion
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ spec:
required: ["resourceRef", "principalId"]
- properties:
required: ["resourceRef", "principalExternalRef"]
- properties:
required: ["resourceRef", "groupRef"]
properties:
principalId:
type: string
Expand All @@ -29,6 +31,20 @@ spec:
x-kubernetes-validations:
- rule: self == oldSelf
message: "principalId is immutable"
groupRef:
type: object
description: "groupRef specifies the TwingateGroup kubernetes object reference to provide access to."
x-kubernetes-validations:
- rule: self == oldSelf
message: "groupRef is immutable."
properties:
name:
type: string
description: "Name of the TwingateGroup object."
namespace:
type: string
default: default
description: "Namespace of TwingateGroup object."
principalExternalRef:
type: object
required: ["type", "name"]
Expand Down
Loading