Skip to content

Commit

Permalink
feat: Include DiracX tokens to PEM data from /auth/proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisburr committed Jan 28, 2024
1 parent 6f2f719 commit e19efaf
Show file tree
Hide file tree
Showing 47 changed files with 227 additions and 81 deletions.
79 changes: 13 additions & 66 deletions diracx-db/tests/proxy/test_proxydb.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from functools import wraps
from pathlib import Path
from functools import partial
from typing import AsyncGenerator

import pytest
from DIRAC.Core.Security.VOMS import voms_init_cmd
from DIRAC.Core.Security.X509Chain import X509Chain
from DIRAC.Core.Utilities.ReturnValues import returnValueOrRaise
from sqlalchemy import insert

from diracx.core.exceptions import DiracError
from diracx.db.sql.proxy.db import ProxyDB
from diracx.db.sql.proxy.schema import CleanProxies

TEST_NAME = "testuser"
TEST_DN = "/O=Dirac Computing/O=CERN/CN=MrUser"
TEST_DATA_DIR = Path(__file__).parent / "data"
TEST_PEM_PATH = TEST_DATA_DIR / "proxy.pem"
from diracx.testing.proxy import (
TEST_DATA_DIR,
TEST_DN,
check_proxy_string,
insert_proxy,
voms_init_cmd_fake,
)


@pytest.fixture
Expand All @@ -33,15 +28,7 @@ async def empty_proxy_db(tmp_path) -> AsyncGenerator[ProxyDB, None]:
@pytest.fixture
async def proxy_db(empty_proxy_db) -> AsyncGenerator[ProxyDB, None]:
async with empty_proxy_db.engine.begin() as conn:
await conn.execute(
insert(CleanProxies).values(
UserName=TEST_NAME,
UserDN=TEST_DN,
ProxyProvider="Certificate",
Pem=TEST_PEM_PATH.read_bytes(),
ExpirationTime=datetime(2033, 11, 25, 21, 25, 23, tzinfo=timezone.utc),
)
)
await insert_proxy(conn)
yield empty_proxy_db


Expand Down Expand Up @@ -75,55 +62,15 @@ async def test_proxy_not_long_enough(proxy_db: ProxyDB):
)


@wraps(voms_init_cmd)
def voms_init_cmd_fake(*args, **kwargs):
cmd = voms_init_cmd(*args, **kwargs)

new_cmd = ["voms-proxy-fake"]
i = 1
while i < len(cmd):
# Some options are not supported by voms-proxy-fake
if cmd[i] in {"-valid", "-vomses", "-timeout"}:
i += 2
continue
new_cmd.append(cmd[i])
i += 1
new_cmd.extend(
[
"-hostcert",
f"{TEST_DATA_DIR}/certs/host/hostcert.pem",
"-hostkey",
f"{TEST_DATA_DIR}/certs/host/hostkey.pem",
"-fqan",
"/fakevo/Role=NULL/Capability=NULL",
]
)
return new_cmd


async def test_get_proxy(proxy_db: ProxyDB, monkeypatch, tmp_path):
monkeypatch.setenv("X509_CERT_DIR", str(TEST_DATA_DIR / "certs"))
monkeypatch.setattr("diracx.db.sql.proxy.db.voms_init_cmd", voms_init_cmd_fake)
monkeypatch.setattr(
"diracx.db.sql.proxy.db.voms_init_cmd", partial(voms_init_cmd_fake, "fakevo")
)

async with proxy_db as proxy_db:
proxy_pem = await proxy_db.get_proxy(
TEST_DN, "fakevo", "fakevo_user", "/fakevo", 3600, tmp_path, tmp_path
)

proxy_chain = X509Chain()
returnValueOrRaise(proxy_chain.loadProxyFromString(proxy_pem))

# Check validity
not_after = returnValueOrRaise(proxy_chain.getNotAfterDate()).replace(
tzinfo=timezone.utc
)
# The proxy should currently be valid
assert datetime.now(timezone.utc) < not_after
# The proxy should be invalid in less than 3601 seconds
time_left = not_after - datetime.now(timezone.utc)
assert time_left < timedelta(hours=1, seconds=1)

# Check VOMS data
voms_data = returnValueOrRaise(proxy_chain.getVOMSData())
assert voms_data["vo"] == "fakevo"
assert voms_data["fqan"] == ["/fakevo/Role=NULL/Capability=NULL"]
check_proxy_string("fakevo", proxy_pem)
51 changes: 46 additions & 5 deletions diracx-routers/src/diracx/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os
import re
import secrets
import textwrap
from datetime import timedelta
from enum import StrEnum
from pathlib import Path
Expand Down Expand Up @@ -45,6 +46,7 @@
UnevaluatedProperty,
)
from diracx.core.settings import ServiceSettingsBase, TokenSigningKey
from diracx.core.utils import serialize_credentials
from diracx.db.sql.auth.schema import FlowStatus, RefreshTokenStatus

