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: Add minimal client implementation for auth services #76

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ API Reference
api_reference/dataframe
api_reference/spec
api_reference/file
api_reference/auth

Indices and tables
------------------
Expand Down
15 changes: 15 additions & 0 deletions docs/api_reference/auth.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.. _api_tag_page:

nisystemlink.clients.auth
======================

.. autoclass:: nisystemlink.clients.auth.AuthClient
:exclude-members: __init__

.. automethod:: __init__
.. automethod:: authenticate


.. automodule:: nisystemlink.clients.auth.models
:members:
:imported-members:
26 changes: 26 additions & 0 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,31 @@ Examples
Get the metadata of a File using its Id and download it.

.. literalinclude:: ../examples/file/download_file.py
:language: python
:linenos:


Auth API
-------

Overview
~~~~~~~~

The :class:`.AuthClient` class is the primary entry point of the Auth API.

When constructing a :class:`.AuthClient`, you can pass an
:class:`.HttpConfiguration` (like one retrieved from the
:class:`.HttpConfigurationManager`), or let :class:`.AuthClient` use the
default connection. The default connection depends on your environment.

With a :class:`.AuthClient` object, you can:

* Get the information about the caller.

Examples
~~~~~~~~

Get the workspace id for the workspace name.
.. literalinclude:: ../examples/auth/get_workspace_id.py
:language: python
:linenos:
34 changes: 34 additions & 0 deletions examples/auth/get_workspace_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Example of getting workspace ID."""

from nisystemlink.clients.auth import AuthClient
from nisystemlink.clients.auth.utilities import get_workspace_id
from nisystemlink.clients.core import ApiException, HttpConfiguration


server_url = "" # SystemLink API URL
server_api_key = "" # SystemLink API key
workspace_name = "" # Systemlink workspace name

auth_client = AuthClient(
HttpConfiguration(server_uri=server_url, api_key=server_api_key)
)

try:
caller_info = auth_client.authenticate()
workspaces_info = caller_info.workspaces
workspace_id = None

if workspaces_info:
workspace_id = get_workspace_id(
workspaces_info=workspaces_info,
workspace_name=workspace_name,
)

if workspace_id:
print(f"Workspace ID: {workspace_id}")

except ApiException as exp:
print(exp)

except Exception as exp:
print(exp)
3 changes: 3 additions & 0 deletions nisystemlink/clients/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._auth_client import AuthClient

# flake8: noqa
35 changes: 35 additions & 0 deletions nisystemlink/clients/auth/_auth_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Implementation of AuthClient."""

from typing import Optional

from nisystemlink.clients import core
from nisystemlink.clients.core._uplink._base_client import BaseClient
from nisystemlink.clients.core._uplink._methods import get

from . import models


class AuthClient(BaseClient):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming this AccessControlClient makes it intuitive and clubs the related feature across multiple services under the same client.
@mure, @spanglerco, @cameronwaterman - please share your thoughts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@santhoshramaraj I think that it's worth leaving this as-is (auth), then creating a secondary API specifically to expose access control functionality (creating/changing roles and/or workspace permissions management), as they are closely related, but really two different concepts.

As I mentioned in my comments, I think the auth functionality is pretty fundamental and might be worth somehow incorporating into the base class itself, but access control definitely doesn't belong there.

"""A set of methods to access the APIs of SystemLink Auth Client."""

def __init__(self, configuration: Optional[core.HttpConfiguration] = None):
"""Initialize an instance.

Args:
configuration: Defines the web server to connect to and information about
how to connect. If not provided, an instance of
:class:`JupyterHttpConfiguration <nisystemlink.clients.core.JupyterHttpConfiguration>` # noqa: W505
Copy link

@ChrisStrykesAgain ChrisStrykesAgain Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would use an instance of HttpConfiguration and not JupyterHttpConfiguration, right? Or am I missing something? I guess it depends on context. Might be worth a clarifying note, though, as users very well may be running in something other than a JupyterHub environment.

is used.

Raises:
ApiException: if unable to communicate with the Auth Service.
"""
if configuration is None:
configuration = core.JupyterHttpConfiguration()

