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: twingateresourceaccess - allow specifying principal by name #62

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
80fca39
CRD + tests
ekampf Dec 8, 2023
5990acd
CRD pydantic struct changes
ekampf Dec 8, 2023
1b8af7c
nicer code (dont override `spec`)
ekampf Dec 8, 2023
d49f11c
new APIs
ekampf Dec 8, 2023
429ac57
Initial implementation (pending tests)
ekampf Dec 8, 2023
17b4f04
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Dec 12, 2023
bf08cab
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Dec 19, 2023
f330103
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Dec 20, 2023
a54b63e
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Jan 5, 2024
f574d3b
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Jan 16, 2024
7f6ffa9
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Feb 17, 2024
b42a9ce
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Feb 26, 2024
4bd2490
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 14, 2024
e9efbfa
test_get_service_account_id_success
ekampf Mar 14, 2024
34a47a1
test_get_service_account_id_not_found_returns_none
ekampf Mar 14, 2024
63bd3c5
TestTwingateGroupsAPIs
ekampf Mar 14, 2024
cef4681
typecheck
ekampf Mar 14, 2024
d6cef8b
Fix tests
ekampf Mar 14, 2024
4dc15f1
Fxi tests
ekampf Mar 14, 2024
6353fa8
fix CRD validation
ekampf Mar 15, 2024
f5c5b6c
integration tests
ekampf Mar 15, 2024
f67f9ce
more tests
ekampf Mar 15, 2024
aee87a5
more tests
ekampf Mar 15, 2024
bcb302d
test_id_invalid_spec
ekampf Mar 15, 2024
0f3b35d
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 19, 2024
36a9382
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 21, 2024
ddb8385
CR
ekampf Mar 21, 2024
cdde9c1
typo
ekampf Mar 21, 2024
ba22b53
CR
ekampf Mar 21, 2024
69dccdf
CR
ekampf Mar 21, 2024
2da184a
Fix op name
ekampf Mar 21, 2024
3c9c48c
CR
ekampf Mar 21, 2024
f415b26
fix test
ekampf Mar 21, 2024
c26d8da
Fix test
ekampf Mar 21, 2024
ed26e21
serviceaccount -> ServiceAccount
ekampf Mar 22, 2024
1e796ca
Added test
ekampf Mar 22, 2024
e73b9e6
matchName -> name
ekampf Mar 22, 2024
ed87624
More description
ekampf Mar 22, 2024
09e99fe
Fix tests
ekampf Mar 22, 2024
86dca3b
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 22, 2024
b7f87f1
Fix test
ekampf Mar 22, 2024
a3762d9
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 22, 2024
96725ec
Merge branch 'main' into feature/32-feat-twingateresourceaccess-allow…
ekampf Mar 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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