from .dependencies import (
Expand Down Expand Up @@ -1130,11 +1132,24 @@ async def get_proxy(
proxy_db: ProxyDB,
user_info: Annotated[AuthorizedUserInfo, Depends(verify_dirac_access_token)],
config: Config,
auth_db: AuthDB,
settings: AuthSettings,
available_properties: AvailableSecurityProperties,
):
voms_role = config.Registry[user_info.vo].Groups[user_info.dirac_group].VOMSRole
_, sub = user_info.sub.split(":", 1)
user_dns = config.Registry[user_info.vo].Users[sub].DNs
voms_vo_name = config.Registry[user_info.vo].VOMS.Name or user_info.vo
vo_config = config.Registry[user_info.vo]

# Prevent privilege escalation from tokens with non-default properties
default_properties = vo_config.Groups[user_info.dirac_group].Properties
if set(user_info.properties) != default_properties:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only tokens with group default properties can be used to get a proxy",
)

voms_role = vo_config.Groups[user_info.dirac_group].VOMSRole
_, vo_sub = user_info.sub.split(":", 1)
user_dns = vo_config.Users[vo_sub].DNs
voms_vo_name = vo_config.VOMS.Name or user_info.vo

# TODO: Move on to the Config class
tmp = TemporaryDirectory()
Expand All @@ -1158,7 +1173,7 @@ async def get_proxy(
lifetime_seconds = 3600
for user_dn in user_dns:
try:
return await proxy_db.get_proxy(
proxy_string = await proxy_db.get_proxy(
user_dn,
voms_vo_name,
user_info.dirac_group,
Expand All @@ -1167,10 +1182,36 @@ async def get_proxy(
vomses,
vomsdir,
)
break
except ProxyNotFoundError:
pass
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No available proxy for {user_info.sub}",
)

# Add a DiracX token to the proxy
PEM_BEGIN = "-----BEGIN DIRACX-----"
PEM_END = "-----END DIRACX-----"
scope = [f"vo:{user_info.vo}", f"group:{user_info.dirac_group}"] + [
f"property:{prop}" for prop in user_info.properties
]

token = await exchange_token(
auth_db,
" ".join(scope),
{"sub": vo_sub, "preferred_username": user_info.preferred_username},
config,
settings,
available_properties,
refresh_token_expire_minutes=lifetime_seconds,
legacy_exchange=True,
)

proxy_string += f"{PEM_BEGIN}\n"
data = base64.b64encode(serialize_credentials(token).encode("utf-8")).decode()
proxy_string += textwrap.fill(data, width=64)
proxy_string += f"\n{PEM_END}\n"

return proxy_string
2 changes: 1 addition & 1 deletion diracx-routers/tests/auth/test_legacy_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def test_valid(test_client, legacy_credentials, expires_seconds):
assert user_info["sub"] == "lhcb:b824d4dc-1f9d-4ee8-8df5-c0ae55d46041"
assert user_info["vo"] == "lhcb"
assert user_info["dirac_group"] == "lhcb_user"
assert user_info["properties"] == ["NormalUser", "PrivateLimitedDelegation"]
assert set(user_info["properties"]) == {"NormalUser", "PrivateLimitedDelegation"}


async def test_refresh_token(test_client, legacy_credentials):
Expand Down
59 changes: 59 additions & 0 deletions diracx-routers/tests/auth/test_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from functools import partial

import pytest

from diracx.db.sql import ProxyDB
from diracx.testing.proxy import (
TEST_DATA_DIR,
check_proxy_string,
insert_proxy,
voms_init_cmd_fake,
)

DIRAC_CLIENT_ID = "myDIRACClientID"
pytestmark = pytest.mark.enabled_dependencies(
["AuthDB", "AuthSettings", "ConfigSource", "ProxyDB"]
)


@pytest.fixture
def test_client(client_factory):
with client_factory.normal_user(
sub="b824d4dc-1f9d-4ee8-8df5-c0ae55d46041", group="lhcb_user"
) as client:
yield client


async def test_valid(client_factory, test_client, monkeypatch):
proxy_db = client_factory.app.dependency_overrides[ProxyDB.transaction].args[0]
async with proxy_db as db:
await insert_proxy(db.conn)

monkeypatch.setenv("X509_CERT_DIR", str(TEST_DATA_DIR / "certs"))
monkeypatch.setattr(
"diracx.db.sql.proxy.db.voms_init_cmd", partial(voms_init_cmd_fake, "lhcb")
)

r = test_client.get("/api/auth/proxy")
assert r.status_code == 200, r.json()
pem_data = r.json()

check_proxy_string("lhcb", pem_data)


async def test_wrong_properties(client_factory):
"""Ensure that limited JWTs are rejected to prevent privilege escalation"""
from diracx.core.properties import NORMAL_USER

with client_factory.normal_user(
group="lhcb_user", properties=[NORMAL_USER]
) as client:
r = client.get("/api/auth/proxy")
assert r.status_code == 403
assert "group default properties" in r.json()["detail"]


async def test_no_proxy_uploaded(test_client):
r = test_client.get("/api/auth/proxy")
assert r.status_code == 400, r.json()
assert "No available proxy" in r.json()["detail"]
44 changes: 35 additions & 9 deletions diracx-testing/src/diracx/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import requests

if TYPE_CHECKING:
from diracx.core.properties import SecurityProperty
from diracx.routers.auth import AuthSettings
from diracx.routers.job_manager.sandboxes import SandboxStoreSettings

Expand Down Expand Up @@ -145,6 +146,9 @@ def __init__(
for e in select_from_extension(group="diracx.db.sql")
}
self._cache_dir = tmp_path_factory.mktemp("empty-dbs")
self._config_source = ConfigSource.create_from_url(
backend_url=f"git+file://{with_config_repo}"
)

self.test_auth_settings = test_auth_settings

Expand All @@ -158,9 +162,7 @@ def __init__(
os_database_conn_kwargs={
# TODO: JobParametersDB
},
config_source=ConfigSource.create_from_url(
backend_url=f"git+file://{with_config_repo}"
),
config_source=self._config_source,
)

self.all_dependency_overrides = self.app.dependency_overrides.copy()
Expand Down Expand Up @@ -264,22 +266,32 @@ def unauthenticated(self):
yield client

@contextlib.contextmanager
def normal_user(self):
from diracx.core.properties import NORMAL_USER
def normal_user(
self,
*,
vo: str = "lhcb",
sub: str = "yellow-sub",
group: str = "test_group",
properties: list[SecurityProperty] = None,
):
from diracx.core.properties import NORMAL_USER, PRIVATE_LIMITED_DELEGATION
from diracx.routers.auth import create_token

if properties is None:
properties = [NORMAL_USER, PRIVATE_LIMITED_DELEGATION]

with self.unauthenticated() as client:
payload = {
"sub": "testingVO:yellow-sub",
"sub": f"{vo}:{sub}",
"exp": datetime.now(tz=timezone.utc)
+ timedelta(self.test_auth_settings.access_token_expire_minutes),
"aud": AUDIENCE,
"iss": ISSUER,
"dirac_properties": [NORMAL_USER],
"dirac_properties": properties,
"jti": str(uuid4()),
"preferred_username": "preferred_username",
"dirac_group": "test_group",
"vo": "lhcb",
"dirac_group": group,
"vo": vo,
}
token = create_token(payload, self.test_auth_settings)

Expand Down Expand Up @@ -357,6 +369,7 @@ def with_config_repo(tmp_path_factory):
"b824d4dc-1f9d-4ee8-8df5-c0ae55d46041": {
"PreferedUsername": "chaen",
"Email": None,
"DNs": ["/O=Dirac Computing/O=CERN/CN=MrUser"],
},
"c935e5ed-2g0e-5ff9-9eg6-d1bf66e57152": {
"PreferedUsername": "albdr",
Expand All @@ -370,12 +383,25 @@ def with_config_repo(tmp_path_factory):
"b824d4dc-1f9d-4ee8-8df5-c0ae55d46041",
"c935e5ed-2g0e-5ff9-9eg6-d1bf66e57152",
],
"VOMSRole": "arole",
},
"lhcb_tokenmgr": {
"Properties": ["NormalUser", "ProxyManagement"],
"Users": ["c935e5ed-2g0e-5ff9-9eg6-d1bf66e57152"],
},
},
"VOMS": {
"Servers": {
"voms.lhcb.invalid": {
"Info": '"lhcb" "voms.lhcb.invalid" "1256" '
'"/DC=mars/OU=computers/CN=voms.lhcb.invalid" "lhcb" "24"',
"Chain": [
"/DC=mars/OU=computers/CN=voms.lhcb.invalid",
"/DC=mars/CN=Fake Certification Authority",
],
}
}
},
}
},
"Operations": {"Defaults": {}},
Expand Down
Loading

0 comments on commit e19efaf

Please sign in to comment.