super().__init__(configuration, base_path="/niauth/v1/")

@get("auth")
def authenticate(self) -> models.AuthInfo:
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
"""Authenticates the given x-ni-api-key and returns information about the caller."""
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
...
3 changes: 3 additions & 0 deletions nisystemlink/clients/auth/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._auth_models import AuthInfo, Workspace

# flake8: noqa
171 changes: 171 additions & 0 deletions nisystemlink/clients/auth/models/_auth_models.py
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Models utilized for Auth in SystemLink."""

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional

from nisystemlink.clients.core._uplink._json_model import JsonModel
from pydantic import Field


class AuthStatement(JsonModel):
"""Auth Statement information."""

actions: Optional[List[str]] = None
"""A list of actions the user is allowed to perform."""
resource: Optional[List[str]] = None
"""A list of resources the user is allowed to access."""
workspace: Optional[str] = Field(None, example="workspace-id")
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
"""The workspace the user is allowed to access."""


class AuthPolicy(JsonModel):
"""Auth Policy information."""

statements: Optional[List[AuthStatement]] = None
"""A list of statements defining the actions the user can perform on a resource in a workspace.
"""


class Statement(JsonModel):
"""Statement information."""

actions: Optional[List[str]] = None
"""A list of actions the user is allowed to perform."""
resource: Optional[List[str]] = None
"""A list of resources the user is allowed to access."""
workspace: Optional[str] = Field(None, example="workspace-id")
"""The workspace the user is allowed to access."""
description: Optional[str] = None
"""A description for this statement."""
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved


class Policy(JsonModel):
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
"""Policy information."""

id: Optional[str] = Field(None, example="policy-id")
"""The unique id."""
name: Optional[str] = Field(None, example="policy-name")
"""The policies's name."""
type: Optional[str] = Field(None, example="role")
"""The type of the policy."""
built_in: Optional[bool] = Field(None, alias="builtIn", example=True)
"""Whether the policy is built-in."""
user_id: Optional[str] = Field(None, alias="userId", example="user-id")
"""The user id."""
created: Optional[datetime] = Field(None, example="2019-12-02T15:31:45.379Z")
"""The created timestamp."""
updated: Optional[datetime] = Field(None, example="2019-12-02T15:31:45.379Z")
"""The last updated timestamp."""
deleted: Optional[bool] = Field(None, example=True)
"""Whether the policy is deleted or not."""
properties: Optional[Dict[str, str]] = Field(None, example={"key1": "value1"})
"""A map of key value properties."""
statements: Optional[List[Statement]] = None
"""A list of statements defining the actions the user can perform on a resource in a workspace.
"""
template_id: Optional[str] = Field(
None, alias="templateId", example="policy-template-id"
)
"""
The id of the policy template. Only set if the policy has been created based on a template and
does not contain inline statements.
"""
workspace: Optional[str] = Field(None, example="workspace-id")
"""
The workspace the policy template applies to. Only set if the policy has been created based on a
template and does not contain inline statements.
"""


class Status(Enum):
"""Enumeration to represent different status of user's registration."""

PENDING = "pending"
ACTIVE = "active"


class User(JsonModel):
"""User information."""

