Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/openhound_jamf/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from threading import Lock

from dlt.common.configuration import configspec
from dlt.common.configuration.specs import CredentialsConfiguration
from dlt.sources.helpers import requests
from dlt.sources.helpers.rest_client.auth import AuthConfigBase


@configspec
class JamfCredentials(CredentialsConfiguration):
host: str = None

def auth(self):
pass


@configspec
class JamfPasswordCredentials(JamfCredentials):
username: str = None
password: str = None

@property
def auth(self):
return "password"

@property
def token(self):
response = requests.post(
f"{self.host}/api/v1/auth/token",
auth=(self.username, self.password),
)
response.raise_for_status()
return response.json()["token"]


@configspec
class JamfClientCredentials(JamfCredentials):
client_id: str = None
client_secret: str = None

@property
def auth(self):
return "client"

@property
def token(self):
response = requests.post(
f"{self.host}/api/v1/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
},
)
response.raise_for_status()
return response.json()["access_token"]

Comment thread
d3vzer0 marked this conversation as resolved.

@configspec
class JamfAuth(AuthConfigBase):
def __init__(self, credentials: JamfPasswordCredentials | JamfClientCredentials):
self.credentials = credentials
self.token: str | None = None
self._token_lock = Lock()

def get_token(self) -> str:
if not self.token:
with self._token_lock:
if not self.token:
self.token = self.credentials.token

return self.token

def __call__(self, request):
request.headers["Authorization"] = f"Bearer {self.get_token()}"
return request
50 changes: 9 additions & 41 deletions src/openhound_jamf/source.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
from dataclasses import dataclass
from functools import cache
from typing import Union
from urllib.parse import urlsplit

import dlt
from dlt.common.configuration import configspec
from dlt.sources.helpers import requests
from dlt.sources.helpers.rest_client.auth import AuthConfigBase
from dlt.sources.helpers.rest_client.client import RESTClient
from dlt.sources.helpers.rest_client.paginators import (
PageNumberPaginator,
SinglePagePaginator,
)

