Skip to content

Commit

Permalink
feat: twingateresourceaccess - allow specifying principal by name (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
ekampf committed Mar 26, 2024
1 parent e7d8425 commit 0b1e69b
Show file tree
Hide file tree
Showing 13 changed files with 589 additions and 30 deletions.
6 changes: 5 additions & 1 deletion app/api/client.py
Expand Up @@ -9,9 +9,11 @@
from requests.adapters import HTTPAdapter, Retry

from app.api.client_connectors import TwingateConnectorAPI
from app.api.client_groups import TwingateGroupAPIs
from app.api.client_remote_networks import TwingateRemoteNetworksAPIs
from app.api.client_resources import TwingateResourceAPIs
from app.api.client_resources_access import TwingateResourceAccessAPIs
from app.api.client_service_accounts import TwingateServiceAccountAPIs
from app.api.exceptions import GraphQLMutationError
from app.settings import TwingateOperatorSettings, get_version

Expand Down Expand Up @@ -66,9 +68,11 @@ def connect(self):


class TwingateAPIClient(
TwingateConnectorAPI,
TwingateGroupAPIs,
TwingateResourceAPIs,
TwingateResourceAccessAPIs,
TwingateConnectorAPI,
TwingateServiceAccountAPIs,
TwingateRemoteNetworksAPIs,
):
def __init__(
Expand Down
33 changes: 33 additions & 0 deletions app/api/client_groups.py
@@ -0,0 +1,33 @@
import logging

from gql import gql
from gql.transport.exceptions import TransportQueryError

from app.api.protocol import TwingateClientProtocol

QUERY_GET_GROUP_ID_BY_NAME = gql(
"""
query GetGroupByName($name: String!) {
groups(filter: {name: {eq: $name}}) {
edges {
node {
id
name
}
}
}
}
"""
)


class TwingateGroupAPIs:
def get_group_id(self: TwingateClientProtocol, group_name: str) -> str | None:
try:
result = self.execute_gql(
QUERY_GET_GROUP_ID_BY_NAME, variable_values={"name": group_name}
)
return result["groups"]["edges"][0]["node"]["id"]
except (TransportQueryError, IndexError, KeyError):
logging.exception("Failed to get resource")
return None
35 changes: 35 additions & 0 deletions app/api/client_service_accounts.py
@@ -0,0 +1,35 @@
import logging

from gql import gql
from gql.transport.exceptions import TransportQueryError

from app.api.protocol import TwingateClientProtocol

QUERY_GET_SA_ID_BY_NAME = gql(
"""
query GetServiceAccountByName($name: String!) {
serviceAccounts(filter: {name: {eq: $name}}) {
edges {
node {
id
name
}
}
}
}
"""
)


class TwingateServiceAccountAPIs:
def get_service_account_id(
self: TwingateClientProtocol, service_account_name: str
) -> str | None:
try:
result = self.execute_gql(
QUERY_GET_SA_ID_BY_NAME, variable_values={"name": service_account_name}
)
return result["serviceAccounts"]["edges"][0]["node"]["id"]
except (TransportQueryError, IndexError, KeyError):
logging.exception("Failed to get resource")
return None
45 changes: 45 additions & 0 deletions app/api/tests/test_client_groups.py
@@ -0,0 +1,45 @@
import json

import responses


class TestTwingateGroupsAPIs:
def test_get_group_id_success(self, test_url, api_client, mocked_responses):
success_response = json.dumps(
{
"data": {
"groups": {"edges": [{"node": {"id": "test-id", "name": "test"}}]}
}
}
)

mocked_responses.post(
test_url,
status=200,
body=success_response,
match=[
responses.matchers.json_params_matcher(
{"variables": {"name": "test"}}, strict_match=False
)
],
)
result = api_client.get_group_id("test")
assert result == "test-id"

def test_get_group_id_not_found_returns_none(
self, test_url, api_client, mocked_responses
):
success_response = json.dumps({"data": {"groups": {"edges": []}}})

mocked_responses.post(
test_url,
status=200,
body=success_response,
match=[
responses.matchers.json_params_matcher(
{"variables": {"name": "test"}}, strict_match=False
)
],
)
result = api_client.get_group_id("test")
assert result is None
49 changes: 49 additions & 0 deletions app/api/tests/test_client_service_accounts.py
@@ -0,0 +1,49 @@
import json

import responses


class TestTwingateServiceAccountAPIs:
def test_get_service_account_id_success(
self, test_url, api_client, mocked_responses
):
success_response = json.dumps(
{
"data": {
"serviceAccounts": {
"edges": [{"node": {"id": "test-id", "name": "test"}}]
}
}
}
)

mocked_responses.post(
test_url,
status=200,
body=success_response,
match=[
responses.matchers.json_params_matcher(
{"variables": {"name": "test"}}, strict_match=False
)
],
)
result = api_client.get_service_account_id("test")
assert result == "test-id"

def test_get_service_account_id_not_found_returns_none(
self, test_url, api_client, mocked_responses
):
success_response = json.dumps({"data": {"serviceAccounts": {"edges": []}}})

mocked_responses.post(
test_url,
status=200,
body=success_response,
match=[
responses.matchers.json_params_matcher(
{"variables": {"name": "test"}}, strict_match=False
)
],
)
result = api_client.get_service_account_id("test")
assert result is None
28 changes: 24 additions & 4 deletions app/crds.py
Expand Up @@ -146,17 +146,37 @@ class TwingateResourceCRD(BaseK8sModel):
# region TwingateResourceAccessCRD


class PrincipalTypeEnum(str, Enum):
Group = "group"
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)

type: PrincipalTypeEnum
name: str


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

resource_ref: _ResourceRef
principal_id: str | 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:
return self

resource_ref: _ResourceRef = Field(alias="resourceRef")
principal_id: str = Field(alias="principalId")
security_policy_id: str | None = Field(alias="securityPolicyId", default=None)
raise ValueError("Missing principal_id or principal_external_ref")

@property
def resource_ref_fullname(self) -> str:
Expand Down
41 changes: 33 additions & 8 deletions app/handlers/handlers_resource_access.py
Expand Up @@ -5,12 +5,32 @@
import kopf

from app.api.client import GraphQLMutationError, TwingateAPIClient
from app.crds import ResourceAccessSpec
from app.crds import PrincipalTypeEnum, ResourceAccessSpec
from app.handlers.base import fail, success

K8sObject = MutableMapping[Any, Any]


def get_principal_id(access_crd: ResourceAccessSpec, client: TwingateAPIClient) -> str:
if principal_id := access_crd.principal_id:
return principal_id

if ref := access_crd.principal_external_ref:
if ref.type == PrincipalTypeEnum.Group:
principal_id = client.get_group_id(ref.name)
elif ref.type == PrincipalTypeEnum.ServiceAccount:
principal_id = client.get_service_account_id(ref.name)
else:
raise ValueError(f"Unknown principal type: {ref.type}")

if not principal_id:
raise ValueError(f"Principal {ref.type} {ref.name} not found.")

return principal_id

raise ValueError("Missing principal_id or principal_external_ref")


@kopf.on.create("twingateresourceaccess")
def twingate_resource_access_create(body, spec, memo, logger, patch, **kwargs):
logger.info("Got a TwingateResourceAccess create request: %s", spec)
Expand All @@ -21,19 +41,21 @@ def twingate_resource_access_create(body, spec, memo, logger, patch, **kwargs):
kopf.warn(body, reason="ResourceNotFound", message=err)
return {"success": False, "error": err}

spec = resource_crd.spec
if not spec.id:
if not resource_crd.spec.id:
raise kopf.TemporaryError("Resource not yet created, retrying...", delay=15)

resource_id = resource_crd.spec.id
try:
client = TwingateAPIClient(memo.twingate_settings)
principal_id = get_principal_id(access_crd, client)
client.resource_access_add(
spec.id, access_crd.principal_id, access_crd.security_policy_id
resource_id, principal_id, access_crd.security_policy_id
)

kopf.info(
body,
reason="Success",
message=f"Added access to {spec.id}<>{access_crd.principal_id}",
message=f"Added access to {resource_crd.spec.id}<>{principal_id}",
)
patch.metadata["ownerReferences"] = [
resource_crd.metadata.owner_reference_object
Expand Down Expand Up @@ -65,9 +87,10 @@ def twingate_resource_access_update(spec, diff, status, memo, logger, **kwargs):
if resource_crd := access_crd.get_resource():
try:
client = TwingateAPIClient(memo.twingate_settings)
principal_id = get_principal_id(access_crd, client)
client.resource_access_add(
resource_crd.spec.id,
access_crd.principal_id,
principal_id,
access_crd.security_policy_id,
)
return success()
Expand All @@ -84,7 +107,8 @@ def twingate_resource_access_delete(spec, status, memo, logger, **kwargs):
resource_crd = access_crd.get_resource()
if resource_id := resource_crd and resource_crd.spec.id:
client = TwingateAPIClient(memo.twingate_settings)
client.resource_access_remove(resource_id, access_crd.principal_id)
principal_id = get_principal_id(access_crd, client)
client.resource_access_remove(resource_id, principal_id)


@kopf.timer(
Expand All @@ -107,8 +131,9 @@ def twingate_resource_access_sync(body, spec, status, memo, logger, **kwargs):

try:
client = TwingateAPIClient(memo.twingate_settings)
principal_id = get_principal_id(access_crd, client)
client.resource_access_add(
resource_crd.spec.id, access_crd.principal_id, access_crd.security_policy_id
resource_crd.spec.id, principal_id, access_crd.security_policy_id
)
return success()
except GraphQLMutationError as mex:
Expand Down

0 comments on commit 0b1e69b

Please sign in to comment.