id: Optional[str] = Field(None, example="user-id")
"""The unique id."""
first_name: Optional[str] = Field(
None, alias="firstName", example="user-first-name"
)
"""The user's first name."""
last_name: Optional[str] = Field(None, alias="lastName", example="user-last-name")
"""The user's last name."""
email: Optional[str] = Field(None, example="[email protected]")
"""The user's email."""
phone: Optional[str] = Field(None, example="555-555-5555")
"""The user's contact phone number."""
niua_id: Optional[str] = Field(None, alias="niuaId", example="[email protected]")
"""The external id (niuaId, SID, login name)."""
login: Optional[str] = None
"""
The login name of the user. This the "username" or equivalent entered when
the user authenticates with the identity provider.
"""
accepted_to_s: Optional[bool] = Field(None, alias="acceptedToS", example=True)
"""(deprecated) Whether the user accepted the terms of service."""
properties: Optional[Dict[str, str]] = Field(None, example={"key1": "value1"})
"""A map of key value properties."""
keywords: Optional[List[str]] = None
"""A list of keywords associated with the user."""
created: Optional[datetime] = Field(None, example="2019-12-02T15:31:45.379Z")
"""The created timestamp."""
updated: Optional[datetime] = Field(None, example="2019-12-02T15:31:45.379Z")
"""The last updated timestamp."""
org_id: Optional[str] = Field(None, alias="orgId", example="org-id")
"""The id of the organization."""
policies: Optional[List[str]] = None
"""A list of policy ids to reference existing policies."""
status: Optional[Status] = Field(None, example="active")
"""The status of the users' registration."""
entitlements: Optional[Any] = None
"""(deprecated) Features to which the user is entitled within the application."""


class Org(JsonModel):
"""User's Organization information."""

id: Optional[str] = Field(None, example="org-id")
"""The unique id."""
name: Optional[str] = Field(None, example="org-name")
"""The name of the organization."""
owner_id: Optional[str] = Field(None, alias="ownerId", example="user-id")
"""The userId of the organization owner."""


class Workspace(JsonModel):
"""Workspace information."""

id: Optional[str] = Field(None, example="workspace-id")
"""The unique id."""
name: Optional[str] = Field(None, example="workspace-name")
"""The workspace name."""
enabled: Optional[bool] = Field(None, example=True)
"""Whether the workspace is enabled or not."""
default: Optional[bool] = Field(None, example=True)
"""
Whether the workspace is the default. The default workspace is used when callers omit a \
workspace id.
"""


class AuthInfo(JsonModel):
"""Information about the authenticated caller."""

user: Optional[User]
"""Details of authenticated caller."""
org: Optional[Org]
"""Organization of authenticated caller."""
workspaces: Optional[List[Workspace]]
"""List of workspaces the authenticated caller has access."""
policies: Optional[List[AuthPolicy]]
"""List of policies for the authenticated caller."""
properties: Optional[Dict[str, str]] = Field(None, example={"key1": "value1"})
"""A map of key value properties."""
24 changes: 24 additions & 0 deletions nisystemlink/clients/auth/utilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Utilities for Auth Client."""

from typing import List, Union

from nisystemlink.clients.auth.models import Workspace


def get_workspace_id(
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
workspaces_info: List[Workspace],
workspace_name: str,
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
) -> Union[str, None]:
Giriharan219 marked this conversation as resolved.
Show resolved Hide resolved
"""Get workspace id from the list of workspace info using `workspace_name`.

Args:
workspaces_info (List[workspace_info]): List of workspace info.
workspace_name (str): Workspace name.

Returns:
Union[str, None]: Workspace ID of the `workspace_name`.
"""
for workspace_info in workspaces_info:
if workspace_info.name == workspace_name and workspace_info.id:
return workspace_info.id
return None
21 changes: 21 additions & 0 deletions tests/integration/auth/test_auth_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Integration tests for AuthClient."""

import pytest
from nisystemlink.clients.auth import AuthClient


@pytest.fixture(scope="class")
def client(enterprise_config) -> AuthClient:
"""Fixture to create a AuthClient instance."""
return AuthClient(enterprise_config)


@pytest.mark.enterprise
@pytest.mark.integration
class TestAuthClient:
"""A set of test methods to test SystemLink Auth API."""

def test__authenticate__succeeds(self, client: AuthClient):
"""Test the case of getting caller information with SystemLink Credentials."""
response = client.authenticate()
assert response is not None