from .auth import JamfAuth, JamfClientCredentials, JamfPasswordCredentials
from .main import app
from .models import (
SSO,
Expand Down Expand Up @@ -39,36 +37,6 @@ class SourceContext:
client: RESTClient


@configspec
class CustomAuth(AuthConfigBase):
def __init__(self, host: str, username: str, password: str):
self.host = host
self.username = username
self.password = password

@staticmethod
@cache
def token(host, username, password) -> str:
"""Calls the JAMF authentication API to generate an API token based on a username/password.

Args:
host (str): The base JAMF URL used for API calls.
user (str): The JAMF username used for authentication, read from environment or dlt config file.
passw (str): The JAMF password used for authentication, read from environment or dlt config file.

Returns:
dict: A (cached) JAMF API token used for API calls.
"""
response = requests.post(f"{host}/api/v1/auth/token", auth=(username, password))
return response.json()["token"]

def __call__(self, request):
request.headers["Authorization"] = (
f"Bearer {self.token(self.host, self.username, self.password)}"
)
return request


@app.resource(name="users", parallelized=True, columns=BaseUser)
def users(ctx: SourceContext):
"""DLT resource, fetches JAMF users via the /JSSResource/use¬rs endpoint.
Expand Down Expand Up @@ -304,14 +272,14 @@ def tenant(host: str):

@dlt.source(name="jamf", max_table_nesting=0)
def source(
username=dlt.secrets.value, password=dlt.secrets.value, host=dlt.secrets.value
credentials: Union[
JamfPasswordCredentials, JamfClientCredentials
] = dlt.secrets.value,
Comment thread
d3vzer0 marked this conversation as resolved.
):
"""DLT source, defines JAMF collection resources and transformers.

Args:
username (str): The JAMF username used for authentication.
password (str): The JAMF password used for authentication.
host (str): The base JAMF URL used for API calls.
credentials (JamfPasswordCredentials | JamfClientCredentials): The JAMF credentials configuration

Returns:
(tuple[users, user_details, sites, scripts, script_details, policy_details, policies, computers, computerextensionattributes, api_roles, api_integrations, accounts, account_details, account_groups, account_group_details]): A tuple of DLT resources/transformers registered for the JAMF source.
Expand All @@ -320,9 +288,9 @@ def source(

ctx = SourceContext(
client=RESTClient(
base_url=host,
base_url=credentials.host,
headers={"accept": "application/json"},
auth=CustomAuth(host=host, username=username, password=password),
auth=JamfAuth(credentials=credentials),
paginator=SinglePagePaginator(),
)
)
Expand All @@ -345,5 +313,5 @@ def source(
api_integrations(ctx),
api_roles(ctx),
sso(ctx),
tenant(host),
tenant(credentials.host),
)
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import json
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from urllib.parse import parse_qs, urlsplit

import pytest
from fastapi import Request
from requests import PreparedRequest, Response
from requests.hooks import dispatch_hook
from requests.structures import CaseInsensitiveDict
Expand Down Expand Up @@ -40,6 +41,19 @@ async def auth_token():
app.state.calls.append("auth_token")
return {"token": "test-token"}

@app.post("/api/v1/oauth/token")
async def oauth_token(request: Request):
form_data = parse_qs((await request.body()).decode())

assert form_data == {
"grant_type": ["client_credentials"],
"client_id": ["jamf-client-id"],
"client_secret": ["jamf-client-secret"],
}

app.state.calls.append("oauth_token")
return {"access_token": "test-client-token"}

@app.get("/JSSResource/users")
async def users():
app.state.calls.append("users")
Expand Down
66 changes: 45 additions & 21 deletions tests/test_collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from collections import Counter

import pytest

pytestmark = pytest.mark.usefixtures("mock_dlt_requests")

EXPECTED_RESOURCES = [
"account_details",
"account_group_details",
Expand All @@ -18,9 +22,8 @@
]


EXPECTED_CALLS = Counter(
BASE_EXPECTED_CALLS = Counter(
{
"auth_token": 1,
"users": 1,
"user_details": 1,
"accounts": 2,
Expand All @@ -39,15 +42,8 @@
}
)

#
# def _reload_source_module():
# for module_name in list(sys.modules):
# if module_name.startswith("openhound_jamf"):
# sys.modules.pop(module_name, None)
# return importlib.import_module("openhound_jamf.source")


def test_collect_pipeline_runs_successfully(tmp_path, mock_dlt_requests, mock_jamf_api):
def _run_collect_and_assert(tmp_path, mock_jamf_api, credentials, expected_calls):
import os

os.environ["DLT_DATA_DIR"] = str(tmp_path / "dlt")
Expand All @@ -57,17 +53,8 @@ def test_collect_pipeline_runs_successfully(tmp_path, mock_dlt_requests, mock_ja

from openhound_jamf.source import source as source_module

# source_module = _reload_source_module()
# source_module.CustomAuth.token.cache_clear()

collector = Collector(name="jamf", output_path=tmp_path / "output")
load_info = collector.run(
source_module(
username="jamf-user",
password="jamf-pass",
host="https://jamf.test",
)
)
load_info = collector.run(source_module(credentials=credentials))

assert load_info.loads_ids
assert not load_info.has_failed_jobs
Expand All @@ -78,4 +65,41 @@ def test_collect_pipeline_runs_successfully(tmp_path, mock_dlt_requests, mock_ja
assert resource_dir.exists()
assert any(resource_dir.glob("*.jsonl*"))

assert Counter(mock_jamf_api.app.state.calls) == EXPECTED_CALLS
actual_calls = Counter(mock_jamf_api.app.state.calls)
assert actual_calls == expected_calls, (
"Jamf API calls did not match expectations. "
f"actual={actual_calls}, expected={expected_calls}, "
f"call_order={mock_jamf_api.app.state.calls}"
)


def test_collect_pipeline_runs_successfully(tmp_path, mock_jamf_api):
from openhound_jamf.auth import JamfPasswordCredentials

credentials = JamfPasswordCredentials(
host="https://jamf.test",
username="jamf-user",
password="jamf-pass",
)
assert credentials.auth == "password"

expected_calls = BASE_EXPECTED_CALLS + Counter({"auth_token": 1})

_run_collect_and_assert(tmp_path, mock_jamf_api, credentials, expected_calls)


def test_collect_pipeline_runs_successfully_with_client_credentials_auth(
tmp_path, mock_jamf_api
):
from openhound_jamf.auth import JamfClientCredentials

credentials = JamfClientCredentials(
host="https://jamf.test",
client_id="jamf-client-id",
client_secret="jamf-client-secret",
)
assert credentials.auth == "client"

expected_calls = BASE_EXPECTED_CALLS + Counter({"oauth_token": 1})

_run_collect_and_assert(tmp_path, mock_jamf_api, credentials, expected_calls)
82 changes: 29 additions & 53 deletions tests/test_data/jss_resource/accounts-by-id.json
Original file line number Diff line number Diff line change
@@ -1,57 +1,33 @@
{
"id": 1,
"name": "John Smith",
"directory_user": true,
"full_name": "John Smith",
"email": "john.smith@company.com",
"email_address": "john.smith@company.com",
"enabled": "Enabled",
"ldap_server": {
"account": {
"id": 1,
"name": "Directory Server Name"
},
"force_password_change": true,
"access_level": "Full Access",
"privilege_set": "Administrator",
"site": {
"id": -1,
"name": "None"
},
"privileges": {
"jss_objects": [
{
"privilege": "string"
}
],
"jss_settings": [
{
"privilege": "string"
}
],
"jss_actions": [
{
"privilege": "string"
}
],
"recon": [
{
"privilege": "string"
}
],
"casper_admin": [
{
"privilege": "string"
}
],
"casper_remote": [
{
"privilege": "string"
}
],
"casper_imaging": [
{
"privilege": "string"
}
]
"name": "John Smith",
"directory_user": true,
"full_name": "John Smith",
"email": "john.smith@company.com",
"email_address": "john.smith@company.com",
"enabled": "Enabled",
"ldap_server": {
"id": 1,
"name": "Directory Server Name"
},
"force_password_change": true,
"access_level": "Full Access",
"privilege_set": "Administrator",
"site": {
"id": -1,
"name": "None"
},
"privileges": {
"jss_objects": [
"string"
],
"jss_settings": [
"string"
],
"jss_actions": [
"string"
]
}
}
}
Loading