Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ else
endif

_docker_pull_test_images:
docker pull ghcr.io/neuro-inc/platformauthapi:latest; \
docker pull ghcr.io/neuro-inc/platformadmin:latest \
1 change: 1 addition & 0 deletions PLATFORMADMINAPI_IMAGE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ghcr.io/neuro-inc/platformadmin:latest
30 changes: 14 additions & 16 deletions platform_disk_api/api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, asynccontextmanager
Expand Down Expand Up @@ -35,14 +37,13 @@
from apolo_kube_client.apolo import NO_ORG, normalize_name
from apolo_kube_client.config import KubeConfig
from marshmallow import Schema, fields
from neuro_auth_client import (
from neuro_admin_client import (
AuthClient,
ClientSubTreeViewRoot,
Permission,
User,
check_permissions,
)
from neuro_auth_client.security import AuthScheme, setup_security
from neuro_admin_client.security import AuthScheme, setup_security
from neuro_logging import init_logging, setup_sentry

from platform_disk_api import __version__
Expand Down Expand Up @@ -223,9 +224,6 @@ async def handle_create_disk(self, request: Request) -> Response:
resp_payload = DiskSchema().dump(disk)
return json_response(resp_payload, status=HTTPCreated.status_code)

def _check_disk_read_perm(self, disk: Disk, tree: ClientSubTreeViewRoot) -> bool:
return tree.allows(self._get_disk_read_perm(disk))

@docs(
tags=["disks"],
summary="Get Disk objects by id or name",
Expand Down Expand Up @@ -256,18 +254,18 @@ async def handle_get_disk(self, request: Request) -> Response:
@response_schema(DiskSchema(many=True), 200)
async def handle_list_disks(self, request: Request) -> Response:
username = await check_authorized(request)
tree = await self._auth_client.get_permissions_tree(
username, self._disk_cluster_uri
)
org_name = request.query.get("org_name") or normalize_name(NO_ORG)
project_name = request.query["project_name"]
disks = [
disk
for disk in await self._service.get_all_disks(
org_name=org_name, project_name=project_name
)
if self._check_disk_read_perm(disk, tree)
]
disks = await self._service.get_all_disks(
org_name=org_name, project_name=project_name
)

disks = await self._auth_client.get_authorized_entities(
user_name=username,
entities=disks,
global_perm=None,
per_entity_perms=lambda d: [self._get_disk_read_perm(d)],
)
resp_payload = DiskSchema(many=True).dump(disks)
return json_response(resp_payload, status=HTTPOk.status_code)

Expand Down
10 changes: 3 additions & 7 deletions platform_disk_api/identity.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import logging
import typing
from dataclasses import dataclass, field

from aiohttp.web import HTTPUnauthorized, Request
from aiohttp_security.api import AUTZ_KEY, IDENTITY_KEY
from neuro_auth_client.security import AuthPolicy
from aiohttp_security.api import IDENTITY_KEY
from neuro_admin_client.security import get_untrusted_user_name

logger = logging.getLogger(__name__)

Expand All @@ -21,10 +20,7 @@ async def untrusted_user(request: Request) -> Identity:
retrieve the minimal information about the user.
"""
identity = await _get_identity(request)

autz_policy = request.config_dict[AUTZ_KEY]
autz_policy = typing.cast(AuthPolicy, autz_policy) # todo: fix after python upgrade
name = autz_policy.get_user_name_from_identity(identity)
name = get_untrusted_user_name(identity)
if name is None:
raise HTTPUnauthorized()

Expand Down
1,161 changes: 594 additions & 567 deletions poetry.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ classifiers = [
python = ">=3.12,<4.0"
aiohttp = {version = "3.12.15", extras = ["speedups"]}
yarl = "<2"
neuro-auth-client = "25.8.2"
neuro-admin-client = "25.9.2"
marshmallow = "4.0.0"
aiohttp-apispec = "3.0.0b2"
markupsafe = "3.0.2"
Expand Down Expand Up @@ -92,7 +92,7 @@ module = "jose"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "neuro_auth_client.*"
module = "neuro_admin_client.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
Expand Down
1 change: 1 addition & 0 deletions tests/PLATFORMADMINAPI_IMAGE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ghcr.io/neuro-inc/platformadmin:latest
181 changes: 105 additions & 76 deletions tests/integration/auth.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,98 @@
from __future__ import annotations

import asyncio
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Optional

import aiodocker
from aiodocker.containers import DockerContainer
import pytest
from aiodocker.containers import DockerContainer
from aiodocker.utils import JSONObject
from aiohttp import ClientError
from aiohttp.hdrs import AUTHORIZATION
from jose import jwt
from neuro_auth_client import AuthClient, Permission, User as AuthClientUser
from neuro_admin_client import AuthClient, User as AuthClientUser
from yarl import URL
from aiodocker.utils import JSONObject

from platform_disk_api.config import AuthConfig
from tests.integration.conftest import random_name

DOCKER_NETWORK = "it_net"
ADMIN_CONTAINER = "auth_server"


@pytest.fixture(scope="session")
def auth_server_image_name() -> str:
with open("PLATFORMAUTHAPI_IMAGE") as f:
with open("PLATFORMADMINAPI_IMAGE") as f:
return f.read().strip()


def create_token(name: str) -> str:
payload = {"identity": name}
return jwt.encode(payload, "secret", algorithm="HS256")


@pytest.fixture(scope="session")
def token_factory() -> Callable[[str], str]:
return create_token


@pytest.fixture
def admin_token(token_factory: Callable[[str], str]) -> str:
return token_factory("admin")


async def _get_host_port(
container: DockerContainer, cport: int, tries: int = 50, sleep_s: float = 0.2
) -> int:
for _ in range(tries):
info = await container.port(cport)
if info and info[0].get("HostPort"):
return int(info[0]["HostPort"])
await asyncio.sleep(sleep_s)
raise RuntimeError(f"Port mapping for {cport}/tcp not found after {tries} tries")


async def create_auth_config(container: DockerContainer) -> AuthConfig:
port = await _get_host_port(container, 8080)
url = URL(f"http://127.0.0.1:{port}/apis/admin/v1")
token = create_token("admin")
return AuthConfig(url=url, token=token)


@pytest.fixture(scope="session")
async def auth_server(
docker: aiodocker.Docker, reuse_docker: bool, auth_server_image_name: str
docker: aiodocker.Docker,
reuse_docker: bool,
auth_server_image_name: str,
postgres: dict,
docker_network: str,
docker_smart_stubs: dict,
run_platformadmin_migrations,
) -> AsyncIterator[AuthConfig]:
image_name = auth_server_image_name
container_name = "auth_server"
container_config: JSONObject = {
"Image": image_name,
"AttachStdout": False,
"AttachStderr": False,
"HostConfig": {"PublishAllPorts": True},
"Env": ["NP_JWT_SECRET=secret"],
}
container_name = ADMIN_CONTAINER

env = [
"NP_JWT_SECRET=secret",
f"NP_ADMIN_POSTGRES_DSN={postgres['dsn_sync']}",
f"NP_ADMIN_AUTH_URL={docker_smart_stubs['auth_url']}",
f"NP_ADMIN_AUTH_TOKEN={create_token('admin')}",
f"NP_ADMIN_CONFIG_URL={docker_smart_stubs['config_url']}",
f"NP_ADMIN_CONFIG_TOKEN={create_token('admin')}",
f"NP_ADMIN_NOTIFICATIONS_URL={docker_smart_stubs['notif_url']}",
f"NP_ADMIN_NOTIFICATIONS_TOKEN={create_token('compute')}",
"NP_ADMIN_DB_POOL_MIN=1",
"NP_ADMIN_DB_POOL_MAX=5",
]

if reuse_docker:
try:
container = await docker.containers.get(container_name)
if container["State"]["Running"]:
info = await container.show()
if info["State"]["Running"]:
auth_config = await create_auth_config(container)
await wait_for_auth_server(auth_config)
yield auth_config
Expand All @@ -50,16 +101,32 @@ async def auth_server(
pass

try:
await docker.images.inspect(auth_server_image_name)
await docker.images.inspect(image_name)
except aiodocker.exceptions.DockerError:
await docker.images.pull(auth_server_image_name)
await docker.images.pull(image_name)

container_config: JSONObject = {
"Image": image_name,
"name": container_name,
"AttachStdout": False,
"AttachStderr": False,
"ExposedPorts": {"8080/tcp": {}},
"HostConfig": {
"PublishAllPorts": True,
"NetworkMode": docker_network, # same network as stubs + postgres
"ExtraHosts": ["host.docker.internal:host-gateway"],
},
"NetworkingConfig": {"EndpointsConfig": {docker_network: {}}},
"Env": env,
}

container = await docker.containers.create_or_replace(
name=container_name, config=container_config
)
await container.start()

auth_config = await create_auth_config(container)
print(f"[auth_server] admin at {auth_config.url}")
await wait_for_auth_server(auth_config)
yield auth_config

Expand All @@ -68,48 +135,17 @@ async def auth_server(
await container.delete(force=True)


def create_token(name: str) -> str:
payload = {"identity": name}
return jwt.encode(payload, "secret", algorithm="HS256")


@pytest.fixture(scope="session")
def token_factory() -> Callable[[str], str]:
return create_token


@pytest.fixture
def admin_token(token_factory: Callable[[str], str]) -> str:
return token_factory("admin")


async def create_auth_config(
container: DockerContainer,
) -> AuthConfig:
host = "0.0.0.0"
port_info = await container.port(8080)
if not port_info:
raise RuntimeError("Port 8080 not mapped in the container!")
port = int(port_info[0]["HostPort"])
url = URL(f"http://{host}:{port}")
token = create_token("compute")
return AuthConfig(
url=url,
token=token,
)
@asynccontextmanager
async def create_auth_client(config: AuthConfig) -> AsyncGenerator[AuthClient, None]:
async with AuthClient(url=config.url, token=config.token) as client:
yield client


@pytest.fixture
async def auth_config(auth_server: AuthConfig) -> AsyncIterator[AuthConfig]:
yield auth_server


@asynccontextmanager
async def create_auth_client(config: AuthConfig) -> AsyncGenerator[AuthClient, None]:
async with AuthClient(url=config.url, token=config.token) as client:
yield client


@pytest.fixture
async def auth_client(auth_server: AuthConfig) -> AsyncGenerator[AuthClient, None]:
async with create_auth_client(auth_server) as client:
Expand Down Expand Up @@ -161,33 +197,26 @@ async def _factory(
) -> _User:
if not name:
name = f"user-{random_name()}"
user = AuthClientUser(name=name)
await auth_client.add_user(user, token=admin_token)
user = AuthClientUser(name=name, email=f"{name}@test.org")
await auth_client.add_user(user)
_user = _User(name=user.name, token=token_factory(user.name))

if not skip_grant:
org_path = f"/{org_name}" if org_name else ""
project_path = f"/{project_name}" if project_name else ""
name_path = "" if org_level else f"/{name}"
permissions = [
Permission(uri=f"disk://{cluster_name}/{name}", action="write")
]
if org_path:
permissions.append(
Permission(
uri=f"disk://{cluster_name}{org_path}{name_path}",
action="write",
)
)
if project_path:
permissions.append(
Permission(
uri=f"disk://{cluster_name}{org_path}{project_path}",
action="write",
)
cluster_path = cluster_name if cluster_name else f"{name}-cluster"
try:
await auth_client.create_cluster(
cluster_name=cluster_path, headers=_user.headers
)
await auth_client.grant_user_permissions(
name, permissions, token=admin_token
)
except ClientError:
pass

return _User(name=user.name, token=token_factory(user.name))
project_path = project_name if project_name else f"{name}-project"
try:
await auth_client.create_project(
name=project_path, cluster_name=cluster_path, headers=_user.headers
)
except ClientError:
pass
return _user

yield _factory
Loading