Skip to content
Merged
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
142 changes: 142 additions & 0 deletions nau_openedx_extensions/partner_integration/course_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
SSO Partner Integration course enrollment filters.

This module contains filters for validating SSO partner linkage during
course enrollment.
"""

import logging

from django.apps import apps
from openedx_filters import PipelineStep
from openedx_filters.learning.filters import CourseEnrollmentStarted

logger = logging.getLogger(__name__)


class FilterSSOPartnerAccountLink(PipelineStep):
"""
Validate that a user attempting to enroll has a completed SSO account link
with a partner that has access to the course.

This filter checks:
1. If the user has an SSOPartnerIntegration record (linked account).
2. If the partner client's base_security_scope allows access to the course.

If either check fails, enrollment is prevented with a Portuguese error message.

The partner's base_security_scope defines which courses they can access,
and is validated against the CourseOverview model fields.

Example usage:

Add the following configurations to your configuration file:

"OPEN_EDX_FILTERS_CONFIG": {
"org.openedx.learning.course.enrollment.started.v1": {
"fail_silently": false,
"pipeline": [
"nau_openedx_extensions.partner_integration.course_filters.FilterSSOPartnerAccountLink"
]
}
}
"""

def run_filter(self, user, course_key, mode): # pylint: disable=unused-argument, arguments-differ
"""
Filter implementation.

Validates SSO partner account link and partner course access.

Args:
user: The user attempting to enroll.
course_key: The course key for the course being enrolled in.
mode: The enrollment mode (unused).

Returns:
Empty dict if validation passes.

Raises:
CourseEnrollmentStarted.PreventEnrollment: If user is not linked to a partner
or the partner doesn't have access to the course.
"""
try:
SSOPartnerIntegration = apps.get_model(
"nau_openedx_extensions",
"SSOPartnerIntegration"
)
except LookupError:
logger.error("SSOPartnerIntegration model not found")
return {}

try:
sso_record = SSOPartnerIntegration.objects.get(user=user)
except SSOPartnerIntegration.DoesNotExist as exc:
logger.warning(
f"User {user.id} ({user.username}) has no SSO partner integration record"
)
exception_msg = (
"A ligação de conta com a plataforma do parceiro não foi concluída. "
"Por favor, complete o processo de ligação de conta antes de se inscrever."
)
raise CourseEnrollmentStarted.PreventEnrollment(exception_msg) from exc

partner_client = sso_record.partner_client
base_security_scope = partner_client.query_security_scope.get("base_security_scope", {})

if not base_security_scope:
logger.warning(
f"Partner {partner_client.name} has no base_security_scope configured"
)
exception_msg = (
"O parceiro de integração não tem acesso configurado para nenhum curso. "
"Por favor, contacte o suporte."
)
raise CourseEnrollmentStarted.PreventEnrollment(exception_msg)

if not FilterSSOPartnerAccountLink._is_course_allowed_for_partner(course_key, base_security_scope):
logger.warning(
f"Partner {partner_client.name} does not have access to course {course_key}"
)
exception_msg = (
"O parceiro de integração não tem permissão para inscrever utilizadores "
"neste curso. Por favor, contacte o suporte."
)
raise CourseEnrollmentStarted.PreventEnrollment(exception_msg)

logger.info(
f"User {user.id} ({user.username}) validated for enrollment in {course_key} "
f"via partner {partner_client.name}"
)
return {}

@staticmethod
def _is_course_allowed_for_partner(course_key, base_security_scope):
"""
Check if a course is allowed by the partner's base_security_scope.

The base_security_scope uses Django ORM lookup syntax to filter CourseOverview.
This method applies the security scope filters to determine if the course is allowed.

Args:
course_key: The course key to validate.
base_security_scope: A dictionary of Django ORM filters for CourseOverview.

