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
31 changes: 31 additions & 0 deletions physionet-django/console/templates/console/partners/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,36 @@ <h2>{{ partner.organization_name }}</h2>
{% endif %}
</div>
</div>

<div class="card mt-4">
<div class="card-header">
<strong>Entitlement check API usage</strong>
<span class="badge badge-secondary">{{ entitlement_check_total }} total</span>
</div>
<div class="card-body">
{% if recent_entitlement_logs %}
<table class="table table-sm">
<thead>
<tr><th>When</th><th>User</th><th>Project</th><th>Data</th></tr>
</thead>
<tbody>
{% for log in recent_entitlement_logs %}
<tr>
<td>{{ log.creation_datetime|date:"Y-m-d H:i" }}</td>
<td>{{ log.user.username }}</td>
<td>{{ log.project|default:"—" }}</td>
<td><code>{{ log.data }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="text-muted small">Showing the most recent 25.
<a href="{% url 'entitlement_check_logs' %}">View all entitlement check logs &rarr;</a>
</p>
{% else %}
<p class="text-muted">This partner has not made any entitlement check calls yet.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
6 changes: 6 additions & 0 deletions physionet-django/console/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@
path('partners/<int:pk>/reactivate/', views.partner_reactivate, name='partner_reactivate'),
path('partners/<int:pk>/revoke/', views.partner_revoke, name='partner_revoke'),

# Entitlement-check API logs (Layer 3 usage metrics)
path('entitlement-check-logs/',
views.entitlement_check_logs,
name='entitlement_check_logs'),

# Federated Sites
path('federated-sites/', views.federated_sites, name='federated_sites'),
path('federated-sites/add/', views.federated_site_add, name='federated_site_add'),
Expand Down Expand Up @@ -334,6 +339,7 @@
'partner_suspend': {'_skip_': True},
'partner_reactivate': {'_skip_': True},
'partner_revoke': {'_skip_': True},
'entitlement_check_logs': {'_skip_': True},

# DUA Logs: pk must be a credentialed project
'dua_logs_detail': {
Expand Down
33 changes: 33 additions & 0 deletions physionet-django/console/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
DataAccessRequest,
DUASignature,
EditLog,
EntitlementCheckLog,
License,
Publication,
PublishedAuthor,
Expand Down Expand Up @@ -3994,10 +3995,20 @@ def partner_detail(request, pk):
one_time_secret = None
if request.GET.get("show_secret") == "1":
one_time_secret = request.session.pop("_partner_one_time_secret", None)
recent_entitlement_logs = (
EntitlementCheckLog.objects
.filter(data__contains=f'partner_id={partner.pk};')
.order_by('-creation_datetime')[:25]
)
entitlement_check_total = EntitlementCheckLog.objects.filter(
data__contains=f'partner_id={partner.pk};'
).count()
return render(request, "console/partners/detail.html", {
"partner": partner,
"one_time_secret": one_time_secret,
"Status": Partner.Status,
"recent_entitlement_logs": recent_entitlement_logs,
"entitlement_check_total": entitlement_check_total,
})


Expand Down Expand Up @@ -4156,6 +4167,28 @@ def partner_revoke(request, pk):
{"form": form, "partner": partner, "action": "revoke"})


@console_permission_required('oauth.change_partner')
def entitlement_check_logs(request):
"""Console page: recent entitlement-check API calls across all partners."""
from collections import Counter
logs = (
EntitlementCheckLog.objects.select_related('user')
.order_by('-creation_datetime')[:500]
)
counter = Counter()
for log in logs:
for piece in log.data.split(';'):
if piece.startswith('partner_org='):
counter[piece.split('=', 1)[1]] += 1
break
partner_counts = sorted(counter.items(), key=lambda kv: -kv[1])
return render(
request,
'console/entitlement_check_logs.html',
{'logs': logs, 'partner_counts': partner_counts},
)


# ------------------------- Federated Sites Views ------------------------- #


Expand Down
Empty file.
6 changes: 6 additions & 0 deletions physionet-django/entitlements/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class EntitlementsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'entitlements'
Empty file.
44 changes: 44 additions & 0 deletions physionet-django/entitlements/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Response payload construction for the entitlement check endpoint.

We build a plain dict (not a DRF Serializer) because the result composes
data from three different objects (user, project, EntitlementResult) and
DRF's nested-source plumbing adds noise without value at this size.
"""
from django.utils import timezone

from project.enums import AccessPolicy

from entitlements.services import EntitlementResult


def _access_policy_name(value: int) -> str:
try:
return AccessPolicy(value).name
except ValueError:
return 'UNKNOWN'


def build_response_payload(result: EntitlementResult, user, project,
partner=None) -> dict:
return {
'allowed': result.allowed,
'reason_code': result.reason_code,
'missing_requirements': list(result.missing_requirements),
'missing_training_types': list(result.missing_training_types),
'user': {
'public_user_uuid': (
str(user.public_user_uuid) if user.is_authenticated else None
),
},
'project': {
'slug': project.slug,
'version': project.version,
'public_project_uuid': str(project.public_project_uuid),
'access_policy': _access_policy_name(project.access_policy),
},
'partner': (
{'organization_name': partner.organization_name}
if partner is not None else None
),
'checked_at': timezone.now().isoformat(),
}
154 changes: 154 additions & 0 deletions physionet-django/entitlements/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Entitlement check service.

Pure function answering: is this user entitled to access this published
project, and if not, why? Wraps `project.authorization.access.can_access_project`
to keep the bool source of truth identical, then re-derives reason codes
when access is denied so partner platforms can render actionable messages
to users.
"""
import logging
from dataclasses import dataclass, field
from typing import List

from django.conf import settings

from project.authorization.access import can_access_project
from project.models import (
AccessPolicy, DUASignature, DataAccessRequest, PublishedProject,
)
from user.models import Training
from physionet.utility import get_client_ip, get_country_code

logger = logging.getLogger(__name__)


# Closed set of reason codes. Partners depend on these strings; do not rename
# without coordinating a versioned API change.
GRANTED = 'granted'
DEPRECATED_FILES = 'deprecated_files'
GEORESTRICTED = 'georestricted'
NOT_AUTHENTICATED = 'not_authenticated'
DUA_NOT_SIGNED = 'dua_not_signed'
NOT_CREDENTIALED = 'not_credentialed'
TRAINING_INCOMPLETE = 'training_incomplete'
DATA_ACCESS_REQUEST_REQUIRED = 'data_access_request_required'

# Order in which we report the *primary* reason when multiple gates fail.
REASON_PRIORITY = [
DEPRECATED_FILES,
GEORESTRICTED,
NOT_AUTHENTICATED,
NOT_CREDENTIALED,
DUA_NOT_SIGNED,
DATA_ACCESS_REQUEST_REQUIRED,
TRAINING_INCOMPLETE,
]


@dataclass
class EntitlementResult:
allowed: bool
reason_code: str
missing_requirements: List[str] = field(default_factory=list)
missing_training_types: List[str] = field(default_factory=list)


def _missing_required_trainings(user, project: PublishedProject) -> List[str]:
required = list(project.required_trainings.values_list('slug', flat=True))
if not required:
return []
valid = (
Training.objects.get_valid()
.filter(user=user, training_type__slug__in=required)
.values_list('training_type__slug', flat=True)
)
valid_set = set(valid)
return [slug for slug in required if slug not in valid_set]


def check_entitlement(user, project: PublishedProject, request=None) -> EntitlementResult:
"""Return structured entitlement result for (user, project).

Mirrors the bool decision of `can_access_project` exactly, but adds
reason codes when access is denied. `request` is optional and only
used for georestriction (matches existing behavior of
can_access_project when request=None: georestriction is skipped).
Note: when called without a request on a georestricted project, the
response will not surface `georestricted` as the denial reason —
callers that care about georestriction must pass `request`.
"""
if can_access_project(project, user, request):
return EntitlementResult(allowed=True, reason_code=GRANTED)

if project.deprecated_files:
return EntitlementResult(
allowed=False, reason_code=DEPRECATED_FILES,
missing_requirements=[DEPRECATED_FILES],
)

if project.georestricted and request is not None:
country = get_country_code(get_client_ip(request))
if country in settings.BLOCKED_REGIONS:
return EntitlementResult(
allowed=False, reason_code=GEORESTRICTED,
missing_requirements=[GEORESTRICTED],
)

if not user.is_authenticated:
return EntitlementResult(
allowed=False, reason_code=NOT_AUTHENTICATED,
missing_requirements=[NOT_AUTHENTICATED],
)

missing: List[str] = []
missing_training: List[str] = []
policy = project.access_policy

if policy == AccessPolicy.RESTRICTED:
if not DUASignature.objects.filter(project=project, user=user).exists():
missing.append(DUA_NOT_SIGNED)

elif policy == AccessPolicy.CREDENTIALED:
if not user.is_credentialed:
missing.append(NOT_CREDENTIALED)
if not DUASignature.objects.filter(project=project, user=user).exists():
missing.append(DUA_NOT_SIGNED)
missing_training = _missing_required_trainings(user, project)
if missing_training:
missing.append(TRAINING_INCOMPLETE)

elif policy == AccessPolicy.CONTRIBUTOR_REVIEW:
if not user.is_credentialed:
missing.append(NOT_CREDENTIALED)
approved = DataAccessRequest.objects.get_active(
project=project, requester=user,
status=DataAccessRequest.ACCEPT_REQUEST_VALUE,
).exists()
if not approved:
missing.append(DATA_ACCESS_REQUEST_REQUIRED)
missing_training = _missing_required_trainings(user, project)
if missing_training:
missing.append(TRAINING_INCOMPLETE)

if not missing:
# can_access_project said False but our policy ladder found no
# specific reason — indicates the two have drifted out of sync.
# Log loudly and fall back to a generic denial so partners still
# get a structured response rather than a 500.
logger.error(
'check_entitlement: can_access_project returned False but no '
'reason matched for user=%s project=%s policy=%s. '
'Service is out of sync with project.authorization.access.',
getattr(user, 'pk', None), project.pk, policy,
)
missing = [NOT_AUTHENTICATED]

primary = next((r for r in REASON_PRIORITY if r in missing), missing[0])
missing_training_types = (
missing_training if TRAINING_INCOMPLETE in missing else []
)
return EntitlementResult(
allowed=False, reason_code=primary,
missing_requirements=missing,
missing_training_types=missing_training_types,
)
Loading
Loading