Returns:
True if the course matches the security scope, False otherwise.
"""
try:
CourseOverview = apps.get_model("course_overviews", "CourseOverview")
except LookupError:
logger.error("CourseOverview model not found")
return False

try:
query = CourseOverview.objects.filter(**base_security_scope)
course_exists = query.filter(id=course_key).exists()
return course_exists
except Exception as e: # pylint: disable=broad-except
logger.error(
f"Error validating course {course_key} against security scope: {e}"
)
return False
240 changes: 240 additions & 0 deletions nau_openedx_extensions/partner_integration/docs/COURSE_FILTER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# SSO Partner Enrollment Filter

## Overview

The `FilterSSOPartnerAccountLink` is an Open edX enrollment filter that validates whether users attempting to enroll in courses have:

1. **Completed SSO account linking** - User has a `SSOPartnerIntegration` record linking them to a partner account
2. **Partner has course access** - The partner's `base_security_scope` grants access to the course being enrolled in

This filter ensures that only users with valid partner account links can enroll in courses authorized for that partner.

## Implementation Details

**File:** [`nau_openedx_extensions/partner_integration/course_filters.py`](./course_filters.py)

**Hook:** `org.openedx.learning.course.enrollment.started.v1`

The filter integrates with the Open edX Filter Framework using the `PipelineStep` base class and prevents enrollment by raising `CourseEnrollmentStarted.PreventEnrollment` with Portuguese error messages.

## How It Works

### Validation Flow

```
User attempts enrollment
Does user have SSOPartnerIntegration?
├─ NO → Raise error: "Account link not completed"
└─ YES → Continue
Does partner have base_security_scope?
├─ NO → Raise error: "Partner has no access configured"
└─ YES → Continue
Is course allowed by partner's scope?
├─ NO → Raise error: "Partner doesn't have permission for this course"
└─ YES → Enrollment allowed ✓
```

### Security Scope Matching

The filter uses Django ORM lookup syntax to match courses against the partner's `base_security_scope`:

```python
base_security_scope = {"org": "nau"} # Allows all courses in "nau" org
base_security_scope = {"org__in": ["nau", "fccn"]} # Allows multiple orgs
base_security_scope = {"id__in": ["course-v1:org+ABC+2024"]} # Specific courses
base_security_scope = {"display_name__icontains": "python"} # Name-based
```

The filter applies these filters to `CourseOverview` to determine access.

## Configuration

### Add to OPEN_EDX_FILTERS_CONFIG

In your Django settings file (`lms.env.yml` or equivalent), add the filter to the enrollment pipeline:

```yaml
OPEN_EDX_FILTERS_CONFIG:
org.openedx.learning.course.enrollment.started.v1:
fail_silently: false
pipeline:
- "nau_openedx_extensions.filters.pipeline.FilterEnrollmentByDomain"
- "nau_openedx_extensions.filters.pipeline.FilterEnrollmentRequireNIF"
- "nau_openedx_extensions.partner_integration.course_filters.FilterSSOPartnerAccountLink"
```

Or in JSON format:

```json
{
"OPEN_EDX_FILTERS_CONFIG": {
"org.openedx.learning.course.enrollment.started.v1": {
"fail_silently": false,
"pipeline": [
"nau_openedx_extensions.filters.pipeline.FilterEnrollmentByDomain",
"nau_openedx_extensions.filters.pipeline.FilterEnrollmentRequireNIF",
"nau_openedx_extensions.partner_integration.course_filters.FilterSSOPartnerAccountLink"
]
}
}
}
```

### Set fail_silently

- **`fail_silently: false`** (Recommended) - Any error in the filter chain blocks enrollment
- **`fail_silently: true`** - Errors are logged but don't block enrollment

## Error Messages

The filter provides clear Portuguese (pt-PT) error messages:

### No SSO Integration
> "A ligação de conta com a plataforma do parceiro não foi concluída. Por favor, complete o processo de ligação de conta antes de se inscrever."

Translation: "The account link with the partner platform has not been completed. Please complete the account linking process before enrolling."

### No Security Scope Configured
> "O parceiro de integração não tem acesso configurado para nenhum curso. Por favor, contacte o suporte."

Translation: "The integration partner has no access configured for any course. Please contact support."

### Course Not Allowed
> "O parceiro de integração não tem permissão para inscrever utilizadores neste curso. Por favor, contacte o suporte."

Translation: "The integration partner does not have permission to enroll users in this course. Please contact support."

## Database Models

The filter uses these related models:

### SSOPartnerIntegration
Links a local user to a partner account:
```python
{
"user": User, # The local edX user
"partner_client": PartnerAPIClient, # The partner
"external_user_id": "str" # Partner's user identifier
}
```

### PartnerAPIClient
Represents a partner client with access control:
```python
{
"name": "str", # Partner name
"client_id": "UUID",
"query_security_scope": {
"base_security_scope": {
# Django ORM filters against CourseOverview
},
"base_certificates_scope": {
# Django ORM filters against GeneratedCertificate
}
}
}
```

## Testing

Comprehensive tests are provided in [`tests/test_course_filters.py`](./tests/test_course_filters.py).

### Test Coverage

1. ✅ Valid SSO record with allowed course
2. ✅ User has no SSO record → error
3. ✅ Partner has no security scope → error
4. ✅ Course not in partner's scope → error
5. ✅ Multiple orgs in scope
6. ✅ Specific course IDs in scope
7. ✅ Error handling and logging

### Running Tests

```bash
# Run all filter tests
cd /openedx/nau-openedx-extensions
python -m pytest nau_openedx_extensions/partner_integration/tests/test_course_filters.py -v

# Run specific test
python -m pytest nau_openedx_extensions/partner_integration/tests/test_course_filters.py::FilterSSOPartnerAccountLinkTests::test_filter_passes_when_user_has_valid_sso_record_and_course_allowed -v
```

## Logging

The filter logs important events for observability:

```python
# Info: Successful validation
"User 123 (john.doe) validated for enrollment in course-v1:nau+ABC+2024 via partner NAU"

# Warning: No SSO record
"User 123 (john.doe) has no SSO partner integration record"

# Warning: No access
"Partner NAU does not have access to course course-v1:different+XYZ+2024"

# Error: Configuration/database issues
"Error validating course course-v1:nau+ABC+2024 against security scope: ..."
```

All logs use the logger: `nau_openedx_extensions.partner_integration.course_filters`

## Integration with SSO Flow

This filter works alongside the SSO integration module:

1. **SSO link creation** - `CustomAuthorizationView` creates `SSOPartnerIntegration` records
2. **Enrollment validation** - This filter validates those records during enrollment
3. **Access control** - Only allows enrollment if partner has been granted access to the course

## Performance Considerations

The filter runs Django ORM queries to:
1. Lookup `SSOPartnerIntegration` by user (indexed query)
2. Check course access using `base_security_scope` filters

Both operations are efficient and use database indexes on:
- `SSOPartnerIntegration.user` (OneToOne)
- `CourseOverview.org`, `CourseOverview.id` (indexed fields)

## Backwards Compatibility

The filter is **opt-in** and only affects enrollment when:
1. The filter is added to the `OPEN_EDX_FILTERS_CONFIG`
2. The user has an `SSOPartnerIntegration` record

Existing users without SSO integration records are not affected.

## Troubleshooting

### Filter Not Running

Check that:
1. Filter is in `OPEN_EDX_FILTERS_CONFIG` pipeline
2. `fail_silently` is set appropriately
3. Settings have been reloaded (restart LMS/CMS)

### All Users Blocked

Check:
1. User has `SSOPartnerIntegration` record (Django admin)
2. Partner's `base_security_scope` is configured
3. Course org/ID matches security scope

### Database Errors

Check:
1. `SSOPartnerIntegration` table exists (run migrations)
2. User and partner client records exist
3. Database connection is working

## See Also

- [Partner Integration Module README](./README.md)
- [SSOPartnerIntegration Model](./models.py)
- [PartnerAPIClient Configuration](./README.md#configuring-base_security_scope-and-base_certificates_scope)
- [Open edX Filters Documentation](https://github.com/openedx/openedx-filters)
Loading
Loading